diff --git a/.github/workflows/test-bloblang-playground.yml b/.github/workflows/test-bloblang-playground.yml index b87f438b..603b58d4 100644 --- a/.github/workflows/test-bloblang-playground.yml +++ b/.github/workflows/test-bloblang-playground.yml @@ -8,10 +8,14 @@ on: - 'src/js/vendor/wasm_exec.js' - 'src/js/16-bloblang-interactive.js' - 'src/js/17-bloblang-yaml.js' + - 'src/js/19-property-tooltips.js' - 'src/css/bloblang-interactive.css' - 'src/static/bloblang-docs.json' - 'tests/bloblang-playground/**' - 'tests/bloblang-interactive/**' + - 'tests/negative-cache/**' + - 'package.json' + - 'package-lock.json' - 'gulpfile.js' - '.github/workflows/test-bloblang-playground.yml' pull_request: @@ -22,10 +26,14 @@ on: - 'src/js/vendor/wasm_exec.js' - 'src/js/16-bloblang-interactive.js' - 'src/js/17-bloblang-yaml.js' + - 'src/js/19-property-tooltips.js' - 'src/css/bloblang-interactive.css' - 'src/static/bloblang-docs.json' - 'tests/bloblang-playground/**' - 'tests/bloblang-interactive/**' + - 'tests/negative-cache/**' + - 'package.json' + - 'package-lock.json' - 'gulpfile.js' - '.github/workflows/test-bloblang-playground.yml' workflow_dispatch: @@ -99,6 +107,8 @@ jobs: run: npx gulp test:build - name: Test Interactive Features run: npm run test:interactive + - name: Test Negative Cache + run: npm run test:negative-cache - name: Upload playground test results uses: actions/upload-artifact@v4 if: always() @@ -111,4 +121,10 @@ jobs: with: name: test-results-interactive path: test-results-interactive.json + - name: Upload negative-cache test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-negative-cache + path: test-results-negative-cache.json diff --git a/package.json b/package.json index fea318c2..161c99a4 100644 --- a/package.json +++ b/package.json @@ -78,7 +78,8 @@ "test:headless": "node tests/bloblang-playground/test-runner.js", "test:playground": "npm run build:wasm && npm run test:headless", "test:interactive": "node tests/bloblang-interactive/test-runner.js", - "test:all": "npm run test:playground && npm run test:interactive", + "test:negative-cache": "node tests/negative-cache/test-runner.js", + "test:all": "npm run test:playground && npm run test:interactive && npm run test:negative-cache", "build:wasm": "cd blobl-editor/wasm && GOOS=js GOARCH=wasm go build -o ../../src/static/blobl.wasm .", "copy:wasm-exec": "cp \"$(go env GOROOT)/lib/wasm/wasm_exec.js\" src/js/vendor/", "serve:playground": "npx serve ." diff --git a/src/js/16-bloblang-interactive.js b/src/js/16-bloblang-interactive.js index 6aceb4fc..edb28624 100644 --- a/src/js/16-bloblang-interactive.js +++ b/src/js/16-bloblang-interactive.js @@ -72,6 +72,43 @@ ); } + /** + * Track Connect JSON URLs that recently returned HTTP 404/410, so a URL + * that is known not to exist (for example, a version whose JSON was never + * generated) is not re-requested on every page view. Transient failures + * (429, 5xx, network errors) are never recorded here. + */ + const FETCH_FAILURE_KEY = 'connect-json-fetch-failures'; + const FETCH_FAILURE_TTL = 60 * 60 * 1000; // 1 hour + + function readFetchFailures() { + try { + const parsed = JSON.parse(localStorage.getItem(FETCH_FAILURE_KEY) || '{}'); + const now = Date.now(); + const fresh = {}; + Object.keys(parsed).forEach((url) => { + if (now - parsed[url] < FETCH_FAILURE_TTL) fresh[url] = parsed[url]; + }); + return fresh; + } catch (e) { + return {}; + } + } + + function hasRecentFetchFailure(url) { + return url in readFetchFailures(); + } + + function markFetchFailure(url) { + try { + const failures = readFetchFailures(); + failures[url] = Date.now(); + localStorage.setItem(FETCH_FAILURE_KEY, JSON.stringify(failures)); + } catch (e) { + // localStorage not available + } + } + /** * Parse a Bloblang snippet into mapping, input, and metadata sections. * Looks for # In: and # Meta: comment directives. @@ -178,9 +215,14 @@ } /** - * Try to fetch Connect JSON - uses meta tag URL or falls back to version-based path + * Try to fetch Connect JSON. The connect-json-url meta tag - resolved at + * build time against the attachments that actually exist in the catalog + * (set-available-attachment-versions extension in + * docs-extensions-and-macros) - is the only production source: only the + * newest connect-.json is hosted, so guessing other versions can + * only produce 404s. */ - async function tryFetchConnectJSON(version) { + async function tryFetchConnectJSON() { try { let url; @@ -189,18 +231,22 @@ const rootPath = typeof uiRootPath !== 'undefined' ? uiRootPath : '/_'; url = `${rootPath}/connect.json`; } else { - // Production: try meta tag URL first (resolved by Antora) url = getConnectJsonUrl(); - // Fallback if meta tag not set - if (!url) { - url = `/redpanda-connect/components/_attachments/connect-${version}.json`; + if (!url || hasRecentFetchFailure(url)) { + return null; } } const response = await fetch(url); if (!response.ok) { + // Only mark deterministic missing-resource responses (404/410). + // Transient failures (429, 5xx) are not cached, so the next page + // view retries them. + if (!isPreviewMode() && (response.status === 404 || response.status === 410)) { + markFetchFailure(url); + } return null; } @@ -288,54 +334,6 @@ return docs; } - /** - * Get Connect version from cache or fetch from antora.yml - * Caches in localStorage for 1 hour to avoid repeated fetches - */ - async function getConnectVersion() { - var CACHE_KEY = 'bloblang-connect-version'; - var CACHE_TTL = 60 * 60 * 1000; // 1 hour - - // Check cache first - try { - var cached = localStorage.getItem(CACHE_KEY); - if (cached) { - var parsed = JSON.parse(cached); - if (Date.now() - parsed.timestamp < CACHE_TTL) { - return parsed.version; - } - } - } catch (e) { - // localStorage not available or parse error - } - - // Fetch from antora.yml (no rate limits, CDN-served) - try { - var resp = await fetch('https://raw.githubusercontent.com/redpanda-data/rp-connect-docs/main/antora.yml'); - if (resp.ok) { - var yaml = await resp.text(); - var match = yaml.match(/latest-connect-version:\s*['"]?(\d+\.\d+\.\d+)/); - if (match) { - var version = match[1]; - // Cache the result - try { - localStorage.setItem(CACHE_KEY, JSON.stringify({ - version: version, - timestamp: Date.now() - })); - } catch (e) { - // localStorage not available - } - return version; - } - } - } catch (e) { - // Silent fail - will use fallback versions - } - - return null; - } - /** * Load Bloblang documentation from Connect JSON */ @@ -358,20 +356,7 @@ // Skip remote fetches on docs-ui preview site - JSON files don't exist there if (!isDocsUiPreview) { - // Try to get latest version from cached antora.yml - var latestVersion = await getConnectVersion(); - if (latestVersion) { - data = await tryFetchConnectJSON(latestVersion); - } - - // Fallback: try known recent versions - if (!data) { - var fallbackVersions = ['4.79.0', '4.78.0', '4.77.0', '4.76.0', '4.75.0']; - for (var i = 0; i < fallbackVersions.length; i++) { - data = await tryFetchConnectJSON(fallbackVersions[i]); - if (data) break; - } - } + data = await tryFetchConnectJSON(); } // Transform data to our format diff --git a/src/js/19-property-tooltips.js b/src/js/19-property-tooltips.js index e447c588..0c4d1074 100644 --- a/src/js/19-property-tooltips.js +++ b/src/js/19-property-tooltips.js @@ -56,6 +56,65 @@ ) } + var CACHE_KEY = 'redpanda-properties-cache' + var CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours + // Missing-resource markers live under their own key so they can never + // overwrite a valid cached dataset, and are only written for HTTP 404/410 + // (the resource does not exist). Transient failures (5xx, offline, parse + // errors) are not cached at all: the next page view simply retries. + // The value is a per-URL map ({url: {version, timestamp}}) because the + // properties JSON URL and tag vary per doc version: a user browsing + // several versions with missing JSON must not thrash a single marker. + var MISSING_CACHE_KEY = 'redpanda-properties-missing' + var MISSING_CACHE_TTL = 60 * 60 * 1000 // 1 hour: re-check missing resources so a fix deploy is picked up + + /** + * Read the missing-resource map, dropping expired or malformed entries + */ + function readMissingMarkers () { + try { + var parsed = JSON.parse(localStorage.getItem(MISSING_CACHE_KEY) || '{}') + if (!parsed || typeof parsed !== 'object') return {} + var now = Date.now() + var fresh = {} + Object.keys(parsed).forEach(function (markedUrl) { + var entry = parsed[markedUrl] + if (entry && typeof entry.timestamp === 'number' && now - entry.timestamp < MISSING_CACHE_TTL) { + fresh[markedUrl] = entry + } + }) + return fresh + } catch (e) { + return {} + } + } + + function hasMissingMarker (url, version) { + var entry = readMissingMarkers()[url] + return !!(entry && entry.version === version) + } + + function markMissing (url, version) { + try { + var markers = readMissingMarkers() + markers[url] = { version: version, timestamp: Date.now() } + localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(markers)) + } catch (e) { + // localStorage full or unavailable + } + } + + function clearMissingMarker (url) { + try { + var markers = readMissingMarkers() + delete markers[url] + // Always rewrite: this also prunes expired entries from storage + localStorage.setItem(MISSING_CACHE_KEY, JSON.stringify(markers)) + } catch (e) { + // localStorage full or unavailable + } + } + /** * Fetch properties JSON with caching */ @@ -90,12 +149,12 @@ } } - var CACHE_KEY = 'redpanda-properties-cache' - var CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours // Use latest-redpanda-tag meta tag for cache versioning var cacheVersion = getLatestRedpandaTag() || 'unknown' - // Check localStorage cache (skip in preview mode for easier testing) + // Check localStorage cache (skip in preview mode for easier testing). + // The dataset cache and the missing-marker each get their own try/catch + // so a corrupt entry in one cannot disable the other check. if (!isPreviewMode()) { try { var cached = localStorage.getItem(CACHE_KEY) @@ -114,12 +173,25 @@ } catch (e) { // Ignore cache errors } + if (hasMissingMarker(url, cacheVersion)) { + // The resource is known not to exist for this version: resolve to + // an empty lookup instead of re-requesting a 404 on every page view + propertiesData = {} + propertiesLoading = false + propertiesLoadQueue.forEach(function (resolve) { + resolve(propertiesData) + }) + propertiesLoadQueue = [] + return Promise.resolve(propertiesData) + } } return fetch(url) .then(function (response) { if (!response.ok) { - throw new Error('HTTP ' + response.status) + var httpError = new Error('HTTP ' + response.status) + httpError.status = response.status + throw httpError } return response.json() }) @@ -128,6 +200,9 @@ // Cache the result (skip in preview mode) if (!isPreviewMode()) { + // Clear the missing-marker before the cache write: if setItem + // throws on quota, a stale marker must not outlive a successful fetch + clearMissingMarker(url) try { localStorage.setItem( CACHE_KEY, @@ -182,6 +257,9 @@ } console.warn('Property tooltips: Failed to load properties data:', error) + if (!isPreviewMode() && (error.status === 404 || error.status === 410)) { + markMissing(url, cacheVersion) + } propertiesLoading = false propertiesData = {} propertiesLoadQueue.forEach(function (resolve) { @@ -458,10 +536,12 @@ function tryInit (retriesLeft) { if (window.tippy) { - // Use requestIdleCallback for non-blocking processing - var schedule = window.requestIdleCallback || function (cb) { - setTimeout(cb, 100) - } + // Use requestIdleCallback for non-blocking processing, with a + // timeout so tooltips still attach promptly when the main thread + // never goes idle (busy pages, loaded CI runners) + var schedule = window.requestIdleCallback + ? function (cb) { window.requestIdleCallback(cb, { timeout: 500 }) } + : function (cb) { setTimeout(cb, 100) } schedule(function () { processCodeElements() }) diff --git a/src/partials/bloblang-playground.hbs b/src/partials/bloblang-playground.hbs index 4a122697..88ade6c4 100644 --- a/src/partials/bloblang-playground.hbs +++ b/src/partials/bloblang-playground.hbs @@ -497,34 +497,39 @@ async function fetchConnectCompletions() { return extractCompletionsFromConnectData(mockConnectData); } - // Try to get latest version from GitHub releases + // Prefer the connect-json-url meta tag: it is resolved at build time + // against the attachments that actually exist in the catalog + // (set-available-attachment-versions extension), so it never points at + // a version whose JSON was not generated. + const meta = document.querySelector('meta[name="connect-json-url"]'); + const metaUrl = meta && meta.content; + if (metaUrl && !metaUrl.startsWith('attachment$') && !metaUrl.startsWith('page$')) { + const result = await tryFetchConnectJSON(metaUrl); + if (result.length > 0) return result; + } + + // Secondary: derive the URL from the latest GitHub release. Only the + // newest connect-.json is hosted, so no other version is worth + // guessing when this fails. try { const releasesResp = await fetch('https://api.github.com/repos/redpanda-data/connect/releases/latest'); if (releasesResp.ok) { const release = await releasesResp.json(); const version = release.tag_name.replace(/^v/, ''); // Remove 'v' prefix if present - const result = await tryFetchConnectJSON(version); + const result = await tryFetchConnectJSON(`/connect/components/_attachments/connect-${version}.json`); if (result.length > 0) { return result; } } } catch (e) { - // Silently fall back to known versions - } - - // Fallback: try known recent versions - const fallbackVersions = ['4.78.0', '4.77.0', '4.76.0', '4.75.0']; - for (const version of fallbackVersions) { - const result = await tryFetchConnectJSON(version); - if (result.length > 0) return result; + // Silently fall back to static completions } return []; } -async function tryFetchConnectJSON(version) { +async function tryFetchConnectJSON(url) { try { - const url = `/connect/components/_attachments/connect-${version}.json`; const response = await fetch(url); if (!response.ok) { diff --git a/tests/negative-cache/README.md b/tests/negative-cache/README.md new file mode 100644 index 00000000..0380970f --- /dev/null +++ b/tests/negative-cache/README.md @@ -0,0 +1,34 @@ +# Negative-cache tests + +Verifies the localStorage negative caching for tooltip data fetches in +`src/js/16-bloblang-interactive.js` (Connect JSON) and +`src/js/19-property-tooltips.js` (properties JSON): + +- HTTP `404`/`410` responses are negative-cached for 1 hour, so a missing + JSON file is not re-requested on every page view (the 404-storm fix). + Both caches are keyed per URL, so browsing multiple doc versions with + missing JSON cannot thrash a shared marker. +- Transient failures (`429`, `5xx`, network errors, JSON parse errors) are + **not** cached and are retried on the next page view. +- Markers expire after their TTL, and a successful fetch clears the + matching properties missing-marker and populates the dataset cache. +- Preview mode never writes markers and always retries. + +Preview mode means the hostname is `localhost`, `127.0.0.1`, or contains +`docs-ui.netlify.app`. Content-repo deploy previews (other `*.netlify.app` +hosts) are treated as production, so a 404 seen there is negative-cached +for up to 1 hour on that origin; clear the browser's localStorage to retry +sooner. + +The runner uses Puppeteer request interception to serve a synthetic test +page from a fake production hostname (`docs.example.test`) — and from +`localhost` for the preview-mode scenario — controlling the HTTP status of +each JSON response. No real network requests are made. + +## Run + +```sh +npm run test:negative-cache +``` + +Results are also written to `test-results-negative-cache.json`. diff --git a/tests/negative-cache/test-runner.js b/tests/negative-cache/test-runner.js new file mode 100644 index 00000000..8024ab63 --- /dev/null +++ b/tests/negative-cache/test-runner.js @@ -0,0 +1,344 @@ +const puppeteer = require('puppeteer'); +const fs = require('fs'); +const path = require('path'); + +/** + * Test runner for JSON-fetch negative caching + * + * Verifies that: + * - HTTP 404 and 410 responses for Connect JSON and properties JSON are + * negative-cached in localStorage, so they are not re-requested on every + * page view + * - Transient failures (429, 5xx, network errors, JSON parse errors) are NOT + * cached and are retried on the next page view + * - Markers expire after their TTL and successful fetches clear them + * - Preview mode (localhost) never writes markers and always retries + * + * The negative cache is disabled in preview mode (localhost / 127.0.0.1 / + * docs-ui.netlify.app), so most tests use Puppeteer request interception to + * serve the page from a fake production hostname without touching the + * network; the preview-mode scenario serves the same page from localhost. + */ + +const PROD_HOST = 'http://docs.example.test'; +const PREVIEW_HOST = 'http://localhost'; +const CONNECT_PATH = '/redpanda-connect/components/_attachments/connect-9.9.9.json'; +const PROPERTIES_PATH = '/current/reference/properties/_attachments/redpanda-properties-v9.9.9.json'; +// In preview mode the Connect fetch ignores the meta tag and uses the static +// UI path instead +const CONNECT_PREVIEW_PATH = '/_/connect.json'; + +const BLOBLANG_JS = fs.readFileSync(path.resolve(__dirname, '../../src/js/16-bloblang-interactive.js'), 'utf8'); +const PROPERTY_JS = fs.readFileSync(path.resolve(__dirname, '../../src/js/19-property-tooltips.js'), 'utf8'); + +const CONNECT_JSON_BODY = JSON.stringify({ + 'bloblang-functions': [], + 'bloblang-methods': [] +}); + +const PROPERTIES_JSON_BODY = JSON.stringify({ + properties: { + log_retention_ms: { + name: 'log_retention_ms', + type: 'integer', + description: 'Test property' + } + } +}); + +const TEST_PAGE = ` + + + + + + + + + +
+
root = this
+

