From d59727ba7c90542848b124f5d4033e648b9f9396 Mon Sep 17 00:00:00 2001 From: SandObserver <260779319+SandObserver@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:21:00 -0400 Subject: [PATCH] Give demo widget pages their own stamps The image serves stamped widget pages as immutable for a year. The static demo changes those pages, so a browser that had visited the image kept the cached pages without the replay script and every widget request failed. The build now restamps each widget page in the recorded widget list, and verification fails when a widget page keeps the image's address. --- scripts/demo/build.js | 39 ++++++++++++++++++++++++++++++++++-- scripts/demo/tour.js | 27 +++++++++++++++++++++++-- test/repo/demo-build.test.js | 33 ++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/scripts/demo/build.js b/scripts/demo/build.js index e81fdc8e..45c43c64 100644 --- a/scripts/demo/build.js +++ b/scripts/demo/build.js @@ -146,6 +146,31 @@ function redirectRules(routes) { return `${lines.join('\n')}\n`; } +/* The image tells browsers to keep a stamped widget page for a year. The demo + changes that page, so it needs a stamp of its own, or a browser that saw the + image keeps the page without the replay script. + @param {Record} fixed + @param {(path: string) => string|null} readPage */ +function restampWidgets(fixed, readPage) { + const key = 'GET /api/widgets'; + if (!fixed[key]) throw new Error(`${key} was not recorded`); + const [status, body] = fixed[key]; + const list = JSON.parse(body); + let stamped = 0; + for (const w of list.widgets || []) { + for (const file of Object.keys(w.entryVersions || {})) { + const page = readPage(`/widgets/${w.name}/${file}`); + if (page === null) throw new Error(`/widgets/${w.name}/${file} is listed but not in the web root`); + const fresh = hash(`demo-replay\n${page}`).slice(0, 8); + if (fresh === w.entryVersions[file]) throw new Error(`/widgets/${w.name}/${file} kept the image's stamp`); + w.entryVersions[file] = fresh; + stamped++; + } + } + if (!stamped) throw new Error(`${key} lists no stamped widget pages; the demo would reuse cached image pages`); + return { ...fixed, [key]: [status, JSON.stringify(list)] }; +} + /** @param {string} html @param {string} src */ function injectScript(html, src) { const out = html.replace(/]*)?>/i, m => `${m}`); @@ -159,7 +184,8 @@ function build(webroot, recording, out) { const files = listFiles(out); const { fixed: still, varying } = classify(recording.frames); - const fixed = { ...recording.once, ...still }; + const readPage = p => (fs.existsSync(path.join(out, p)) ? fs.readFileSync(path.join(out, p), 'utf8') : null); + const fixed = restampWidgets({ ...recording.once, ...still }, readPage); const bad = Object.entries(fixed).filter(([, [status]]) => status === 429); if (bad.length) throw new Error(`rate limited while recording: ${bad.map(([k]) => k).join(', ')}`); @@ -206,7 +232,16 @@ function build(webroot, recording, out) { return { pages: files.filter(p => p.endsWith('.html')).length, fixed: Object.keys(fixed).length, varying }; } -module.exports = { classify, splitSeries, fallbackRoutes, headerRules, redirectRules, injectScript, build }; +module.exports = { + classify, + splitSeries, + fallbackRoutes, + headerRules, + redirectRules, + injectScript, + restampWidgets, + build, +}; if (require.main === module) { const [webroot, recordingFile, out] = process.argv.slice(2); diff --git a/scripts/demo/tour.js b/scripts/demo/tour.js index fefdbd1a..8fbf09bf 100644 --- a/scripts/demo/tour.js +++ b/scripts/demo/tour.js @@ -26,6 +26,7 @@ async function run(origin) { const problems = []; const errors = new Set(); const outcomes = new Set(); + const widgetPages = new Set(); let changed = false; const open = async view => { @@ -54,6 +55,10 @@ async function run(origin) { for (const view of VIEWS) { const { ctx, page } = await open(view); await page.goto(`${origin}/`, { waitUntil: 'networkidle' }); + for (const frame of page.frames()) { + const u = new URL(frame.url(), origin); + if (u.pathname.startsWith('/widgets/')) widgetPages.add(u.pathname + u.search); + } const before = await widgetText(page); await page.waitForTimeout(25_000); if ((await widgetText(page)) !== before) changed = true; @@ -154,7 +159,14 @@ async function run(origin) { } await browser.close(); - return { exchanges, problems, errors: [...errors].sort(), outcomes: [...outcomes].sort(), changed }; + return { + exchanges, + problems, + errors: [...errors].sort(), + outcomes: [...outcomes].sort(), + widgetPages: [...widgetPages].sort(), + changed, + }; } /** @param {import('@playwright/test').Page} page */ @@ -206,7 +218,12 @@ async function main() { } fs.writeFileSync( tourFile, - JSON.stringify({ exchanges: result.exchanges, errors: result.errors, outcomes: result.outcomes }), + JSON.stringify({ + exchanges: result.exchanges, + errors: result.errors, + outcomes: result.outcomes, + widgetPages: result.widgetPages, + }), ); console.log(`${result.exchanges.length} API exchanges, ${result.errors.length} console errors on the container`); console.log(result.outcomes.join('\n')); @@ -223,6 +240,12 @@ async function main() { if (!seen.has(o)) problems.push(`the container showed "${o}", the static demo did not`); for (const o of result.outcomes) if (!container.outcomes.includes(o)) problems.push(`the static demo showed "${o}"`); + if (!result.widgetPages.length) problems.push('the static demo loaded no widget page'); + for (const p of result.widgetPages) { + if (container.widgetPages.includes(p)) { + problems.push(`${p} is the image's address, so a browser that saw the image keeps its cached page`); + } + } if (!result.changed) problems.push('no widget changed in 25 seconds'); problems.push(...(await checkHeaders(origin, JSON.parse(fs.readFileSync(recordingFile, 'utf8'))))); if (problems.length) { diff --git a/test/repo/demo-build.test.js b/test/repo/demo-build.test.js index 9d72a638..3471fe82 100644 --- a/test/repo/demo-build.test.js +++ b/test/repo/demo-build.test.js @@ -9,6 +9,7 @@ const { headerRules, redirectRules, injectScript, + restampWidgets, } = require('../../scripts/demo/build'); const { writeRoutes, routePattern } = require('../../scripts/demo/source'); @@ -159,3 +160,35 @@ test('every write route of the API is found, with its parameters', () => { assert.match('/api/widget-options/w-stats', new RegExp(routePattern('/api/widget-options/:id'))); assert.doesNotMatch('/api/widget-options/a/b', new RegExp(routePattern('/api/widget-options/:id'))); }); + +const widgetList = versions => + ok({ widgets: Object.entries(versions).map(([name, entryVersions]) => ({ name, entryVersions })) }); + +test('every widget page gets a stamp of its own, so cached image pages are not reused', () => { + const pages = { '/widgets/clock/analog.html': 'clock', '/widgets/dns/index.html': 'dns' }; + const before = { clock: { 'analog.html': 'b640345d' }, dns: { 'index.html': 'b5911a46' } }; + const read = p => pages[p] ?? null; + const fixed = restampWidgets({ 'GET /api/widgets': widgetList(before), 'GET /api/config': ok({}) }, read); + const after = Object.fromEntries( + JSON.parse(fixed['GET /api/widgets'][1]).widgets.map(w => [w.name, w.entryVersions]), + ); + assert.notEqual(after.clock['analog.html'], 'b640345d'); + assert.notEqual(after.dns['index.html'], 'b5911a46'); + assert.match(after.clock['analog.html'], /^[0-9a-f]{8}$/); + assert.deepEqual(fixed['GET /api/config'], ok({}), 'other answers stay as recorded'); + const again = restampWidgets({ 'GET /api/widgets': widgetList(before) }, read); + assert.deepEqual(again['GET /api/widgets'], fixed['GET /api/widgets'], 'the same page gets the same stamp'); + pages['/widgets/clock/analog.html'] += ' changed'; + const changed = restampWidgets({ 'GET /api/widgets': widgetList(before) }, read); + assert.notDeepEqual(changed['GET /api/widgets'], fixed['GET /api/widgets'], 'a changed page gets a new stamp'); +}); + +test('a widget list the build cannot restamp stops the build', () => { + const read = () => ''; + assert.throws(() => restampWidgets({}, read), /was not recorded/); + assert.throws(() => restampWidgets({ 'GET /api/widgets': widgetList({}) }, read), /no stamped widget pages/); + assert.throws( + () => restampWidgets({ 'GET /api/widgets': widgetList({ clock: { 'analog.html': 'b640345d' } }) }, () => null), + /not in the web root/, + ); +});