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(process.env.TIMMY_TEST_URL || '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 }); // Byte-limit enforcement in the browser: File.size is checked BEFORE the // file is read, and UTF-8 bytes (not JS characters) are the measured unit. const MAX_IMPORT_BYTES = await page.evaluate(async () => (await import('/src/domain.js')).MAX_IMPORT_BYTES); const oversizePath = join(workDir, 'oversize-ledger.json'); await writeFile(oversizePath, Buffer.concat([ Buffer.from('{"product":"Timmy the Talking Turd","schemaVersion":1,"entries":[{"id":"x","note":"'), Buffer.alloc(MAX_IMPORT_BYTES + 1, 0x6e), Buffer.from('"}]}'), ])); let textReads = 0; await page.evaluate(() => { const original = File.prototype.text; File.prototype.text = function (...args) { window.__fileTextReads = (window.__fileTextReads || 0) + 1; return original.apply(this, args); }; }); await page.setInputFiles('#import', oversizePath); await page.getByText(/too large/i).waitFor({ timeout: 5000 }); textReads = await page.evaluate(() => window.__fileTextReads || 0); assert.equal(textReads, 0, 'oversized files must be rejected by File.size before File.text()'); const afterOversize = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); assert.deepEqual(afterOversize.sort(), ['legacy-9', 'seed-1'], 'oversized import must not mutate the ledger'); // Multibyte boundary: a payload whose UTF-8 byte size exceeds the cap while // its JS character count does not must still be rejected (byte-exact limit). const emojiHead = '{"product":"Timmy the Talking Turd","schemaVersion":1,"exportedAt":"2026-08-22T00:00:00.000Z","entries":[{"id":"e","occurredAt":"2026-08-20T09:00:00.000Z","bristolType":4,"color":"brown","urgency":0,"discomfort":0,"note":"'; const emojiTail = '"}]}'; const emojiNoteUnits = Math.ceil(MAX_IMPORT_BYTES / 3); // ~1.33x cap in UTF-8 bytes, ~0.67x cap in JS units const multibyteOverBytesPath = join(workDir, 'multibyte-over-bytes.json'); await writeFile(multibyteOverBytesPath, Buffer.from(emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail, 'utf8')); const multibyteStats = await page.evaluate(payload => { return { bytes: new TextEncoder().encode(payload).length, units: payload.length }; }, emojiHead + '💩'.repeat(emojiNoteUnits) + emojiTail); assert.ok(multibyteStats.bytes > MAX_IMPORT_BYTES, 'fixture must exceed the cap in UTF-8 bytes'); assert.ok(multibyteStats.units <= MAX_IMPORT_BYTES, 'fixture must stay under the cap in JS characters'); await page.setInputFiles('#import', multibyteOverBytesPath); await page.getByText(/too large/i).waitFor({ timeout: 5000 }); const afterMultibyte = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); assert.deepEqual(afterMultibyte.sort(), ['legacy-9', 'seed-1'], 'multibyte over-byte import must not mutate the ledger'); // A dense multibyte payload just UNDER the byte cap still imports cleanly. const underBytesPath = join(workDir, 'multibyte-under-bytes.json'); const underPayload = JSON.stringify({ product: 'Timmy the Talking Turd', schemaVersion: 1, exportedAt: '2026-08-22T00:00:00.000Z', entries: [{ id: 'under-1', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'café ☕'.repeat(2000), photoDataUrl: '', symptoms: {} }], }); const underBytes = await page.evaluate(payload => new TextEncoder().encode(payload).length, underPayload); assert.ok(underBytes <= MAX_IMPORT_BYTES && underBytes > 10000, 'under-cap fixture must carry real multibyte mass'); await writeFile(underBytesPath, Buffer.from(underPayload, 'utf8')); await page.setInputFiles('#import', underBytesPath); await page.getByText('Ledger imported').waitFor({ timeout: 5000 }); const afterUnderBytes = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); assert.ok(afterUnderBytes.includes('under-1'), 'valid multibyte import lands in the ledger'); // Collision-safe merge: re-importing a file whose ids already exist must // not duplicate or overwrite anything and must say so explicitly. const beforeReimport = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE); await page.setInputFiles('#import', underBytesPath); await page.getByText(/already in your ledger/i).waitFor({ timeout: 5000 }); const afterReimport = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey) || '[]').map(entry => entry.id), ROOT_STORE); assert.deepEqual(afterReimport.sort(), JSON.parse(beforeReimport).map(entry => entry.id).sort(), 're-import is idempotent: no duplicates, no overwrites'); // RED 1 — transactional import: a quota failure during save must roll back // BOTH the in-memory ledger and localStorage together. const beforeQuota = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE); const domCountBeforeQuota = await page.locator('.entry').count(); await page.evaluate(() => { // Simulate storage exhaustion at the exact moment of persistence, // keeping the original setter around for a faithful restore. const real = Object.getOwnPropertyDescriptor(Storage.prototype, 'setItem'); window.__realSetItem = real; Object.defineProperty(Storage.prototype, 'setItem', { ...real, value: function setItem() { throw new DOMException('quota exceeded', 'QuotaExceededError'); }, }); }); const quotaImportPath = join(workDir, 'quota-import.json'); await writeFile(quotaImportPath, JSON.stringify([ { id: 'quota-new-1', occurredAt: '2026-08-22T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'arrives right before quota failure', photoDataUrl: '', symptoms: {} }, ])); await page.setInputFiles('#import', quotaImportPath); await page.getByText(/storage is full/i).waitFor({ timeout: 5000 }); const afterQuotaStorage = await page.evaluate(([storeKey]) => localStorage.getItem(storeKey), [ROOT_STORE]); assert.equal(afterQuotaStorage, beforeQuota, 'localStorage must be untouched after a failed save'); const inMemoryIds = await page.evaluate(() => Array.from(document.querySelectorAll('.entry strong')).map(node => node.textContent)); assert.equal(await page.locator('.entry').count(), domCountBeforeQuota, 'rendered journal shows no partially imported rows'); assert.ok(!JSON.stringify(inMemoryIds).includes('quota-new-1'), 'in-memory ledger rolled back with storage'); await page.evaluate(() => { Object.defineProperty(Storage.prototype, 'setItem', window.__realSetItem); delete window.__realSetItem; }); // RED 2 — pre-render migration: malformed stored history (invalid dates, // duplicate ids) is repaired before render instead of blanking the UI. await page.evaluate(() => { localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([ { id: 'broken-date', occurredAt: 'not-a-real-date', bristolType: 3, note: 'date was corrupted' }, { id: 'twin-row', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, note: 'twin one' }, { id: 'twin-row', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, note: 'twin two' }, ])); }); await page.reload({ waitUntil: 'networkidle' }); assert.ok((await page.getByText(/Timmy noticed|Journal/).count()) > 0 || (await page.locator('#app').innerText()).length > 0, 'app renders over malformed storage'); await page.locator('[data-view="calendar"]').last().click(); assert.equal(await page.locator('.entry').count(), 3, 'invalid date did not blank the UI; every distinct record renders'); const migratedStore = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE); const brokenRow = migratedStore.find(entry => entry.note === 'date was corrupted'); assert.ok(brokenRow && !Number.isNaN(new Date(brokenRow.occurredAt).getTime()), 'stored invalid dates become valid ISO timestamps'); const twinRows = migratedStore.filter(entry => entry.id.startsWith('twin-row')); assert.deepEqual(twinRows.map(entry => entry.id).sort(), ['twin-row', 'twin-row#2'], 'duplicate ids repaired deterministically in storage'); // RED 3 — ambiguous top-level arrays are rejected wholesale in the browser. const unrelatedPath = join(workDir, 'unrelated-array.json'); await writeFile(unrelatedPath, JSON.stringify([{ userId: 7, email: 'person@example.com', preferences: { theme: 'dark' } }])); const beforeUnrelated = await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE); await page.locator('[data-view="privacy"]').click(); await page.setInputFiles('#import', unrelatedPath); await page.getByText(/not a supported Timmy export/).waitFor({ timeout: 5000 }); assert.equal( await page.evaluate(storeKey => localStorage.getItem(storeKey), ROOT_STORE), beforeUnrelated, 'rejected unrelated arrays never mutate the ledger', ); // RED 4 — mislabeled SVG photos are dropped by canonical raster validation // while genuinely canonical photos survive import untouched. const svgB64 = Buffer.from('').toString('base64'); const fakePhotoEntry = { id: 'fake-photo', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 4, color: 'brown', urgency: 0, discomfort: 0, note: 'smuggled svg', symptoms: {}, photoDataUrl: `data:image/jpeg;base64,${svgB64}`, }; const realJpeg = Buffer.concat([Buffer.from([0xFF, 0xD8, 0xFF, 0xE0]), Buffer.alloc(600, 0x33)]).toString('base64'); const realPhotoEntry = { id: 'real-photo', occurredAt: '2026-08-20T10:00:00.000Z', bristolType: 2, color: 'green', urgency: 1, discomfort: 0, note: 'genuine raster', symptoms: {}, photoDataUrl: `data:image/jpeg;base64,${realJpeg}`, }; const photoImportPath = join(workDir, 'photo-contract.json'); await writeFile(photoImportPath, JSON.stringify([fakePhotoEntry, realPhotoEntry])); await page.setInputFiles('#import', photoImportPath); await page.getByText('Ledger imported').waitFor({ timeout: 5000 }); const afterPhotos = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE); const fakeStored = afterPhotos.find(entry => entry.id === 'fake-photo'); assert.ok(fakeStored, 'the record itself still imports'); assert.equal(fakeStored.photoDataUrl, '', 'mislabeled SVG payload is stripped from the entry'); const realStored = afterPhotos.find(entry => entry.id === 'real-photo'); assert.equal(realStored.photoDataUrl, `data:image/jpeg;base64,${realJpeg}`, 'canonical raster photos survive byte-for-byte'); // RED 5 — duplicate-ID repair preserves both distinct local records in the // full app flow, and the repair lands in storage. await page.evaluate(() => { localStorage.setItem('timmy:/:ledger-v1', JSON.stringify([ { id: 'dup-pair', occurredAt: '2026-08-20T09:00:00.000Z', bristolType: 2, color: 'green', urgency: 3, discomfort: 2, note: 'first distinct record', photoDataUrl: '', symptoms: {} }, { id: 'dup-pair', occurredAt: '2026-08-21T09:00:00.000Z', bristolType: 6, color: 'yellow', urgency: 1, discomfort: 0, note: 'second distinct record', photoDataUrl: '', symptoms: {} }, ])); }); await page.reload({ waitUntil: 'networkidle' }); await page.locator('[data-view="calendar"]').last().click(); assert.equal(await page.locator('.entry').count(), 2, 'both duplicate-id records render as distinct rows'); const dupStore = await page.evaluate(storeKey => JSON.parse(localStorage.getItem(storeKey)), ROOT_STORE); const dupIds = dupStore.map(entry => entry.id).sort(); assert.deepEqual(dupIds, ['dup-pair', 'dup-pair#2'], 'storage holds two distinct deterministic ids'); // Delete Everything removes every namespaced copy of the local ledger. await page.locator('[data-view="privacy"]').click(); 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: {} }, ])); // Legacy root-deployment ledger living in the same origin's storage. localStorage.setItem('timmy-ledger-v1', JSON.stringify([ { id: 'root-legacy-1', occurredAt: '2026-08-19T09:00:00.000Z', bristolType: 3, color: 'brown', urgency: 0, discomfort: 0, note: 'root legacy ledger', 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.ok(isolation.legacy && JSON.parse(isolation.legacy).some(entry => entry.id === 'root-legacy-1'), 'legacy store untouched by staging session'); 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'); const legacyAfterStagingDelete = await stagingPage.evaluate(() => localStorage.getItem('timmy-ledger-v1')); assert.ok( legacyAfterStagingDelete && JSON.parse(legacyAfterStagingDelete).some(entry => entry.id === 'root-legacy-1'), 'base-path delete-all must not erase another namespace’s global legacy ledger', ); 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 }); } });