log_retention_ms

+
+ + + +`; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function runTests() { + let browser = null; + const results = []; + let failures = 0; + + function assert(name, condition, detail) { + const passed = !!condition; + if (!passed) failures++; + results.push({ name, passed, detail: detail || '' }); + console.log(` ${passed ? 'āœ… PASS' : 'āŒ FAIL'}: ${name}${detail ? ` (${detail})` : ''}`); + } + + try { + console.log('šŸš€ Launching browser...'); + browser = await puppeteer.launch({ + headless: true, + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--disable-dev-shm-usage', + '--disable-extensions' + ], + timeout: 30000 + }); + + const page = await browser.newPage(); + + // Surface page-side failures in the runner output for debugging + page.on('pageerror', (err) => console.log(` āš ļø pageerror: ${err.message}`)); + page.on('console', (msg) => { + if (msg.type() === 'error' || msg.type() === 'warning') { + console.log(` āš ļø console.${msg.type()}: ${msg.text().slice(0, 200)}`); + } + }); + + // Per-scenario response behavior: an HTTP status number, 'abort' + // (network error), or 'badjson' (200 with a non-JSON body) + const state = { + connect: 404, + properties: 404, + connectRequests: 0, + propertiesRequests: 0 + }; + + function respondJson(req, behavior, successBody) { + if (behavior === 'abort') return req.abort('failed'); + if (behavior === 'badjson') { + return req.respond({ status: 200, contentType: 'application/json', body: 'this is not json' }); + } + if (behavior === 200) { + return req.respond({ status: 200, contentType: 'application/json', body: successBody }); + } + return req.respond({ status: behavior, contentType: 'text/plain', body: 'error' }); + } + + await page.setRequestInterception(true); + page.on('request', (req) => { + let url; + try { + url = new URL(req.url()); + } catch (e) { + return req.respond({ status: 404, contentType: 'text/plain', body: 'bad url' }); + } + + if (url.origin === PROD_HOST || url.origin === PREVIEW_HOST) { + if (url.pathname === '/test.html') { + return req.respond({ status: 200, contentType: 'text/html', body: TEST_PAGE }); + } + if (url.pathname === '/js/16-bloblang-interactive.js') { + return req.respond({ status: 200, contentType: 'application/javascript', body: BLOBLANG_JS }); + } + if (url.pathname === '/js/19-property-tooltips.js') { + return req.respond({ status: 200, contentType: 'application/javascript', body: PROPERTY_JS }); + } + if (url.pathname === CONNECT_PATH || url.pathname === CONNECT_PREVIEW_PATH) { + state.connectRequests++; + return respondJson(req, state.connect, CONNECT_JSON_BODY); + } + if (url.pathname === PROPERTIES_PATH) { + state.propertiesRequests++; + return respondJson(req, state.properties, PROPERTIES_JSON_BODY); + } + } + + // Anything else (bloblang-docs.json fallback, favicon, ...) is a 404 + return req.respond({ status: 404, contentType: 'text/plain', body: 'not found' }); + }); + + // Loads the test page from the given origin and returns how many + // requests hit each JSON URL during that page view + async function loadPage(origin = PROD_HOST) { + const before = { + connect: state.connectRequests, + properties: state.propertiesRequests + }; + await page.goto(`${origin}/test.html`, { waitUntil: 'networkidle0', timeout: 30000 }); + // Property tooltips fetch via requestIdleCallback; give async work + // time to settle beyond networkidle0 + await page.waitForNetworkIdle({ idleTime: 600, timeout: 5000 }).catch(() => {}); + await sleep(500); + return { + connect: state.connectRequests - before.connect, + properties: state.propertiesRequests - before.properties + }; + } + + function readStorage(key) { + return page.evaluate((k) => localStorage.getItem(k), key); + } + + async function readMissingMarkers() { + return JSON.parse(await readStorage('redpanda-properties-missing') || '{}'); + } + + async function readConnectFailures() { + return JSON.parse(await readStorage('connect-json-fetch-failures') || '{}'); + } + + // Rewrites all stored marker timestamps to two hours ago + function expireMarkers() { + return page.evaluate(() => { + const twoHoursAgo = Date.now() - 2 * 60 * 60 * 1000; + const failures = JSON.parse(localStorage.getItem('connect-json-fetch-failures') || '{}'); + Object.keys(failures).forEach((url) => { failures[url] = twoHoursAgo; }); + localStorage.setItem('connect-json-fetch-failures', JSON.stringify(failures)); + const missing = JSON.parse(localStorage.getItem('redpanda-properties-missing') || '{}'); + Object.keys(missing).forEach((url) => { missing[url].timestamp = twoHoursAgo; }); + localStorage.setItem('redpanda-properties-missing', JSON.stringify(missing)); + }); + } + + // --- Scenario 1: 404 responses are negative-cached --- + console.log('\nšŸ“‹ Scenario 1: 404 responses are negative-cached'); + state.connect = 404; + state.properties = 404; + const load1 = await loadPage(); + assert('404: Connect JSON requested once on first view', load1.connect === 1, `got ${load1.connect}`); + assert('404: properties JSON requested once on first view', load1.properties === 1, `got ${load1.properties}`); + assert('404: Connect failure marker written', CONNECT_PATH in await readConnectFailures()); + assert('404: properties missing-marker written for URL', PROPERTIES_PATH in await readMissingMarkers()); + + const load2 = await loadPage(); + assert('404: Connect JSON NOT re-requested on next view', load2.connect === 0, `got ${load2.connect}`); + assert('404: properties JSON NOT re-requested on next view', load2.properties === 0, `got ${load2.properties}`); + + // --- Scenario 2: markers expire after their TTL --- + console.log('\nšŸ“‹ Scenario 2: markers expire after their TTL'); + await expireMarkers(); + const load3 = await loadPage(); + assert('Expiry: Connect JSON re-requested after TTL', load3.connect === 1, `got ${load3.connect}`); + assert('Expiry: properties JSON re-requested after TTL', load3.properties === 1, `got ${load3.properties}`); + + // --- Scenario 3: 410 responses are negative-cached --- + console.log('\nšŸ“‹ Scenario 3: 410 responses are negative-cached'); + await page.evaluate(() => localStorage.clear()); + state.connect = 410; + state.properties = 410; + const load410a = await loadPage(); + assert('410: Connect JSON requested once on first view', load410a.connect === 1, `got ${load410a.connect}`); + assert('410: properties JSON requested once on first view', load410a.properties === 1, `got ${load410a.properties}`); + const load410b = await loadPage(); + assert('410: Connect JSON NOT re-requested on next view', load410b.connect === 0, `got ${load410b.connect}`); + assert('410: properties JSON NOT re-requested on next view', load410b.properties === 0, `got ${load410b.properties}`); + + // --- Scenario 4: transient failures (503, 429) are retried --- + console.log('\nšŸ“‹ Scenario 4: transient HTTP failures are retried'); + await page.evaluate(() => localStorage.clear()); + state.connect = 503; + state.properties = 503; + const load4 = await loadPage(); + assert('503: Connect JSON requested exactly once', load4.connect === 1, `got ${load4.connect}`); + assert('503: properties JSON requested exactly once', load4.properties === 1, `got ${load4.properties}`); + assert('503: no Connect failure marker written', !(CONNECT_PATH in await readConnectFailures())); + assert('503: no properties missing-marker written', !(PROPERTIES_PATH in await readMissingMarkers())); + + state.connect = 429; + state.properties = 429; + const load5 = await loadPage(); + assert('429: Connect JSON retried on next view', load5.connect === 1, `got ${load5.connect}`); + assert('429: properties JSON retried on next view', load5.properties === 1, `got ${load5.properties}`); + assert('429: no Connect failure marker written', !(CONNECT_PATH in await readConnectFailures())); + assert('429: no properties missing-marker written', !(PROPERTIES_PATH in await readMissingMarkers())); + + // --- Scenario 5: network errors are retried --- + console.log('\nšŸ“‹ Scenario 5: network errors are retried'); + state.connect = 'abort'; + state.properties = 'abort'; + const load6 = await loadPage(); + assert('Network error: Connect JSON requested', load6.connect === 1, `got ${load6.connect}`); + assert('Network error: properties JSON requested', load6.properties === 1, `got ${load6.properties}`); + const load7 = await loadPage(); + assert('Network error: Connect JSON retried on next view', load7.connect === 1, `got ${load7.connect}`); + assert('Network error: properties JSON retried on next view', load7.properties === 1, `got ${load7.properties}`); + assert('Network error: no Connect failure marker written', !(CONNECT_PATH in await readConnectFailures())); + assert('Network error: no properties missing-marker written', !(PROPERTIES_PATH in await readMissingMarkers())); + + // --- Scenario 6: JSON parse errors are retried --- + console.log('\nšŸ“‹ Scenario 6: JSON parse errors are retried'); + state.connect = 'badjson'; + state.properties = 'badjson'; + const load8 = await loadPage(); + assert('Parse error: Connect JSON requested', load8.connect === 1, `got ${load8.connect}`); + assert('Parse error: properties JSON requested', load8.properties === 1, `got ${load8.properties}`); + const load9 = await loadPage(); + assert('Parse error: Connect JSON retried on next view', load9.connect === 1, `got ${load9.connect}`); + assert('Parse error: properties JSON retried on next view', load9.properties === 1, `got ${load9.properties}`); + assert('Parse error: no Connect failure marker written', !(CONNECT_PATH in await readConnectFailures())); + assert('Parse error: no properties missing-marker written', !(PROPERTIES_PATH in await readMissingMarkers())); + + // --- Scenario 7: success clears markers and populates the cache --- + console.log('\nšŸ“‹ Scenario 7: success clears markers and populates the cache'); + await page.evaluate(() => localStorage.clear()); + state.connect = 404; + state.properties = 404; + await loadPage(); // writes fresh markers + state.connect = 200; + state.properties = 200; + const load10 = await loadPage(); + assert('Fresh markers still suppress fetches', load10.connect === 0 && load10.properties === 0, + `connect ${load10.connect}, properties ${load10.properties}`); + + await expireMarkers(); + const load11 = await loadPage(); + assert('Success: Connect JSON fetched', load11.connect === 1, `got ${load11.connect}`); + assert('Success: properties JSON fetched', load11.properties === 1, `got ${load11.properties}`); + assert('Success: properties missing-marker cleared', !(PROPERTIES_PATH in await readMissingMarkers())); + // Assert the cached lookup content, not just the key's existence: + // an empty lookup (JSON shape drift, buildPropertyLookup regression) + // would still write the cache key and clear the marker + const cachedLookup = JSON.parse(await readStorage('redpanda-properties-cache') || '{}'); + const cachedProp = cachedLookup.data && cachedLookup.data.log_retention_ms; + assert('Success: cached lookup contains the property with its fields', + !!(cachedProp && cachedProp.type === 'integer' && cachedProp.description === 'Test property'), + (await readStorage('redpanda-properties-cache') || 'null').slice(0, 120)); + + const load12 = await loadPage(); + assert('Success: properties served from cache on next view', load12.properties === 0, `got ${load12.properties}`); + + // --- Scenario 8: preview mode (localhost) never negative-caches --- + console.log('\nšŸ“‹ Scenario 8: preview mode (localhost) never negative-caches'); + state.connect = 404; + state.properties = 404; + const load13 = await loadPage(PREVIEW_HOST); + assert('Preview: Connect JSON requested', load13.connect === 1, `got ${load13.connect}`); + assert('Preview: properties JSON requested', load13.properties === 1, `got ${load13.properties}`); + const load14 = await loadPage(PREVIEW_HOST); + assert('Preview: Connect JSON re-requested on next view', load14.connect === 1, `got ${load14.connect}`); + assert('Preview: properties JSON re-requested on next view', load14.properties === 1, `got ${load14.properties}`); + assert('Preview: no Connect failure marker written', Object.keys(await readConnectFailures()).length === 0); + assert('Preview: no properties missing-marker written', Object.keys(await readMissingMarkers()).length === 0); + + // --- Summary --- + const total = results.length; + const passed = total - failures; + console.log(`\nšŸ“Š Results: ${passed}/${total} passed`); + + fs.writeFileSync( + path.resolve(__dirname, '../../test-results-negative-cache.json'), + JSON.stringify({ total, passed, failed: failures, results }, null, 2) + ); + + if (failures > 0) { + process.exitCode = 1; + } + } catch (error) { + console.error('šŸ’„ Test runner error:', error); + process.exitCode = 1; + } finally { + if (browser) await browser.close(); + } +} + +runTests();