From b8532f587d8e2d8ee04b24173d7cde4305a4eaa4 Mon Sep 17 00:00:00 2001 From: Timmy Date: Sat, 22 Aug 2026 20:32:57 +0000 Subject: [PATCH] feat: version ledger migrations and hardened JSON portability 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. --- .gitea/workflows/quality.yml | 1 + app.js | 2 +- package.json | 1 + service-worker.js | 2 +- src/analysis.js | 2 +- src/domain.js | 40 ++++++- tests/analysis.test.js | 12 +- tests/domain.test.js | 118 ++++++++++++++++++++ tests/ledger-portability.acceptance.mjs | 140 ++++++++++++++++++++++++ tests/service-worker-runtime.test.js | 4 +- 10 files changed, 310 insertions(+), 12 deletions(-) create mode 100644 tests/ledger-portability.acceptance.mjs diff --git a/.gitea/workflows/quality.yml b/.gitea/workflows/quality.yml index c77cc35..0dc62f0 100644 --- a/.gitea/workflows/quality.yml +++ b/.gitea/workflows/quality.yml @@ -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 diff --git a/app.js b/app.js index 1d9c082..c8eee79 100644 --- a/app.js +++ b/app.js @@ -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()} diff --git a/package.json b/package.json index 31839e0..e5a2754 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/service-worker.js b/service-worker.js index bbe69e7..9a60f51 100644 --- a/service-worker.js +++ b/service-worker.js @@ -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', diff --git a/src/analysis.js b/src/analysis.js index 8053cfc..e36d019 100644 --- a/src/analysis.js +++ b/src/analysis.js @@ -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 = {}) { diff --git a/src/domain.js b/src/domain.js index a7b7832..2964163 100644 --- a/src/domain.js +++ b/src/domain.js @@ -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); } diff --git a/tests/analysis.test.js b/tests/analysis.test.js index 56e130e..db99f70 100644 --- a/tests/analysis.test.js +++ b/tests/analysis.test.js @@ -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', () => { diff --git a/tests/domain.test.js b/tests/domain.test.js index 08c43f3..e2df743 100644 --- a/tests/domain.test.js +++ b/tests/domain.test.js @@ -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); +}); diff --git a/tests/ledger-portability.acceptance.mjs b/tests/ledger-portability.acceptance.mjs new file mode 100644 index 0000000..acf25ac --- /dev/null +++ b/tests/ledger-portability.acceptance.mjs @@ -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 }); + } +}); diff --git a/tests/service-worker-runtime.test.js b/tests/service-worker-runtime.test.js index e93fa4e..c497392 100644 --- a/tests/service-worker-runtime.test.js +++ b/tests/service-worker-runtime.test.js @@ -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 () => {