Quality gates / quality (pull_request) Successful in 18m50s
A backup is the person's own record and has to keep working as the AI fields evolve. - Export stamps schema version 2 and carries the confirmed logs plus the training contribution receipts, including withdrawn tombstones; both are written through an allowlist so unknown keys and credentials never leave the device. - Import accepts version 1 and 2. A version 1 file is upgraded, the notice names the version it came from, and entries that predate provenance stay null instead of being given a source they never recorded. - A file from a newer Timmy is refused with an update instruction, malformed, oversized and over-limit files fail closed, and a refused import leaves the ledger unchanged. - A log saved from a confirmed or corrected AI suggestion records its provenance (source, suggested visible fields, model, confirmedAt) and the entry sheet states it. - Service worker shell cache v5 -> v6 so installed clients actually receive the change. Evidence: tests/portability.test.js (7 unit cases), tests/portability.acceptance.mjs (real browser: v1 upgrade, future refusal with unchanged ledger, provenance recorded, export v2, round trip), release demo scenes with on-camera caption proof, and the release builder gate portable_backup_restore. Refs #35
136 lines
5.1 KiB
JavaScript
136 lines
5.1 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 cache', async () => {
|
|
// The backup/provenance release (issue #35) changes app.js and src/domain.js, so the shell cache
|
|
// must advance or an installed client keeps serving the old ledger code indefinitely.
|
|
const { listeners, deleted } = loadWorker({
|
|
cacheKeys: [
|
|
'timmy-shell-v4',
|
|
'timmy-shell:/:v4',
|
|
'timmy-shell:/:v5',
|
|
'timmy-shell:/:v6',
|
|
'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, []);
|
|
});
|
|
|
|
function headerReader(values) {
|
|
return name => values[String(name).toLowerCase()] ?? null;
|
|
}
|
|
|
|
test('image bodies are never written into the shell cache', async () => {
|
|
const request = { method: 'GET', url: 'https://example.test/timmy-staging/history/entry-photo.jpg' };
|
|
const imageResponse = { headers: { get: headerReader({ 'content-type': 'image/jpeg' }) }, clone: () => ({}) };
|
|
const { listeners, puts, getCacheOpenCount } = loadWorker({
|
|
scope: 'https://example.test/timmy-staging/',
|
|
fetchImpl: async () => imageResponse,
|
|
});
|
|
|
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
|
await response;
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
assert.equal(await response, imageResponse);
|
|
assert.deepEqual(puts, []);
|
|
assert.equal(getCacheOpenCount(), 0);
|
|
});
|
|
|
|
test('ordinary shell assets are still cached so offline logging keeps working', async () => {
|
|
const request = { method: 'GET', url: 'https://example.test/app.js' };
|
|
const assetResponse = { headers: { get: headerReader({ 'content-type': 'text/javascript' }) }, clone: () => ({}) };
|
|
const { listeners, puts } = loadWorker({ fetchImpl: async () => assetResponse });
|
|
|
|
const response = await dispatchFetch(listeners.get('fetch'), request);
|
|
await response;
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
|
|
assert.deepEqual(puts, [request.url]);
|
|
});
|