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 }); // 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'); // 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: {} }, ])); // 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 }); } });