import { chromium } from 'playwright'; import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; // Issue #35 — a backup is the person's own record, and it has to keep working as the app's AI // provenance fields evolve. This drives the real app in a browser: a version 1 file is upgraded and // says so, a file from a newer Timmy is refused without touching the ledger, the export carries the // contribution receipts, and a log saved from a confirmed AI suggestion comes back saying that. // // The image used below is the committed synthetic Type 4 fixture; no real photo is involved. const browser = await chromium.launch({ headless: true }); const context = await browser.newContext({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, 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()); const suggestionBody = { status: 'suggestion', isStool: true, bristolType: 4, color: 'brown', confidence: 0.83, imageQuality: 'good', observations: 'Smooth, formed appearance.', warning: 'Visual suggestion only. Confirm it yourself; this is not a diagnosis.', }; await page.route('**/api/vision-status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, profile: 'selfhost', processor: 'self-hosted', model: 'SmolVLM2-2.2B-Instruct', providerReady: true, modelSeen: true }), })); await page.route('**/api/analyze', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(suggestionBody) })); await page.route('**/api/agent/status', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ enabled: true, configured: false, authenticated: false, mode: 'local-fallback' }), })); await page.goto('http://127.0.0.1:4173', { waitUntil: 'networkidle' }); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); const basePath = () => page.evaluate(() => document.querySelector('meta[name="timmy-base-path"]')?.content || '/'); const storeKey = async suffix => `timmy:${await basePath()}:${suffix}`; const readStore = async suffix => { const raw = await page.evaluate(key => localStorage.getItem(key), await storeKey(suffix)); return raw === null ? null : JSON.parse(raw); }; // The privacy view sits behind the journal's settings row, exactly as a person reaches it. const openPrivacy = async () => { await page.locator('[data-view="calendar"]').first().click(); await page.locator('.settings-row[data-view="privacy"]').first().click(); await page.locator('#export').waitFor(); }; const importBackup = async (name, text) => { await page.locator('#import').setInputFiles({ name, mimeType: 'application/json', buffer: Buffer.from(text) }); await page.waitForTimeout(350); }; const legacyBackup = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-01T09:00:00.000Z', entries: [ { id: 'legacy-1', occurredAt: '2026-08-01T08:00:00.000Z', bristolType: 3, color: 'brown', urgency: 1, discomfort: 0, note: 'synthetic', photoDataUrl: '', symptoms: {} }, { id: 'legacy-2', occurredAt: '2026-08-02T08:00:00.000Z', bristolType: 6, color: 'brown', urgency: 2, discomfort: 1, note: '', photoDataUrl: '', symptoms: {} }, ], }); // 1. A version 1 backup is imported, upgraded, and says which version it came from. await openPrivacy(); await importBackup('timmy-backup-v1.json', legacyBackup); const upgraded = await readStore('ledger-v1'); assert.equal(upgraded.length, 2, 'the version 1 backup must restore both logs'); await page.locator('[data-portability-notice]').waitFor(); const noticeText = (await page.locator('[data-portability-notice]').innerText()).trim(); assert.match(noticeText, /version 1/i, 'the notice must name the version the file came from'); assert.match(noticeText, /upgraded/i, 'the notice must say the backup was upgraded'); assert.equal(await page.locator('[data-portability-notice]').getAttribute('data-portability-version'), '1'); assert.equal(upgraded[0].provenance, null, 'a pre-provenance backup must not acquire a source it never recorded'); assert.equal(upgraded[0].bristolType, 3, 'the confirmed form must survive the upgrade'); // 2. A file from a newer Timmy is refused, and the ledger it would have replaced stays exactly as it was. const before = JSON.stringify(await readStore('ledger-v1')); await importBackup('timmy-backup-v3.json', JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 3, entries: [{ id: 'future', bristolType: 7 }] })); await page.locator('[data-portability-error]').waitFor(); const errorText = (await page.locator('[data-portability-error]').innerText()).trim(); assert.match(errorText, /newer version of Timmy/i); assert.equal(JSON.stringify(await readStore('ledger-v1')), before, 'a refused import must not change the ledger'); // 3. A log saved from a confirmed AI suggestion records that provenance, and the export states it. await page.locator('[data-view="home"]').first().click(); await page.locator('[data-scan]').first().click(); await page.getByText(/Self-hosted model ready/i).waitFor(); await page.locator('#gallery-photo').setInputFiles('tests/fixtures/synthetic-type4.jpg'); await page.locator('#ai-consent').check(); await page.locator('#analyze-photo').click(); await page.locator('#use-suggestion').waitFor(); await page.locator('#use-suggestion').click(); await page.locator('[data-provenance="confirmed"]').waitFor(); await page.locator('#next').click(); await page.locator('#next').click(); await page.locator('#save').click(); await page.getByText(/Private log saved|Saved/).waitFor(); const savedLedger = await readStore('ledger-v1'); const assisted = savedLedger.find(entry => entry.id !== 'legacy-1' && entry.id !== 'legacy-2'); assert.ok(assisted, 'the saved log must be in the ledger'); assert.equal(assisted.provenance?.source, 'ai-confirmed', 'a confirmed AI suggestion must be recorded on the saved log'); assert.equal(assisted.provenance?.suggestedBristolType, 4); assert.equal(assisted.provenance?.model, 'SmolVLM2-2.2B-Instruct'); // 4. Export carries the current version, the entries, and the contribution receipts — and nothing else. await openPrivacy(); const [download] = await Promise.all([page.waitForEvent('download'), page.locator('#export').click()]); const exported = JSON.parse(await readFile(await download.path(), 'utf8')); assert.equal(exported.schemaVersion, 2); assert.equal(exported.entries.length, 3); assert.deepEqual(exported.contributions, [], 'nothing was contributed, so no receipt may appear'); assert.equal(exported.entries.find(entry => entry.id === assisted.id).provenance.source, 'ai-confirmed'); // 5. Round trip: the export restores into a cleared browser with provenance intact. const exportedText = JSON.stringify(exported); await page.evaluate(() => localStorage.clear()); await page.reload({ waitUntil: 'networkidle' }); await openPrivacy(); await importBackup('timmy-backup-v2.json', exportedText); const roundTripped = await readStore('ledger-v1'); assert.equal(roundTripped.length, 3); const restoredAssisted = roundTripped.find(entry => entry.id === assisted.id); assert.deepEqual(restoredAssisted.provenance, assisted.provenance, 'provenance must survive a round trip'); await page.locator('[data-portability-notice]').waitFor(); assert.match((await page.locator('[data-portability-notice]').innerText()).trim(), /version 2/i); assert.deepEqual(errors, [], `browser errors: ${errors.join(' | ')}`); await browser.close(); console.log(JSON.stringify({ ok: true, scenes: ['v1-upgrade', 'future-refused', 'provenance-recorded', 'export-v2', 'round-trip'] }));