feat: version ledger migrations and hardened JSON portability
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.
This commit is contained in:
Timmy 2026-08-22 20:32:57 +00:00
parent 47294a98aa
commit b8532f587d
10 changed files with 310 additions and 12 deletions

View File

@ -51,6 +51,7 @@ jobs:
npm run test:ui
npm run test:photo
npm run test:sleek
npm run test:portability
- name: Dependency audit
run: npm audit --audit-level=high
- name: Syntax checks

2
app.js
View File

@ -119,7 +119,7 @@ function privacy(){
document.querySelector('#export').onclick=exportData;document.querySelector('#import').onchange=importData;document.querySelector('#delete-all').onclick=deleteData;
}
function exportData(){const blob=new Blob([exportLedger(entries)],{type:'application/json'}),a=document.createElement('a');a.href=URL.createObjectURL(blob);a.download='timmy-ledger.json';a.click();URL.revokeObjectURL(a.href);toast('Export created');}
async function importData(e){try{const text=await e.target.files[0].text();entries=importLedger(text);saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
async function importData(e){try{const text=await e.target.files[0].text();entries=[...entries,...importLedger(text)];saveEntries();render();toast('Ledger imported')}catch(err){toast(err.message)}}
function deleteData(){if(confirm('Delete every local Timmy entry and photo? This cannot be undone.')){entries=[];localStorage.removeItem(STORE);localStorage.removeItem(LEGACY_STORE);render();toast('Local ledger deleted')}}
function openPhotoFirst(){form=draft();photoDataUrl='';photoHint='';aiSuggestion=null;visionStatus=null;showPhotoFirst('pick');loadVisionStatus()}

View File

@ -8,6 +8,7 @@
"test:ui": "node tests/ui.acceptance.mjs",
"test:photo": "node tests/photo-first.acceptance.mjs",
"test:sleek": "node tests/sleek-chat.acceptance.mjs",
"test:portability": "node tests/ledger-portability.acceptance.mjs",
"test:staging-smoke": "node tests/staging.acceptance.mjs",
"check:syntax": "node --check app.js && node --check server.mjs && node --check service-worker.js && node --check src/analysis.js && node --check src/domain.js && node --check src/hermes-agent-service.js && node --check src/vision-config.js && node --check src/vision-service.js && node --check scripts/record_release_demo.mjs && node --check tests/staging.acceptance.mjs && bash -n scripts/bootstrap_selfhost_smolvlm.sh && bash -n scripts/run_selfhost_smolvlm.sh && python3 -m py_compile scripts/ingest_training_photo.py scripts/build_release.py scripts/deploy_staging.py",
"check:diff": "bash scripts/check_diff.sh",

View File

@ -1,7 +1,7 @@
const ROOT = new URL(self.registration.scope).pathname;
const appPath = path => `${ROOT}${String(path).replace(/^\/+/, '')}`;
const CACHE_NAMESPACE = `timmy-shell:${ROOT}:`;
const CACHE = `${CACHE_NAMESPACE}v5`;
const CACHE = `${CACHE_NAMESPACE}v6`;
const ASSETS = [
'',
'index.html',

View File

@ -42,7 +42,7 @@ export function parseVisionResponse(raw) {
export function mergeVisualSuggestion(form, suggestion) {
if (suggestion?.status !== 'suggestion') return { ...form };
return { ...form, bristolType: suggestion.bristolType, color: suggestion.color };
return { ...form, bristolType: suggestion.bristolType, color: suggestion.color, provenance: { origin: 'ai-suggestion' } };
}
export function validatePhotoPayload(payload = {}) {

View File

@ -1,4 +1,5 @@
const URGENT_KEYS = ['blood', 'blackOrDarkRed', 'severePain', 'vomiting', 'fever', 'cannotPassGas'];
const KNOWN_PROVENANCE_ORIGINS = Object.freeze({ user: true, 'ai-suggestion': true });
const URGENT_MESSAGE = 'These reported symptoms can need prompt medical care. Contact a clinician or urgent service now; call emergency services for heavy or nonstop bleeding, fainting, or severe worsening symptoms.';
const URGENT_TEXT_PATTERNS = Object.freeze([
['blood', /\b(?:rectal bleeding|bleeding from (?:the )?(?:rectum|bottom)|blood(?:y)? (?:in|on|with) (?:my |the )?(?:stool|poop|bowel movement)|(?:stool|poop) (?:has|contains|with) blood)\b/i],
@ -57,7 +58,11 @@ export function sanitizeEntry(input = {}) {
const symptoms = {};
for (const key of URGENT_KEYS) symptoms[key] = input.symptoms?.[key] === true;
const bristolType = Math.min(7, Math.max(1, Number(input.bristolType) || 4));
return {
const provenanceOrigin = input.provenance && typeof input.provenance === 'object'
&& input.provenance.origin in KNOWN_PROVENANCE_ORIGINS
? input.provenance.origin
: null;
const entry = {
id: String(input.id || globalThis.crypto?.randomUUID?.() || `${Date.now()}-${Math.random()}`),
occurredAt: new Date(input.occurredAt || Date.now()).toISOString(),
bristolType,
@ -68,6 +73,8 @@ export function sanitizeEntry(input = {}) {
photoDataUrl: typeof input.photoDataUrl === 'string' && input.photoDataUrl.startsWith('data:image/') ? input.photoDataUrl : '',
symptoms,
};
if (provenanceOrigin) entry.provenance = { origin: provenanceOrigin };
return entry;
}
export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 } = {}) {
@ -79,16 +86,37 @@ export function photoQualityMessage({ width = 0, height = 0, brightness = 0.5 }
export function exportLedger(entries, exportedAt = new Date().toISOString()) {
return JSON.stringify({
product: 'Timmy the Talking Turd',
schemaVersion: 1,
product: PRODUCT_NAME,
schemaVersion: SCHEMA_VERSION,
exportedAt,
entries: Array.isArray(entries) ? entries : [],
entries: Array.isArray(entries) ? entries.map(sanitizeEntry) : [],
}, null, 2);
}
export const MAX_IMPORT_BYTES = 2 * 1024 * 1024;
const PRODUCT_NAME = 'Timmy the Talking Turd';
const SCHEMA_VERSION = 1;
const KNOWN_SCHEMA_VERSIONS = new Set([0, SCHEMA_VERSION]);
export function importLedger(text) {
const parsed = JSON.parse(text);
if (parsed?.schemaVersion !== 1 || !Array.isArray(parsed.entries)) throw new Error('This is not a supported Timmy export.');
if (typeof text !== 'string' || text.length === 0) throw new Error('This is not a supported Timmy export.');
if (text.length > MAX_IMPORT_BYTES) throw new RangeError('That file is too large to be a Timmy export.');
let parsed;
try {
parsed = JSON.parse(text);
} catch {
throw new Error('This is not a supported Timmy export.');
}
if (Array.isArray(parsed)) return parsed.map(sanitizeEntry);
const hasLedgerEnvelope = typeof parsed?.product === 'string'
&& Number.isInteger(parsed?.schemaVersion)
&& Array.isArray(parsed?.entries);
if (!hasLedgerEnvelope || parsed.product !== PRODUCT_NAME) {
throw new Error('This is not a supported Timmy export.');
}
if (!KNOWN_SCHEMA_VERSIONS.has(parsed.schemaVersion)) {
throw new RangeError(`This export uses schema version ${parsed.schemaVersion} from a newer Timmy app. Update Timmy first, then import again.`);
}
return parsed.entries.map(sanitizeEntry);
}

View File

@ -57,7 +57,17 @@ test('rejects malformed model output instead of guessing defaults', () => {
test('merges only visual fields and preserves user-reported context', () => {
const form = { bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } };
const merged = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 4, color: 'brown', confidence: 0.8 });
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true } });
assert.deepEqual(merged, { bristolType: 4, color: 'brown', urgency: 3, discomfort: 2, note: 'user note', symptoms: { fever: true }, provenance: { origin: 'ai-suggestion' } });
});
test('records ai-suggestion provenance only when a suggestion is actually applied', () => {
const form = { bristolType: 2, color: 'green', urgency: 1, discomfort: 0, note: '', symptoms: {} };
const abstained = mergeVisualSuggestion(form, { status: 'needs_user_input', reason: 'too uncertain' });
assert.deepEqual(abstained, form);
assert.equal(abstained.provenance, undefined);
const suggested = mergeVisualSuggestion(form, { status: 'suggestion', bristolType: 5, color: 'yellow', confidence: 0.9 });
assert.deepEqual(suggested.provenance, { origin: 'ai-suggestion' });
assert.equal(suggested.urgency, form.urgency, 'nonvisual fields stay user-owned');
});
test('accepts bounded JPEG/PNG/WebP data URLs and rejects oversized or unsupported input', () => {

View File

@ -8,6 +8,8 @@ import {
detectUrgentText,
exportLedger,
hasUrgentLedgerContext,
importLedger,
MAX_IMPORT_BYTES,
photoQualityMessage,
sanitizeEntry,
} from '../src/domain.js';
@ -118,6 +120,29 @@ test('sanitizes a user entry to the MVP data contract', () => {
assert.equal(entry.unexpected, undefined);
});
test('keeps only a bounded provenance origin and strips smuggled secrets', () => {
const entry = sanitizeEntry({
id: 'prov-1',
bristolType: 4,
provenance: { origin: 'ai-suggestion', suggestedBristolType: 4, apiToken: 'sk-secret-value', sessionId: 'hermes-session-x' },
});
assert.deepEqual(entry.provenance, { origin: 'ai-suggestion' });
assert.doesNotMatch(JSON.stringify(entry), /secret|session/i);
});
test('omits provenance entirely when none was recorded', () => {
const entry = sanitizeEntry({ id: 'plain-1', bristolType: 3 });
assert.equal(entry.provenance, undefined);
});
test('rejects provenance origins outside the recorded vocabulary', () => {
for (const bogus of ['clinician', 'self-diagnosis', '']) {
const entry = sanitizeEntry({ id: 'x', bristolType: 4, provenance: { origin: bogus } });
assert.equal(entry.provenance, undefined, bogus);
}
});
test('photo quality guidance is deterministic and does not claim visual diagnosis', () => {
assert.match(photoQualityMessage({ width: 300, height: 300, brightness: 0.5 }), /closer/i);
assert.match(photoQualityMessage({ width: 1200, height: 900, brightness: 0.02 }), /light/i);
@ -132,3 +157,96 @@ test('export ledger is portable JSON with version and entries', () => {
assert.equal(parsed.exportedAt, '2026-08-18T00:00:00.000Z');
assert.equal(parsed.entries.length, 1);
});
test('import migrates the legacy bare-array ledger to the current versioned envelope', () => {
const legacy = JSON.stringify([
{ id: 'legacy-1', occurredAt: '2026-08-17T12:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'older export' },
{ id: 'legacy-2', bristolType: 7 },
]);
const entries = importLedger(legacy);
assert.equal(entries.length, 2);
assert.equal(entries[0].id, 'legacy-1');
assert.equal(entries[0].bristolType, 2);
assert.equal(entries[0].note, 'older export');
});
test('import accepts every prior schema version and migrates entries forward', () => {
for (const version of [0, 1]) {
const payload = version === 0
? [{ id: `v${version}`, bristolType: 3 }]
: { product: 'Timmy the Talking Turd', schemaVersion: version, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: `v${version}`, bristolType: 3 }] };
const entries = importLedger(JSON.stringify(payload));
assert.equal(entries.length, 1, `schemaVersion ${version}`);
assert.equal(entries[0].bristolType, 3, `schemaVersion ${version}`);
}
});
test('import fails safely on a newer schema version instead of guessing', () => {
for (const schemaVersion of [2, 99]) {
assert.throws(
() => importLedger(JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion, entries: [{ id: 'x' }] })),
error => error instanceof RangeError && /newer Timmy app/i.test(error.message),
`schemaVersion ${schemaVersion}`,
);
}
});
test('import fails safely on malformed or wrong-shaped payloads', () => {
for (const payload of [
'not json',
'{"schemaVersion":1,"entries":{}}',
'{"entries":[]}',
'{"product":"Other App","schemaVersion":1,"entries":[]}',
null,
42,
]) {
assert.throws(() => importLedger(payload), /not a supported Timmy export/, JSON.stringify(String(payload)).slice(0, 40));
}
});
test('import rejects oversized ledgers before parsing user data', () => {
const huge = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-18T00:00:00.000Z', entries: [{ id: 'x', note: 'n'.repeat(MAX_IMPORT_BYTES + 1024) }] });
assert.ok(huge.length > MAX_IMPORT_BYTES);
assert.throws(() => importLedger(huge), RangeError);
});
test('round trip preserves confirmed values and provenance without leaking secrets', () => {
const saved = [
sanitizeEntry({
id: 'r1',
occurredAt: '2026-08-19T08:30:00.000Z',
bristolType: 2,
color: 'green',
urgency: 3,
discomfort: 2,
note: 'rough morning',
provenance: { origin: 'ai-suggestion', apiToken: 'sk-leaked-token' },
}),
{
id: 'raw-2', bristolType: 9, color: 'chartreuse', urgency: 11, discomfort: -4,
note: 'odd shape', symptoms: { blood: true }, sessionCookie: 'SID=hijack',
},
];
const exported = exportLedger(saved, '2026-08-20T00:00:00.000Z');
assert.doesNotMatch(exported, /sk-leaked-token|SID=hijack|chartreuse/);
const roundTripped = importLedger(exported);
assert.equal(roundTripped.length, 2);
assert.deepEqual(
{ id: roundTripped[0].id, occurredAt: roundTripped[0].occurredAt, bristolType: roundTripped[0].bristolType, color: roundTripped[0].color, urgency: roundTripped[0].urgency, discomfort: roundTripped[0].discomfort, note: roundTripped[0].note },
{ id: 'r1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'rough morning' },
);
assert.equal(roundTripped[0].symptoms.blood, false);
assert.deepEqual(roundTripped[0].provenance, { origin: 'ai-suggestion' });
assert.equal(roundTripped[1].bristolType, 7);
assert.equal(roundTripped[1].color, 'brown');
assert.equal(roundTripped[1].urgency, 4);
assert.equal(roundTripped[1].discomfort, 0);
assert.deepEqual(roundTripped[1].provenance, undefined);
});
test('re-exporting an imported ledger converges to the same portable document', () => {
const entries = [{ id: 'c1', occurredAt: '2026-08-19T08:30:00.000Z', bristolType: 6, color: 'yellow', urgency: 2, discomfort: 1, note: 'loose', provenance: { origin: 'user' } }];
const first = JSON.parse(exportLedger(entries, '2026-08-20T00:00:00.000Z'));
const second = JSON.parse(exportLedger(importLedger(exportLedger(entries, '2026-08-20T00:00:00.000Z')), '2026-08-20T00:00:00.000Z'));
assert.deepEqual(second, first);
});

View File

@ -0,0 +1,140 @@
import { chromium } from 'playwright';
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { setTimeout as sleep } from 'node:timers/promises';
const ROOT_STORE = 'timmy:/:ledger-v1';
const LEGACY_STORE = 'timmy-ledger-v1';
await mkdtemp(join(tmpdir(), 'timmy-portability-')).then(async (workDir) => {
const browser = await chromium.launch({ headless: true });
try {
const context = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' });
const page = await context.newPage();
const errors = [];
page.on('console', message => { if (message.type() === 'error') errors.push(message.text()); });
page.on('pageerror', error => errors.push(error.message));
page.on('dialog', dialog => dialog.accept());
await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' });
await page.evaluate(() => localStorage.clear());
await page.reload({ waitUntil: 'networkidle' });
// Seed one confirmed, user-owned entry with provenance.
await page.evaluate(() => {
localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([
{
id: 'seed-1', occurredAt: '2026-08-21T08:30:00.000Z', bristolType: 2, color: 'green',
urgency: 3, discomfort: 2, note: 'seeded confirmed entry', photoDataUrl: '',
symptoms: { blood: false }, provenance: { origin: 'ai-suggestion' },
},
]));
});
await page.reload({ waitUntil: 'networkidle' });
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 1, 'seeded entry renders');
// Export writes the versioned envelope with provenance and no secrets.
await page.locator('[data-view="calendar"]').last().click();
await page.locator('[data-view="privacy"]').click();
const downloadPromise = page.waitForEvent('download');
await page.locator('#export').click();
const download = await downloadPromise;
const exportedText = await download.path().then(readFile).then(buffer => buffer.toString('utf8'));
const exported = JSON.parse(exportedText);
assert.equal(exported.schemaVersion, 1);
assert.equal(exported.entries[0].provenance.origin, 'ai-suggestion');
assert.doesNotMatch(exportedText, /apiToken|sessionId|sk-/i);
// Importing a prior-version export merges instead of replacing user data.
const legacyPath = join(workDir, 'legacy-ledger.json');
await writeFile(legacyPath, JSON.stringify([
{ id: 'legacy-9', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 1, discomfort: 0, note: 'legacy import' },
]));
await page.setInputFiles('#import', legacyPath);
await page.getByText('Ledger imported').waitFor({ timeout: 5000 });
const mergedIds = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(mergedIds.sort(), ['legacy-9', 'seed-1'], 'import must merge, not replace');
// A future schema version fails safely and leaves the ledger untouched.
const futurePath = join(workDir, 'future-ledger.json');
await writeFile(futurePath, JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 2, entries: [{ id: 'from-the-future' }] }));
await page.setInputFiles('#import', futurePath);
await page.getByText(/newer Timmy app/).waitFor({ timeout: 5000 });
const afterFuture = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterFuture.sort(), ['legacy-9', 'seed-1'], 'failed import must not mutate the ledger');
// Malformed JSON fails safely and leaves the ledger untouched.
const malformedPath = join(workDir, 'malformed-ledger.json');
await writeFile(malformedPath, '{"schemaVersion":1,"entries":');
await page.setInputFiles('#import', malformedPath);
await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 });
const afterMalformed = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE);
assert.deepEqual(afterMalformed.sort(), ['legacy-9', 'seed-1'], 'malformed import must not mutate the ledger');
await page.screenshot({ path: 'artifacts/portability-import-mobile.png', fullPage: false });
// Delete Everything removes every namespaced copy of the local ledger.
await page.locator('#delete-all').click();
await page.getByText('Local ledger deleted').waitFor({ timeout: 5000 });
const storesAfterDelete = await page.evaluate(([rootStore, legacyStore]) => ({
root: localStorage.getItem(rootStore),
legacy: localStorage.getItem(legacyStore),
}), [ROOT_STORE, LEGACY_STORE]);
assert.equal(storesAfterDelete.root, null, 'namespaced store cleared');
assert.equal(storesAfterDelete.legacy, null, 'legacy store cleared');
await page.locator('[data-view="calendar"]').last().click();
assert.equal(await page.locator('.entry').count(), 0, 'journal is empty after delete-all');
// Base-path deployments keep their ledger in an isolated namespace.
const staging = spawn(process.execPath, ['server.mjs'], {
env: { ...process.env, PORT: '4179', HOST: '127.0.0.1', TIMMY_BASE_PATH: '/timmy-staging' },
stdio: 'ignore',
});
try {
let up = false;
for (let attempt = 0; attempt < 40 && !up; attempt += 1) {
up = await fetch('http://127.0.0.1:4179/timmy-staging/api/healthz').then(response => response.ok).catch(() => false);
if (!up) await sleep(250);
}
assert.ok(up, 'staging server must start');
const stagingContext = await browser.newContext({ viewport: { width: 390, height: 844 }, serviceWorkers: 'block' });
const stagingPage = await stagingContext.newPage();
stagingPage.on('dialog', dialog => dialog.accept());
await stagingPage.goto('http://127.0.0.1:4179/timmy-staging', { waitUntil: 'networkidle' });
await stagingPage.evaluate(() => {
localStorage.clear();
localStorage.setItem('timmy:/timmy-staging:ledger-v1', JSON.stringify([
{ id: 'staging-1', occurredAt: '2026-08-21T10:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'staging only', photoDataUrl: '', symptoms: {} },
]));
});
await stagingPage.reload({ waitUntil: 'networkidle' });
await stagingPage.locator('[data-view="calendar"]').last().click();
assert.equal(await stagingPage.locator('.entry').count(), 1, 'staging entry renders under its base path');
const isolation = await stagingPage.evaluate(() => ({
staging: localStorage.getItem('timmy:/timmy-staging:ledger-v1'),
root: localStorage.getItem('timmy:/:ledger-v1'),
legacy: localStorage.getItem('timmy-ledger-v1'),
}));
assert.ok(isolation.staging, 'staging store keeps its data');
assert.equal(isolation.root, null, 'root-namespaced store untouched by staging data');
assert.equal(isolation.legacy, null, 'legacy store untouched by staging data');
await stagingPage.locator('[data-view="privacy"]').click();
await stagingPage.locator('#delete-all').click();
await stagingPage.getByText('Local ledger deleted').waitFor({ timeout: 5000 });
const stagingAfterDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy:/timmy-staging:ledger-v1'));
assert.equal(stagingAfterDelete, null, 'delete-all clears the base-path store');
await stagingContext.close();
} finally {
staging.kill();
}
assert.deepEqual(errors, [], 'no console or page errors');
await context.close();
console.log('PASS ledger portability: export round trip, merge import, safe failures, isolation, delete-all');
} finally {
await browser.close();
await rm(workDir, { recursive: true, force: true });
}
});

View File

@ -43,7 +43,7 @@ async function dispatchFetch(handler, request) {
return response;
}
test('root activation deletes only its obsolete Timmy caches including the legacy v4 cache', async () => {
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',
@ -56,7 +56,7 @@ test('root activation deletes only its obsolete Timmy caches including the legac
await dispatchExtendable(listeners.get('activate'));
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4']);
assert.deepEqual(deleted.sort(), ['timmy-shell-v4', 'timmy-shell:/:v4', 'timmy-shell:/:v5']);
});
test('offline shell lookup uses only the current named cache', async () => {