Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 37 additions & 2 deletions scripts/demo/build.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, [number, string]>} 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(/<head(\s[^>]*)?>/i, m => `${m}<script src="${src}"></script>`);
Expand All @@ -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(', ')}`);

Expand Down Expand Up @@ -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);
Expand Down
27 changes: 25 additions & 2 deletions scripts/demo/tour.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -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'));
Expand All @@ -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) {
Expand Down
33 changes: 33 additions & 0 deletions test/repo/demo-build.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
headerRules,
redirectRules,
injectScript,
restampWidgets,
} = require('../../scripts/demo/build');
const { writeRoutes, routePattern } = require('../../scripts/demo/source');

Expand Down Expand Up @@ -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': '<head></head>clock', '/widgets/dns/index.html': '<head></head>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 = () => '<head></head>';
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/,
);
});
Loading