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>
254 lines
15 KiB
TypeScript
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;
|