// 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 { 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 { 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;