Serve the script as /app.js, cached until a deploy changes it

The page loaded its script inline. It now names /app.js?v=<hash> (login.js for
the sign-in page), the hash of the script's contents: the script is served
immutable for a year and the page no-cache, so a browser fetches the script
again only when a deploy changes it and so its name.

Also fixes a race in the mark-everything-read test: it waited on a badge that
was seldom 0 to begin with, so a mark-unread still in flight could land after
the read-all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 13:03:25 +00:00
parent e2969bcee8
commit 5483355021
7 changed files with 84 additions and 20 deletions

View File

@@ -11,9 +11,12 @@ const vm = require('vm');
const { buildPage } = require('../web/build.mjs');
// What ships: minified, so an id may have lost its quotes.
const html = buildPage('index.html');
const script = html.split('<script>')[1].split('</script>')[0];
const ids = new Set([...html.matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const { html, js: script, script: file } = buildPage('index.html');
if (!new RegExp(`<script src="?/${file.replace('.', '\\.')}\\?v=[0-9a-f]{12}"?>`).test(html)) {
console.error(`FAIL: the page does not load /${file}?v=<hash>`); process.exit(1);
}
// Ids in the page, and in the markup the script builds for its dialogs.
const ids = new Set([...(html + script).matchAll(/\bid=(?:"([^"]+)"|([^\s>"']+))/g)].map(m => m[1] || m[2]));
const missing = [];
const el = (name) => new Proxy({ style: { setProperty(){}, getPropertyValue(){ return ''; } }, dataset: {}, classList: { add(){}, remove(){}, toggle(){}, contains(){ return false; } },

View File

@@ -20,6 +20,22 @@ test('the page loads and lists the configured feeds', async ({ page }) => {
expect(errors, 'the page script must not throw at load').toEqual([]);
});
test('the script is its own file, cached until a deploy changes it', async ({ page }) => {
const js = page.waitForResponse(r => new URL(r.url()).pathname === '/app.js');
const doc = await page.reload();
const r = await js;
// The page is checked every visit, so it always names the current script...
expect(doc.headers()['cache-control']).toBe('no-cache');
// ...by a hash of its contents, which is why the script itself can be kept for a year.
expect(new URL(r.url()).searchParams.get('v')).toMatch(/^[0-9a-f]{12}$/);
expect(r.headers()['cache-control']).toContain('immutable');
expect(r.headers()['content-type']).toContain('javascript');
expect(await page.locator('script:not([src])').count(), 'no inline script').toBe(0);
// The sign-in page's script loads before signing in.
const login = await page.request.get('/login.js', { headers: { cookie: '' } });
expect(login.status()).toBe(200);
});
test('Settings picks a theme and, where it has both, light, dark or Auto', async ({ page }) => {
const root = () => page.evaluate(() => [document.documentElement.dataset.theme, document.documentElement.dataset.mode]);
const bg = () => page.evaluate(() => getComputedStyle(document.body).backgroundColor);
@@ -903,6 +919,9 @@ test('All Subscriptions marks everything read, across every feed', async ({ page
// its own button makes it unread again.
await page.locator('.ep').first().click();
await page.locator('#detail [data-a="read"][title="Mark unread"]').click();
// The button turns only once the server has it. The badge was no proof: it was seldom 0 to
// begin with, and a mark-unread still in flight could land after the read-all below.
await expect(page.locator('#detail [data-a="read"][title="Mark read"]')).toBeVisible();
await expect(all.locator('.badge')).not.toHaveText('0');
page.once('dialog', d => d.accept());