Files
Endoleap/tests/headless/browser-storage-cases.mjs
T

102 lines
8.7 KiB
JavaScript

import { createBrowserStorage } from '../../src/lib/canvas-runtime/storage.ts';
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
import { createDocument, createRegistry, builtinComponents, applyChanges } from '../../src/lib/canvas-core/index.ts';
async function engineRecoveryCase(name, assert) {
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' }, registry = createRegistry(builtinComponents());
const document = createDocument('project', [{ id: 'canvas', parent: null }], [{ id: 'element', incarnation: 1, ownerCanvasId: 'canvas', componentId: 'text', dataVersion: 1, structure: 'node', geometry: { x: 0, y: 0, width: 100, height: 100, rotation: 0 }, data: { title: 'initial', text: '' } }]);
let server = document; const receipts = new Map(), calls = [];
const port = {
compile(intent, requestId, snapshot) { return { payload: { intent, requestId }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(snapshot.runtime.versions) }; },
async submit(request) { calls.push(['submit', request]); const intent = JSON.parse(request.body).intent; server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['canvas', 2]]) }); return { outcome: 'unresolved' }; },
async query(request) { calls.push(['query', request]); return { outcome: 'applied', canonical: { receiptId: request.id } }; },
canonical(request) { return receipts.get(request.id); },
};
const storage = createBrowserStorage({ name: `${name}-engine`, checkpointEvery: 4 }), options = { scope, registry, document, versions: new Map([['canvas', 1]]), storage, persistence: port };
let engine;
try {
engine = await openProjectEngine(options);
const detach = engine.sessions.attach('view');
engine.editor.dispatch({ type: 'element.data', target: { id: 'element', incarnation: 1 }, patch: { title: 'durable edit' } });
detach(); await engine.settle();
assert(calls.length === 1, 'detaching last view must not abort persistent work');
const request = calls[0][1];
await engine.close();
const futureClock = { now: () => Date.now() + 100_000, setTimer: (callback, delay) => setTimeout(callback, delay), clearTimer: handle => clearTimeout(handle) };
engine = await openProjectEngine({ ...options, clock: futureClock });
await new Promise(resolve => setTimeout(resolve, 10)); await engine.settle();
assert(calls.length === 2 && calls[1][0] === 'query', 'recovered dispatched request must query before a new submit');
assert(calls[1][1].id === request.id && calls[1][1].body === request.body, 'request ID/body must survive real IndexedDB recovery exactly');
assert(engine.getSnapshot().document.elements.get('element').data.title === 'durable edit', 'recovered pending changes must not replay twice');
assert(engine.getSnapshot().history.undo.length === 1 && engine.getSnapshot().operations.size === 1, 'history and ledger must recover the same state cut');
assert(engine.sessions.getSnapshot().views.size === 0, 'transient views must not be recovered as document data');
assert(engine.getDurability().rootRevision === engine.getDurability().durableRootRevision, 'final receipt and replay must cross the durable barrier');
} finally { await engine?.close(); storage.close(); indexedDB.deleteDatabase(`${name}-engine`); }
}
export async function run() {
const results = [], name = `headless-test-${crypto.randomUUID()}`;
const scope = { accountId: 'test-account', workspaceId: 'test-workspace', projectId: 'test-project' };
const storage = createBrowserStorage({ name, checkpointEvery: 3 });
const image = revision => ({ formatVersion: 1, rootRevision: revision, localRevision: Math.min(revision, 1), payload: { document: { title: `v${revision}` }, ledger: { frozenBody: '{"request_id":"stable"}' }, history: ['history-id'] } });
const assert = (condition, message) => { if (!condition) throw new Error(message); };
const rejects = async (callback, message) => { try { await callback(); } catch (error) { assert(error.message === message, `expected ${message}, received ${error.message}`); return; } throw new Error(`expected ${message}`); };
let leader, other;
try {
leader = await storage.claim(scope);
assert(await storage.recover(leader) === null, 'empty scope must be distinguishable from a corrupt checkpoint');
await rejects(() => storage.claim(scope), 'storage_scope_in_use');
await storage.commit(leader, { image: image(0), files: [{ id: 'asset', bytes: new Blob(['original']) }] });
assert((await storage.recover(leader)).rootRevision === 0, 'initial checkpoint should recover');
assert(await (await storage.readFile(leader, 'asset')).text() === 'original', 'committed bytes should survive');
results.push('initial checkpoint and file bytes commit together; duplicate leader rejected');
await storage.commit(leader, { image: image(1) });
await storage.commit(leader, { image: image(1) });
await rejects(() => storage.commit(leader, { image: { ...image(1), payload: null } }), 'storage_revision_conflict');
await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_revision_gap');
await rejects(() => storage.commit(leader, { image: { ...image(2), formatVersion: 2 } }), 'unsupported_recovery_version');
results.push('same-cut retry is idempotent; conflicting, skipped and future-format cuts reject');
// The first put succeeds before a later duplicate resource aborts the whole transaction.
await rejects(() => storage.commit(leader, { image: image(2), files: [{ id: 'orphan', bytes: new Blob(['must-rollback']) }, { id: 'asset', bytes: new Blob(['replacement']) }] }), 'resource_identity_conflict');
assert(await storage.readFile(leader, 'orphan') === undefined, 'aborted resource bytes must roll back');
assert((await storage.recover(leader)).rootRevision === 1, 'aborted document/history/ledger cut must not advance');
assert(await (await storage.readFile(leader, 'asset')).text() === 'original', 'existing resource bytes must remain immutable');
results.push('late transaction failure rolls back new bytes and the entire state cut');
await storage.commit(leader, { image: image(2) });
const originalFence = leader.fence;
await leader.release();
other = createBrowserStorage({ name, checkpointEvery: 3 });
const next = await other.claim(scope);
await rejects(() => storage.commit(leader, { image: image(3) }), 'storage_leader_lost');
assert(next.fence > originalFence, 'reacquire must advance fencing generation');
assert((await other.recover(next)).rootRevision === 2, 'new adapter must replay the durable journal');
await other.commit(next, { image: image(3) });
assert((await other.recover(next)).payload.document.title === 'v3', 'compacted checkpoint must cover the same document/history/ledger cut');
leader = next;
results.push('fresh adapter replays journal; released handle is fenced; checkpoint compaction preserves latest state');
// Deliberately remove a journal item to exercise the corruption boundary, not an empty fallback.
await other.commit(next, { image: image(4) });
const database = await new Promise((resolve, reject) => { const request = indexedDB.open(name); request.onsuccess = () => resolve(request.result); request.onerror = () => reject(request.error); });
const storageKey = [JSON.stringify([scope.accountId, scope.workspaceId, scope.projectId]), 4];
const tamper = database.transaction('journal', 'readwrite'), read = tamper.objectStore('journal').get(storageKey);
let intact;
read.onsuccess = () => { intact = read.result; const corrupted = structuredClone(intact); corrupted.image.payload.document.title = 'altered'; tamper.objectStore('journal').put(corrupted, storageKey); };
await new Promise((resolve, reject) => { tamper.oncomplete = resolve; tamper.onabort = () => reject(tamper.error); });
await rejects(() => other.recover(next), 'recovery_checksum_mismatch');
results.push('well-formed data tampering is detected by the stored checksum');
const transaction = database.transaction('journal', 'readwrite');
transaction.objectStore('journal').delete(storageKey);
await new Promise((resolve, reject) => { transaction.oncomplete = resolve; transaction.onabort = () => reject(transaction.error); });
database.close();
await rejects(() => other.recover(next), 'recovery_journal_gap');
results.push('journal gap is reported as corruption and never converted to an empty document');
await engineRecoveryCase(name, assert);
results.push('project engine reopens real IndexedDB with one history unit and the original request; ACK resolves without duplicate edit');
return results;
} finally { await leader?.release(); other?.close(); storage.close(); indexedDB.deleteDatabase(name); }
}