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/, + ); +});