Quality gates / quality (pull_request) Successful in 18m50s
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
132 lines
7.7 KiB
JavaScript
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'] }));
|