Files
timmy-talking-turd/tests/portability.acceptance.mjs
T
timmy 2094a07d9f
Quality gates / quality (pull_request) Successful in 18m50s
feat(portability): version the backup, migrate older files, and keep AI provenance (#35)
A backup is the person's own record and has to keep working as the AI fields evolve.

- Export stamps schema version 2 and carries the confirmed logs plus the training
  contribution receipts, including withdrawn tombstones; both are written through an
  allowlist so unknown keys and credentials never leave the device.
- Import accepts version 1 and 2. A version 1 file is upgraded, the notice names the
  version it came from, and entries that predate provenance stay null instead of being
  given a source they never recorded.
- A file from a newer Timmy is refused with an update instruction, malformed, oversized
  and over-limit files fail closed, and a refused import leaves the ledger unchanged.
- A log saved from a confirmed or corrected AI suggestion records its provenance
  (source, suggested visible fields, model, confirmedAt) and the entry sheet states it.
- Service worker shell cache v5 -> v6 so installed clients actually receive the change.

Evidence: tests/portability.test.js (7 unit cases), tests/portability.acceptance.mjs
(real browser: v1 upgrade, future refusal with unchanged ledger, provenance recorded,
export v2, round trip), release demo scenes with on-camera caption proof, and the
release builder gate portable_backup_restore.

Refs #35
2026-10-22 11:08:55 +00:00

132 lines
7.7 KiB
JavaScript

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