feat: version ledger migrations and hardened JSON portability
All checks were successful
Quality gates / quality (pull_request) Successful in 1m42s
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:
parent
47294a98aa
commit
b8532f587d
|
|
@ -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
2
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()}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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 = {}) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
140
tests/ledger-portability.acceptance.mjs
Normal file
140
tests/ledger-portability.acceptance.mjs
Normal 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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user