The page and the service worker were strict TypeScript; the tests, the build, the home page's generator, the load tests, boot.js and the directory's site.js were plain JavaScript that nothing checked. All of it is TypeScript now, in five programs, each with its own globals, all checked by npm run typecheck: the page (tsconfig.json, with boot.ts and the directory's site.ts), the worker, Node's (tsconfig.tools.json: the Node tests, the browser suite, the build, the site generator, the load harness), and k6's (tsconfig.k6.json, with @types/k6). Node 24 runs the .ts files by stripping their types, so they hold only TypeScript that erases (erasableSyntaxOnly) and import each other by .ts name; package.json says "type": "module" for it, and Dockerfile.web moves to Node 24 to run build.mts. k6 2.3 strips its scripts' types itself. The browser suite's page.evaluate calls reach the page's globals; the page's tsc run now also writes its declarations to target/page-types, which the suite's program includes, so those calls are typed by the page's own code rather than by hand-written copies. boot.ts is compiled by build.mts with swc, as the worker is, and named by the hash of what is served. The directory's build has no Node, so site.js is compiled from site.ts and committed (node web/build.mts --directory-script), and tests/directory-script.ts fails if they drift. Checked: the site generator's 139 pages are byte for byte what the JavaScript made; the page build's app.js is identical and only boot.js (now minified) and its hash differ; contrast, page-smoke, page-unit, native-bridge, the browser suite (96 passed, 2 skipped) and the directory's and ipx's Rust tests pass; k6 parses each load script. The load tests themselves did not run: they build a release binary outside the capped builder. .agents/skills/security-audit is an installed skill's, not ours, and is left as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
155 lines
8.9 KiB
TypeScript
155 lines
8.9 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('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('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>;
|