Getting an episode onto a phone took two buttons in two states: download it to the server, wait, then keep it on the device, beside Save to this computer, Share and Delete, five buttons that did not say where each copy went. Now a file has one button that brings it to where you are. On a phone or in the iOS app it ends on the device; when iPX has not got the file yet, iPX fetches it and the device's copy follows once iPX says it is done (remembered in localStorage, so a page closed meanwhile catches up when a list next shows the file). On a computer it has iPX fetch the file, and with iPX's copy there saves one. A line beside the size says where the file is, and the rarer actions, two of them destructive, are under a menu. isPlayable is false for any file not on iPX, so the phone's button is decided by playsKind, the same test without the path. download_done now marks the file as on iPX in what the page holds, so the open file's box changes at once rather than after the list is fetched again. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RqkrVA8TdTxe556JB3sR1n
237 lines
14 KiB
TypeScript
237 lines
14 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('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;
|