Files
timmy-talking-turd/tests/service-worker-runtime.test.js
T
timmy dce668e72b
Quality gates / quality (pull_request) Successful in 1m54s
feat: guarantee manual fallback and in-session draft recovery (#12)
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
2026-10-07 08:12:43 +00:00

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]);
});