iPX opens and reads without a connection (#389)
With no network the home-screen app showed nothing: no service worker, and the lists the page had fetched, full text included, lived only in its memory. A service worker (web/sw/sw.ts, TypeScript checked against a worker's globals by tsconfig.sw.json, built by swc to /sw.js) goes to the network first for the page and every API read and keeps the answers, so online nothing is an old copy and #388's version check works as before; offline the kept copy answers. Scripts named by hash come from what is kept. Reads, flags and places in an episode that cannot reach the server wait in an outbox, sent in order when the connection is back (and new ones queue behind them meanwhile, so none lands out of turn); a kept list served offline shows the reads waiting there. Pictures: a worker cannot keep another site's pictures sensibly, since browsers count each opaque answer as megabytes. ipx now remembers the picture addresses in item text it sends (bounded, in memory) and /api/art fetches those as it does artwork, through the guarded client; the worker keeps them, for unread items, under a 200 MB budget, and serves them offline in place of the publisher's. Online, pictures still come from their publisher. Sign-out, or another account signing in on the device, clears what was kept. The browser suite blocks the worker except in the offline tests, since it answers before page.route can. The fixture JPEGs are placeholders a browser cannot draw; the offline test uses a real PNG. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -291,3 +291,12 @@ test('a reader is stuck only when a drag up or down had room to move it and did
|
||||
assert.equal(run('stuckDrag(-20, 0, 0, 0, 1000)'), false); // too short to mean it
|
||||
assert.equal(run('stuckDrag(-100, 150, 0, 0, 1000)'), false); // a swipe, not a scroll
|
||||
});
|
||||
|
||||
test('the pictures saved for offline are those in unread items\' text, as the browser will ask for them (#389)', () => {
|
||||
const pics = run(`textPictures([
|
||||
{read:false, description:'<p>x</p><img src="https://a.example/1.jpg?w=1&h=2" alt="a"><img alt="none">', markdown_html:'<img src="https://a.example/1.jpg?w=1&h=2">'},
|
||||
{read:false, description:'<img src="data:image/png;base64,xx"><img src="/relative.png">', markdown_html:null},
|
||||
{read:true, description:'<img src="https://a.example/read.jpg">', markdown_html:null},
|
||||
])`);
|
||||
assert.deepEqual([...pics], ['https://a.example/1.jpg?w=1&h=2']);
|
||||
});
|
||||
|
||||
@@ -2485,3 +2485,61 @@ test('an error toast is announced and stays until it is dismissed', async ({ pag
|
||||
await t.getByRole('button', { name: 'Dismiss' }).click();
|
||||
await expect(t).toHaveCount(0);
|
||||
});
|
||||
|
||||
// Last: it adds a feed, which the feed counts of earlier tests do not expect.
|
||||
test.describe('offline (#389)', () => {
|
||||
test.use({ serviceWorkers: 'allow' });
|
||||
const outbox = page => page.evaluate(async () => (await (await caches.open('ipx-outbox')).keys()).length);
|
||||
|
||||
test('iPX opens offline with what it read and its pictures, and sends a read made offline once back', async ({ page, context }) => {
|
||||
const api = (p, init) => page.evaluate(([p, init]) => fetch(p, { headers: { 'Content-Type': 'application/json' }, ...init })
|
||||
.then((r) => r.json().catch(() => null)), [p, init]);
|
||||
const added = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url: 'http://127.0.0.1:8792/offline.xml' }) });
|
||||
const id = added.id || added.feed?.id || 'offline-blog';
|
||||
await page.evaluate(() => navigator.serviceWorker.ready);
|
||||
await page.reload();
|
||||
await expect.poll(() => page.evaluate(() => !!navigator.serviceWorker.controller)).toBe(true);
|
||||
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 item's picture, kept through ipx: no feed names it as artwork, only its text.
|
||||
await expect.poll(() => page.evaluate(async () => (await (await caches.open('ipx-pics')).keys()).length)).toBe(1);
|
||||
|
||||
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 => i.complete ? i.naturalWidth : 0)).toBeGreaterThan(0);
|
||||
// Opening it read it, which waits to be sent.
|
||||
await expect.poll(() => outbox(page)).toBe(1);
|
||||
|
||||
await context.setOffline(false);
|
||||
await expect.poll(() => outbox(page), { timeout: 15_000 }).toBe(0);
|
||||
const list = await api(`/api/feeds/${encodeURIComponent(id)}/entries?offset=0&limit=50&filter=all`);
|
||||
expect(list.entries.find(e => e.guid === 'offline-1').read).toBe(true);
|
||||
});
|
||||
|
||||
test('online, a picture in an item\'s text still comes from its publisher, through the worker', async ({ page }) => {
|
||||
await page.evaluate(() => navigator.serviceWorker.ready);
|
||||
await page.reload();
|
||||
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 => 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]?.workerStart > 0)).toBe(true);
|
||||
});
|
||||
|
||||
test('signing out leaves nothing kept for offline on the device', async ({ page }) => {
|
||||
await page.evaluate(() => navigator.serviceWorker.ready);
|
||||
await page.reload();
|
||||
await expect.poll(() => page.evaluate(async () => (await caches.keys()).filter(k => k.startsWith('ipx-')).length)).toBeGreaterThan(0);
|
||||
await page.locator('#signout').evaluate(b => b.click());
|
||||
await page.waitForURL(u => !u.pathname.endsWith('/') || u.pathname === '/login' || u.pathname.includes('signed-out'));
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
7
tests/ui/fixtures/offline.xml
Normal file
7
tests/ui/fixtures/offline.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<rss version="2.0">
|
||||
<channel><title>Offline Blog</title><link>http://127.0.0.1:8792/</link><description>A post with a picture in its text, to read offline (#389).</description>
|
||||
<item><title>Read on a Plane</title><guid>offline-1</guid>
|
||||
<pubDate>Mon, 01 Sep 2026 10:00:00 +0000</pubDate>
|
||||
<description><p>Read on a plane, with no connection.</p><img src="http://127.0.0.1:8792/pic.png" alt="a picture"></description></item>
|
||||
</channel></rss>
|
||||
BIN
tests/ui/fixtures/pic.png
Normal file
BIN
tests/ui/fixtures/pic.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -50,6 +50,8 @@ http.createServer((req, res) => {
|
||||
: file.endsWith('.opml') ? 'text/x-opml'
|
||||
// Artwork as an image, or /api/art refuses it as not one.
|
||||
: file.endsWith('.jpg') ? 'image/jpeg'
|
||||
// A picture a browser can draw: the JPEGs are a few bytes that only say they are one.
|
||||
: file.endsWith('.png') ? 'image/png'
|
||||
: file.endsWith('.html') ? 'text/html' : 'application/xml';
|
||||
res.writeHead(200, { 'content-type': type, 'content-length': body.length });
|
||||
res.end(body);
|
||||
|
||||
Reference in New Issue
Block a user