Files
ascii-pwa/tests/service-worker.test.mjs
T
2026-10-10 13:02:19 +02:00

151 lines
7.1 KiB
JavaScript

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=Standard`)).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));
}
}
});