All checks were successful
Quality gates / quality (pull_request) Successful in 1m42s
Implements #35. - importLedger migrates prior schema versions (v0 bare-array legacy exports and the v1 envelope) and fails safely on future versions, malformed JSON, wrong-product envelopes, and oversized files with a new 2 MiB MAX_IMPORT_BYTES guard applied before parsing. - exportLedger normalizes entries through sanitizeEntry so confirmed values and bounded provenance round-trip while smuggled secrets and unknown fields never enter the portable file. - Entries may carry a whitelisted provenance origin ('user' or 'ai-suggestion'); mergeVisualSuggestion records 'ai-suggestion' only when a suggestion is actually applied, keeping nonvisual fields user-owned. - App import now merges into the existing ledger instead of replacing it, so a failed or partial import can never silently drop user-owned records. - Service-worker shell cache bumped to v6 (per base-path namespace) so installed PWAs receive the migration code; old v5 caches are purged on activation. - New tests/ledger-portability.acceptance.mjs browser gate covers export round trip, merge import, safe-failure surfacing, root vs /timmy-staging storage isolation, and Delete Everything for both namespaces; wired into package.json test:portability and CI quality.yml. Deterministic medical safety unchanged: urgent-flag detection, red-flag copy, and chat escalation paths are untouched; all fixtures synthetic.
98 lines
3.6 KiB
JavaScript
98 lines
3.6 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFile } from 'node:fs/promises';
|
|
import vm from 'node:vm';
|
|
|
|
const source = await readFile(new URL('../service-worker.js', import.meta.url), 'utf8');
|
|
|
|
function loadWorker({ scope = 'https://example.test/', cacheKeys = [], currentHits = new Map(), fetchImpl = async () => { throw new Error('offline'); } } = {}) {
|
|
const listeners = new Map();
|
|
const deleted = [];
|
|
const puts = [];
|
|
let cacheOpenCount = 0;
|
|
const currentCache = {
|
|
addAll: async () => {},
|
|
put: async request => { puts.push(typeof request === 'string' ? request : request.url); },
|
|
match: async request => currentHits.get(typeof request === 'string' ? request : request.url),
|
|
};
|
|
const caches = {
|
|
keys: async () => cacheKeys,
|
|
delete: async key => { deleted.push(key); return true; },
|
|
open: async () => { cacheOpenCount += 1; return currentCache; },
|
|
match: async () => { throw new Error('global cache matching must not be used'); },
|
|
};
|
|
const self = {
|
|
registration: { scope },
|
|
addEventListener: (type, handler) => listeners.set(type, handler),
|
|
skipWaiting: async () => {},
|
|
clients: { claim: async () => {} },
|
|
};
|
|
vm.runInNewContext(source, { self, caches, fetch: fetchImpl, URL, Promise, String });
|
|
return { listeners, deleted, puts, getCacheOpenCount: () => cacheOpenCount };
|
|
}
|
|
|
|
async function dispatchExtendable(handler) {
|
|
let pending;
|
|
handler({ waitUntil(value) { pending = value; } });
|
|
await pending;
|
|
}
|
|
|
|
async function dispatchFetch(handler, request) {
|
|
let response;
|
|
handler({ request, respondWith(value) { response = value; } });
|
|
return response;
|
|
}
|
|
|
|
test('root activation deletes only its obsolete Timmy caches including the legacy v4 and previous v5 shells', async () => {
|
|
const { listeners, deleted } = loadWorker({
|
|
cacheKeys: [
|
|
'timmy-shell-v4',
|
|
'timmy-shell:/:v4',
|
|
'timmy-shell:/:v5',
|
|
'timmy-shell:/timmy-staging/:v4',
|
|
'sibling-shared-origin-cache',
|
|
],
|
|
});
|
|
|
|
await dispatchExtendable(listeners.get('activate'));
|
|
|
|
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4', 'timmy-shell:/:v5']);
|
|
});
|
|
|
|
test('offline shell lookup uses only the current named cache', async () => {
|
|
const request = { method: 'GET', url: 'https://example.test/app.js' };
|
|
const current = { body: 'current app' };
|
|
const { listeners } = loadWorker({ currentHits: new Map([[request.url, current]]) });
|
|
|
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
|
|
|
assert.equal(await response, current);
|
|
});
|
|
|
|
test('scoped API GET requests bypass the shell cache entirely', async () => {
|
|
const request = { method: 'GET', url: 'https://example.test/timmy-staging/api/healthz' };
|
|
const networkResponse = { headers: { get: () => 'no-store' }, clone: () => ({}) };
|
|
const { listeners, puts, getCacheOpenCount } = loadWorker({
|
|
scope: 'https://example.test/timmy-staging/',
|
|
fetchImpl: async () => networkResponse,
|
|
});
|
|
|
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
|
|
|
assert.equal(await response, networkResponse);
|
|
assert.equal(getCacheOpenCount(), 0);
|
|
assert.deepEqual(puts, []);
|
|
});
|
|
|
|
test('no-store shell responses are returned without being cached', async () => {
|
|
const request = { method: 'GET', url: 'https://example.test/styles.css' };
|
|
const networkResponse = { headers: { get: () => 'private, no-store' }, clone: () => ({}) };
|
|
const { listeners, puts } = loadWorker({ fetchImpl: async () => networkResponse });
|
|
|
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
|
await response;
|
|
await Promise.resolve();
|
|
|
|
assert.deepEqual(puts, []);
|
|
});
|