Quality gates / quality (pull_request) Successful in 1m54s
Logging must never depend on AI availability, and an unsaved entry must not disappear when the user navigates away mid-log. - add src/vision-readiness.js so the photo flow consumes one readiness contract - when the worker is offline/disabled/unknown, offer no AI action and no analysis-consent prompt, and keep manual Bristol entry one action away - add src/draft.js and make the draft own the photo so a chosen photo and entered context survive a recoverable close or navigation, with a visible kept-draft summary and an explicit start over - drafts stay in session memory only: a photo awaiting consent is never persisted, and reload starts clean - service worker now refuses to cache image bodies in addition to analysis requests and no-store responses - tests: 2 new unit files, 3 service-worker privacy assertions (1 hold-out), 1 browser acceptance file, release-demo and CI/package assertions - release demo now visibly replays the vision-offline pass and the retained draft with synthetic data and an in-script assertion
133 lines
4.9 KiB
JavaScript
133 lines
4.9 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 () => {
|
|
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']);
|
|
});
|
|
|
|
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]);
|
|
});
|