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.
141 lines
7.9 KiB
JavaScript
141 lines
7.9 KiB
JavaScript
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 });
|
|
}
|
|
});
|