All checks were successful
Quality gates / quality (pull_request) Successful in 4m14s
- collision-safe ID repair: duplicate ids inside stored data are repaired deterministically (first keeps id, twins get id#2, id#3, ... scanning past owned suffixes); every distinct local record survives, never dropped or silently merged; hostile id types (Symbol/BigInt/objects) repair onto fresh deterministic ids instead of throwing - transactional import: parse+merge into a candidate ledger, persist first, then commit memory; quota/error rolls back in-memory state and localStorage together with explicit user feedback; total 16MiB portability budget enforced before mutation on export, import (post-migration expansion), and storage writes - strict Timmy legacy contract for bare top-level arrays: nonempty array of plain rows each carrying a nonempty string id and integer Bristol 1-7; arbitrary unrelated arrays are rejected wholesale - no invented medical defaults from foreign JSON - canonical raster photo validation: strict JPEG/PNG/WebP grammar, canonical base64 (linear scan, no regex on multi-MB strings), atob round-trip decode, declared-format magic bytes, 32B-4MiB decoded bounds; mislabeled SVG/HTML and noncanonical tiny junk are stripped while genuine photos survive byte-for-byte - migrateStoredLedger: localStorage is validated and migrated before render; invalid dates become safe ISO timestamps, duplicate ids repaired, junk rows dropped (never fabricated into default records); healthy storage is byte-stable and never rewritten - sanitizeEntry absorbs Symbol/BigInt/hostile dates/throwing toString, valueOf, getTime, toJSON without throwing; results stay serializable - browser regression suite: quota rollback, pre-render migration, array rejection, photo contract, duplicate-ID preservation in the real app flow - staging-health startup-rejection budget anchored to measured server cold-start instead of a fixed 800ms (fixes load-sensitive flake)
317 lines
21 KiB
JavaScript
317 lines
21 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(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('<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>').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 });
|
||
}
|
||
});
|