Files
ipx/tests/ui/offline.spec.ts
rays 373f862588 A page older than its script is loaded again, not run with it (#449)
Safari on a Mac threw at load on 2026-11-10: the script wired #sleep,
which the page it ran in did not have. #sleep and its script came in
together on 11-08, so the page was older than the script. The service
worker answers a navigation it cannot fetch with the page it kept last,
here one from before 11-08, and that page asked for its script by the old
hash; by then the network answered, and Caddy, which ignores ?v=, sent
today's app.js. The worker even kept it under the old name.

Every ?v= is the first 12 hex digits of the file's SHA-256, so the worker
now checks what it fetched against the name. On a mismatch it does not
keep or give the file, and loads the page again from the network, at most
once in 30 seconds so a page still unreachable is not reloaded round and
round.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 03:46:49 +00:00

254 lines
15 KiB
TypeScript

// iPX without a connection (#389): the service worker (web/sw/sw.ts) keeps the page, what it read
// and the pictures in unread items, and holds writes made offline until the connection is back.
// After app.spec.ts, file order being run order: it adds a feed, which that file's counts of
// feeds do not expect.
import type { BrowserContext, Page, Worker } from '@playwright/test';
import { TOKEN } from './global-setup.ts';
import { expect, test } from './coverage.ts';
// The suite blocks the worker, which answers before page.route can; these tests are about it.
test.use({ serviceWorkers: 'allow' });
const call = (page: Page, p: string, init?: RequestInit) =>
page.evaluate(([p, init]) => fetch(p!, { headers: { 'Content-Type': 'application/json' }, ...(init as RequestInit) })
.then((r) => r.json().catch(() => null)), [p, init] as const);
const cacheSize = (page: Page, name: string) =>
page.evaluate(async (name) => (await (await caches.open(name)).keys()).length, name);
const outbox = (page: Page) => cacheSize(page, 'ipx-outbox');
/// The page, under the worker's control.
async function controlled(page: Page) {
await page.evaluate(() => navigator.serviceWorker.ready);
await page.reload();
await expect.poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
}
async function worker(context: BrowserContext): Promise<Worker> {
return context.serviceWorkers()[0] ?? context.waitForEvent('serviceworker');
}
/// The feed these tests read, added once; the tests that follow find it there.
async function offlineBlog(page: Page): Promise<string> {
const feeds: { id: string, url: string }[] = await call(page, '/api/feeds');
const had = feeds.find((f) => f.url.endsWith('/offline.xml'));
if (had) return had.id;
const added = await call(page, '/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/offline.xml' }) });
return added?.id || added?.feed?.id || 'offline-blog';
}
test.beforeEach(async ({ page }) => {
await page.goto(`/?token=${TOKEN}`);
await expect(page.locator('#feedlist')).toBeVisible();
await offlineBlog(page);
});
test('iPX opens offline with what it read and its pictures, and sends a read made offline once back', async ({ page, context }) => {
const id = await offlineBlog(page);
// A run before this one read them: both unread again, so the Unread list holds both.
await expect.poll(async () => (await call(page, `/api/feeds/${encodeURIComponent(id)}/entries?offset=0&limit=50&filter=all`))?.entries?.length ?? 0,
{ timeout: 20_000 }).toBe(2);
for (const guid of ['offline-1', 'offline-2'])
await call(page, `/api/entries/${encodeURIComponent(id)}/${guid}/flags`, { method: 'POST', body: JSON.stringify({ read: false }) });
await controlled(page);
await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 });
await page.locator('#content .tabs button[data-f="unread"]').click();
await expect(page.locator('.ep', { hasText: 'Read on a Plane' })).toBeVisible();
// The unread items' four pictures, kept through ipx: no feed names them as artwork, only text.
await expect.poll(() => cacheSize(page, 'ipx-pics')).toBe(4);
await context.setOffline(true);
await page.reload();
// The page, the feed list and the list kept, with the feed and tab open before.
await page.locator('.ep', { hasText: 'Read on a Plane' }).click({ timeout: 20_000 });
await expect(page.locator('#detail .dbody')).toContainText('Read on a plane, with no connection.');
await expect.poll(() => page.locator('#detail .dbody img').evaluate((i: HTMLImageElement) => i.complete ? i.naturalWidth : 0)).toBeGreaterThan(0);
// Opening it read it, which waits to be sent.
await expect.poll(() => outbox(page)).toBe(1);
// Opened again offline, the Unread list kept no longer has it: read, as the outbox has it.
await page.reload();
await expect(page.locator('.ep', { hasText: 'Three Pictures' })).toBeVisible({ timeout: 20_000 });
await expect(page.locator('.ep', { hasText: 'Read on a Plane' })).toHaveCount(0);
await context.setOffline(false);
await expect.poll(() => outbox(page), { timeout: 15_000 }).toBe(0);
const list = await call(page, `/api/feeds/${encodeURIComponent(id)}/entries?offset=0&limit=50&filter=all`);
expect(list.entries.find((e: { guid: string }) => e.guid === 'offline-1').read).toBe(true);
});
test('a place in an episode saved offline waits, and is saved once back', async ({ page, context }) => {
await controlled(page);
const at = '/api/entries/offline-blog/offline-2/position';
await context.setOffline(true);
const answer = await page.evaluate((at) => fetch(at, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secs: 321, duration: 600 }),
}).then((r) => `${r.status} ${r.headers.get('X-Ipx-Queued')}`), at);
// Answered for the server, so the player carries on, and kept.
expect(answer).toBe('204 1');
expect(await outbox(page)).toBe(1);
await context.setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect.poll(() => outbox(page), { timeout: 15_000 }).toBe(0);
expect((await call(page, '/api/entries/offline-blog/offline-2')).position).toBe(321);
});
test('a page older than the script the server sends is loaded again, not run with it', async ({ page }) => {
// #449: a page kept from before a deploy asks for its script by the old hash, and the server
// sends the new one whatever the name says.
await controlled(page);
const mine = await page.evaluate(() => document.querySelector('script[src^="/app.js"]')!.getAttribute('src')!);
const kept = () => page.evaluate(async () => (await (await caches.open('ipx-page')).keys()).map(k => new URL(k.url).pathname + new URL(k.url).search));
await expect.poll(kept).toContain(mine);
await page.evaluate(() => { (window as unknown as { stale: boolean }).stale = true; });
const reloaded = page.waitForEvent('load');
expect(await page.evaluate(() => fetch('/app.js?v=000000000000').then(() => 'given', () => 'refused'))).toBe('refused');
await reloaded;
expect(await page.evaluate(() => 'stale' in window), 'the page was loaded again').toBe(false);
// What is kept is still the script under its own name, not the new one under the old.
expect(await kept()).toContain(mine);
expect(await kept()).not.toContain('/app.js?v=000000000000');
});
test('online, a picture in an item\'s text still comes from its publisher, through the worker', async ({ page }) => {
await controlled(page);
await page.locator('#feedlist .feed', { hasText: 'Offline Blog' }).click({ timeout: 20_000 });
await page.locator('#content .tabs button[data-f="all"]').click();
await page.locator('.ep', { hasText: 'Read on a Plane' }).click();
const img = page.locator('#detail .dbody img');
await expect.poll(() => img.evaluate((i: HTMLImageElement) => i.complete ? i.naturalWidth : 0)).toBeGreaterThan(0);
// Past the worker, not round it: what it passes on online is what it would keep.
expect(await page.evaluate(() =>
((performance.getEntriesByName('http://127.0.0.1:8792/pic.png')[0] as PerformanceResourceTiming | undefined)?.workerStart ?? 0) > 0)).toBe(true);
});
test('kept pictures past the budget go oldest first', async ({ page, context }) => {
const pic = (n: number) => `http://127.0.0.1:8792/pic.png?n=${n}`;
// ipx serves only pictures it has sent in item text: the list names them, once scanned.
await expect.poll(async () => (await call(page, '/api/feeds/offline-blog/entries?offset=0&limit=50&filter=all'))?.entries?.length ?? 0,
{ timeout: 20_000 }).toBe(2);
// Read, so the page asks the worker to keep none of them itself while this test does.
for (const guid of ['offline-1', 'offline-2'])
await call(page, `/api/entries/offline-blog/${guid}/flags`, { method: 'POST', body: JSON.stringify({ read: true }) });
await controlled(page);
const sw = await worker(context);
const kept = await sw.evaluate(async ([a, b, c]) => {
await caches.delete('ipx-pics');
// One at a time, so which is oldest is known; the third goes over two pictures' budget.
await keepPictures([a!]);
await keepPictures([b!]);
const one = Number((await (await caches.open('ipx-pics')).match(`/api/art?u=${encodeURIComponent(a!)}`))?.headers.get('X-Size'));
await keepPictures([c!], one * 2.5);
return (await (await caches.open('ipx-pics')).keys()).map((k) => decodeURIComponent(new URL(k.url).searchParams.get('u')!));
}, [pic(1), pic(2), pic(3)]);
expect(kept).toEqual([pic(2), pic(3)]);
});
test('an episode kept on the device plays from it offline, and its place waits to be saved', async ({ page, context }) => {
type Enc = { id: number, path: string | null, state: string };
const feeds: { id: string, url: string }[] = await call(page, '/api/feeds');
const id = feeds.find((f) => f.url.endsWith('/offline-show.xml'))?.id
?? (await call(page, '/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/offline-show.xml' }) }))?.id ?? 'offline-show';
const enc = async (): Promise<Enc | undefined> =>
(await call(page, `/api/feeds/${encodeURIComponent(id)}/entries?offset=0&limit=5&filter=all`))?.entries?.[0]?.enclosures?.[0];
await expect.poll(async () => (await enc())?.id ?? 0, { timeout: 20_000 }).toBeGreaterThan(0);
const first = (await enc())!;
if (!first.path) await call(page, `/api/enclosures/${first.id}/download`, { method: 'POST' });
await expect.poll(async () => !!(await enc())?.path, { timeout: 20_000 }).toBe(true);
await call(page, `/api/entries/${encodeURIComponent(id)}/offline-ep-1/position`, { method: 'POST', body: JSON.stringify({ secs: 0 }) });
await controlled(page);
const open = async () => {
await page.locator('#feedlist .feed', { hasText: 'Offline Show' }).click({ timeout: 20_000 });
await page.locator('#content .tabs button[data-f="all"]').click();
await page.locator('.ep', { hasText: 'Listen on a Plane' }).click();
};
await open();
// Kept by its menu on a computer; the line in its box says where it is (#457).
const where = page.locator('#files .encbox .meta');
const menu = async (m: string) => {
await page.locator('#files .encbox [data-a="more"]').last().click();
await page.locator(`#modalCard [data-m="${m}"]`).click();
};
const row = page.locator('.ep', { hasText: 'Listen on a Plane' });
await expect(where).toHaveText(/On iPX$/);
await expect(row.locator('.kept')).toHaveCount(0);
await menu('offline');
await expect(where).toHaveText(/On iPX and this device$/, { timeout: 15_000 });
expect(await cacheSize(page, 'ipx-episodes')).toBe(1);
// Its row in the list says so too (#413).
await expect(row.locator('.kept')).toHaveText('On this device');
await context.setOffline(true);
await page.reload();
await open();
// Still kept, as the page reads it from the cache at start.
await expect(where).toHaveText(/On iPX and this device$/);
await expect(row.locator('.kept')).toBeVisible();
await page.locator('#files [data-a="play"]').click();
await expect.poll(() => page.evaluate(() => audio.currentTime), { timeout: 15_000 }).toBeGreaterThan(0.3);
expect(await page.evaluate(() => audio.currentSrc)).toContain('?offline');
// The player saves its place as it pauses: kept for the connection, as a read is.
await page.evaluate(() => audio.pause());
// (Opening the item read it, which waits beside it.)
await expect.poll(() => page.evaluate(async () => {
const c = await caches.open('ipx-outbox');
const saved = await Promise.all((await c.keys()).map(async (k) => (await (await c.match(k))!.json()) as { url: string, body: string }));
return saved.filter((w) => w.url.includes('/offline-ep-1/position')).map((w) => JSON.parse(w.body).secs as number);
})).toEqual([expect.any(Number)]);
await context.setOffline(false);
await page.evaluate(() => window.dispatchEvent(new Event('online')));
await expect.poll(() => outbox(page), { timeout: 15_000 }).toBe(0);
expect((await call(page, `/api/entries/${encodeURIComponent(id)}/offline-ep-1`)).position).toBeGreaterThanOrEqual(0);
// Taken off the device again.
await menu('offline');
await expect(where).toHaveText(/On iPX$/);
expect(await cacheSize(page, 'ipx-episodes')).toBe(0);
await expect(row.locator('.kept')).toHaveCount(0);
});
test('a kept episode is answered in the byte ranges a player asks for', async ({ page, context }) => {
await controlled(page);
const sw = await worker(context);
const got = await sw.evaluate(async () => {
const blob = new Blob(['0123456789']);
const one = async (range: string | null) => {
const r = ranged(blob, range, 'audio/mpeg');
return [r.status, r.headers.get('Content-Range'), r.status === 416 ? null : await r.text()].filter((x) => x !== null).join(' ');
};
return [await one(null), await one('bytes=2-4'), await one('bytes=7-'), await one('bytes=-3'), await one('bytes=8-99'),
await one('bytes=10-'), await one('pages=1-2')];
});
expect(got).toEqual(['200 0123456789', '206 bytes 2-4/10 234', '206 bytes 7-9/10 789', '206 bytes 7-9/10 789',
'206 bytes 8-9/10 89', '416 bytes */10', '200 0123456789']);
});
test('another account signing in on the device clears what the last one kept', async ({ page }) => {
await controlled(page);
// What an account before this one left, and the page last opened as that account.
await page.evaluate(async () => {
await (await caches.open('ipx-api')).put('/api/left-by-someone-else', new Response('{}'));
localStorage.setItem('ipx.owner', 'someone else');
});
await page.reload();
await expect.poll(() => page.evaluate(async () =>
!!(await (await caches.open('ipx-api')).match('/api/left-by-someone-else')))).toBe(false);
// This account is the owner now, and what it keeps from here stays.
expect(await page.evaluate(() => localStorage.getItem('ipx.owner'))).not.toBe('someone else');
});
test('signing out leaves nothing kept for offline on the device', async ({ page }) => {
await controlled(page);
await expect.poll(() => page.evaluate(async () => (await caches.keys()).filter((k) => k.startsWith('ipx-')).length)).toBeGreaterThan(0);
await page.locator('#signout').evaluate((b: HTMLButtonElement) => b.click());
await page.waitForURL((u) => u.pathname !== '/');
// What was read, its pictures and unsent writes. The sign-in page's own script and styles are
// kept again as it loads: the same files for everyone, nothing of the account's.
expect(await page.evaluate(async () => (await caches.keys()).filter((k) => k.startsWith('ipx-') && k !== 'ipx-page'))).toEqual([]);
});
// The worker's own functions, called in it by the test above; declared for the type check.
declare function keepPictures(srcs: string[], budget?: number): Promise<void>;
declare function ranged(blob: Blob, range: string | null, type: string): Response;