import test from 'node:test'; import assert from 'node:assert/strict'; import vm from 'node:vm'; import {readFile, access} from 'node:fs/promises'; const source = await readFile(new URL('../sw.js', import.meta.url), 'utf8'); function worker({scope = 'https://ascii.example/pwa/', version = 'v1', stores = new Map(), missing = ''} = {}) { const handlers = {}; let claimed = false; let activated = false; let fetched = 0; const context = vm.createContext({ URL, self: { registration: {scope}, location: {origin: new URL(scope).origin}, addEventListener(name, callback) { handlers[name] = callback; }, clients: {async claim() { claimed = true; }}, skipWaiting() { activated = true; }, }, caches: { async keys() { return [...stores.keys()]; }, async delete(name) { return stores.delete(name); }, async open(name) { if (!stores.has(name)) stores.set(name, new Map()); const store = stores.get(name); return { async addAll(urls) { if (urls.some(url => url.endsWith(missing)) && missing) throw Error('Missing asset'); for (const url of urls) { const relative = new URL(url).pathname.slice(new URL(scope).pathname.length); await access(new URL(`../${relative || 'index.html'}`, import.meta.url)); } // Cache.addAll commits only after the whole batch succeeds. for (const url of urls) store.set(url, {url}); }, async match(url) { return store.get(url); }, }; }, }, async fetch(request) { fetched++; throw Error(`Offline: ${request.url}`); }, }); vm.runInContext(source.replace(/import .*;\n/, '').replace(/const VERSION = 'v\d+'/, `const VERSION = '${version}'`), context); return { stores, get claimed() { return claimed; }, get activated() { return activated; }, get fetched() { return fetched; }, async lifecycle(name) { let result; handlers[name]({waitUntil(promise) { result = promise; }}); await result; }, message(data) { handlers.message({data}); }, async request(url, {method = 'GET', mode = 'navigate'} = {}) { let result; handlers.fetch({request: {url, method, mode}, respondWith(promise) { result = promise; }}); return result ? await result : undefined; }, }; } for (const scope of ['https://ascii.example/', 'https://ascii.example/pwa/']) { test(`offline shell preserves supported navigation at ${scope}`, async () => { const sw = worker({scope}); await sw.lifecycle('install'); await sw.lifecycle('activate'); for (const cache of sw.stores.values()) { assert.equal([...cache.keys()].some(url => url.endsWith('.flf')), false); } assert.equal(sw.claimed, true); assert.equal(sw.activated, false, 'initial install does not force activation'); assert.equal((await sw.request(`${scope}?text=Hello&font=compact`)).url, scope); assert.equal((await sw.request(`${scope}index.html?text=World`)).url, scope); for (const cache of sw.stores.values()) { for (const url of cache.keys()) { assert.ok(await sw.request(`${url}?v=1`, {mode: 'cors'}), `offline asset ${url}`); } } assert.equal(sw.fetched, 0); }); } test('bypasses API routes, unknown navigation, cross-origin resources and writes', async () => { const sw = worker(); for (const [url, options] of [ ['https://ascii.example/api/render', {}], ['https://ascii.example/pwa/not-a-route', {}], ['https://other.example/pwa/app.js', {mode: 'cors'}], ['https://ascii.example/pwa/app.js', {method: 'POST', mode: 'cors'}], ['https://ascii.example/pwa/unrelated.js', {mode: 'cors'}], ]) assert.equal(await sw.request(url, options), undefined); }); test('version upgrade prepares a complete cache, waits, then cleans only this app scope', async () => { const stores = new Map([['unrelated-cache', new Map()]]); const old = worker({stores}); await old.lifecycle('install'); // Create an unrelated installation cache without attempting to load its shell. const otherKey = `ascii-pwa-${encodeURIComponent('https://ascii.example/other/')}-v1`; stores.set(otherKey, new Map()); const fontKey = `ascii-pwa-fonts-${encodeURIComponent('https://ascii.example/pwa/')}-figlet-1.8.2`; stores.set(fontKey, new Map([['Standard.flf', {used: true}]])); const next = worker({version: 'v2', stores}); await next.lifecycle('install'); assert.equal(next.activated, false); assert.ok([...stores.keys()].some(key => key.endsWith('-v1'))); assert.equal((await old.request('https://ascii.example/pwa/')).url, 'https://ascii.example/pwa/'); next.message('ACTIVATE_UPDATE'); assert.equal(next.activated, true); await next.lifecycle('activate'); assert.ok(stores.has('unrelated-cache')); assert.ok(stores.has(otherKey)); assert.equal(stores.get(fontKey).get('Standard.flf').used, true); const key = `ascii-pwa-${encodeURIComponent('https://ascii.example/pwa/')}-v2`; assert.ok(stores.has(key)); assert.equal(stores.has(key.replace(/v2$/, 'v1')), false); assert.equal((await next.request('https://ascii.example/pwa/')).url, 'https://ascii.example/pwa/'); }); test('missing shell assets prevent installation without replacing the old shell', async () => { const stores = new Map(); const old = worker({stores}); await old.lifecycle('install'); const next = worker({stores, version: 'v2', missing: 'renderer.js'}); await assert.rejects(next.lifecycle('install'), /Missing asset/); assert.equal(next.claimed, false); assert.equal(next.activated, false); assert.equal((await old.request('https://ascii.example/pwa/')).url, 'https://ascii.example/pwa/'); }); test('manifest paths and PNG dimensions support root and subdirectory installs', async () => { const manifest = JSON.parse(await readFile(new URL('../manifest.webmanifest', import.meta.url))); for (const base of ['https://ascii.example/', 'https://ascii.example/pwa/']) { for (const field of ['id', 'scope', 'start_url']) assert.equal(new URL(manifest[field], base).href, base); } assert.equal(manifest.display, 'standalone'); for (const icon of manifest.icons) { const data = await readFile(new URL(`../${icon.src}`, import.meta.url)); if (icon.type === 'image/png') { assert.equal(data.subarray(1, 4).toString(), 'PNG'); assert.equal(`${data.readUInt32BE(16)}x${data.readUInt32BE(20)}`, icon.sizes); } } for (const purpose of ['any', 'maskable']) { for (const size of ['192x192', '512x512']) { assert.ok(manifest.icons.some(icon => icon.purpose === purpose && icon.sizes === size)); } } });