Files
Endoleap/tests/headless/resources.test.mjs
T

157 lines
12 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createDocument, applyChanges, builtinComponents, createRegistry } from '../../src/lib/canvas-core/index.ts';
import { openProjectEngine } from '../../src/lib/canvas-runtime/engine.ts';
import { decodeRecovery } from '../../src/lib/canvas-runtime/recovery.ts';
import { resourcePins } from '../../src/lib/canvas-runtime/resources.ts';
const registry = createRegistry(builtinComponents());
const scope = { accountId: 'account', workspaceId: 'workspace', projectId: 'project' };
const geometry = { x: 0, y: 0, width: 100, height: 100, rotation: 0 };
const node = (id, data = {}) => ({ id, incarnation: 1, structure: 'node', componentId: 'image', dataVersion: 1, ownerCanvasId: 'canvas', geometry, data: { title: id, text: '', ...data } });
const original = createDocument('project', [{ id: 'canvas', parent: null }], [node('a')]);
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
async function until(predicate) { for (let i = 0; i < 100; i++) { if (predicate()) return; await new Promise(resolve => setTimeout(resolve, 1)); } assert.fail('condition did not become true'); }
function fixture() {
let image = null, owner, counter = 0, server = original, version = 1;
const files = new Map(), batches = [], uploads = [], writes = [], receipts = new Map(), assetReceipts = new Map();
const storage = {
async claim(scope) { assert.equal(owner, undefined); const handle = { scope, fence: 1, active: true, async release() { owner = undefined; handle.active = false; } }; owner = handle; return handle; },
async recover() { return structuredClone(image); },
async commit(_leader, batch) { for (const file of batch.files ?? []) { assert.equal(files.has(file.id), false); files.set(file.id, file.bytes); } image = structuredClone(batch.image); batches.push({ ...batch, image }); return { durableRootRevision: image.rootRevision }; },
async readFile(_leader, id) { return files.get(id); }, close() {},
};
const assets = {
sourceURL: id => `https://assets.example/${id}`, displayURL: id => `/assets/${id}`,
async upload(request, bytes) {
const payload = JSON.parse(request.body); assert.equal(await files.get(payload.resource_id).text(), await bytes.text());
assert.ok(image.payload.runtime.ledger.requests.some(([id, row]) => id === request.id && row.phase === 'dispatch-marked'));
uploads.push(request);
const asset = { id: payload.resource_id, url: assets.sourceURL(payload.resource_id), width: 20, height: 10, bytes: bytes.size, mime_type: bytes.type };
assetReceipts.set(request.id, asset);
return { outcome: 'applied', canonical: asset };
},
async query(request) { return assetReceipts.has(request.id) ? { outcome: 'applied', canonical: assetReceipts.get(request.id) } : { outcome: 'unresolved' }; },
accept: (_request, status) => status.canonical,
};
const persistence = {
compile(intent, id, snapshot) { return { payload: { intent, id }, canvasIds: intent.canvasIds, expectedVersions: Object.fromEntries(intent.canvasIds.map(id => [id, snapshot.runtime.versions.get(id)])) }; },
async submit(request) { const { intent } = JSON.parse(request.body); writes.push(request); server = applyChanges(server, intent.changes); receipts.set(request.id, { document: server, versions: new Map([['canvas', ++version]]) }); return { outcome: 'applied' }; },
async query(request) { return receipts.has(request.id) ? { outcome: 'applied' } : { outcome: 'unresolved' }; },
canonical(request) { return receipts.get(request.id); },
};
return { storage, assets, persistence, files, batches, uploads, writes, assetReceipts, image: () => image,
open: extra => openProjectEngine({ scope, registry, document: original, versions: new Map([['canvas', 1]]), storage, assets, persistence, createId: () => `id-${++counter}`, ...extra }),
};
}
function addImage(engine, id = 'picture') {
let resource;
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }) }], ([ref], tx) => {
resource = ref;
tx.dispatch({ type: 'element.create', element: node(id, { assetId: ref.id, src: ref.src }) });
});
assert.equal(result.status, 'applied'); return resource;
}
test('pending images publish immediately, bytes share the durable cut, and independent canvas edits overtake upload', async () => {
const f = fixture(), gate = deferred(), upload = f.assets.upload;
f.assets.upload = async (...args) => { await gate.promise; return upload(...args); };
const engine = await f.open(), ref = addImage(engine);
assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id);
assert.equal(engine.getSnapshot().history.undo.length, 1);
assert.equal(engine.getSnapshot().runtime.ledger.operations.size, 2);
const sequences = [...engine.getSnapshot().runtime.ledger.operations.values()].map(record => record.intent.sequence);
assert.equal(new Set(sequences).size, 2);
const handle = await engine.displayResource(ref.id); assert.match(handle.url, /^blob:/); handle.release(); handle.release();
engine.editor.dispatch({ type: 'element.data', target: { id: 'a', incarnation: 1 }, patch: { title: 'independent' } });
await until(() => f.writes.length === 1);
assert.equal(f.uploads.length, 0);
const fileBatch = f.batches.find(batch => batch.files?.length);
assert.equal(fileBatch.image.payload.document.elements.some(element => element.id === 'picture'), true);
assert.equal(fileBatch.image.payload.runtime.resources.length, 1);
gate.resolve(); await engine.settle();
assert.equal(f.uploads.length, 1); assert.equal(f.writes.length, 2);
assert.equal(engine.getSnapshot().history.undo.length, 2);
assert.ok(engine.getSnapshot().runtime.resources.get(ref.id).stored);
assert.equal(engine.getSnapshot().document.elements.get('a').data.title, 'independent');
engine.editor.dispatch({ type: 'element.create', element: node('copy', { assetId: ref.id, src: ref.src }) });
await engine.settle(); assert.equal(f.uploads.length, 1);
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
assert.equal(f.uploads.length, 1); assert.equal(engine.getSnapshot().document.elements.has('copy'), false);
const pins = resourcePins(engine.getSnapshot(), engine.getSnapshot().runtime.ledger, registry, new Map());
assert.equal(pins.complete, true); assert.ok([...pins.pins.get(ref.id)].some(owner => owner.startsWith('history:')));
await engine.close();
});
test('lost upload acknowledgment recovers original byte identity and query receipt without recreating a node', async () => {
const f = fixture(), upload = f.assets.upload;
f.assets.upload = async (...args) => { await upload(...args); return { outcome: 'unresolved' }; };
const engine = await f.open(), ref = addImage(engine); await engine.settle();
const frozen = f.uploads[0]; assert.ok(frozen); assert.equal(f.writes.length, 0);
const decoded = decodeRecovery(f.image(), registry);
assert.equal(decoded.runtime.resources.get(ref.id).id, ref.id); assert.equal(decoded.operations.size, 1); assert.equal(decoded.runtime.ledger.operations.size, 2);
const bad = structuredClone(f.image()); bad.payload.runtime.resources[0][1].operationId = 'not-the-upload';
assert.throws(() => decodeRecovery(bad, registry), /corrupt_recovery_image/);
await engine.close();
const queries = [], query = f.assets.query;
f.assets.query = async request => { queries.push(request); return query(request); };
const recovered = await f.open({ clock: { now: () => Date.now() + 100000, setTimer: (fn, ms) => setTimeout(fn, ms), clearTimer: id => clearTimeout(id) } });
await new Promise(resolve => setTimeout(resolve, 1)); await recovered.settle();
assert.equal(queries.length, 1); assert.equal(queries[0].id, frozen.id); assert.equal(queries[0].body, frozen.body);
assert.equal(f.uploads.length, 1); assert.equal(f.writes.length, 1);
assert.equal(recovered.getSnapshot().document.elements.size, 2); assert.equal(recovered.getSnapshot().history.undo.length, 1);
assert.ok(recovered.getSnapshot().runtime.resources.get(ref.id).stored);
await recovered.close();
});
test('rejected and empty staging do not retain file bytes or create upload work', async () => {
const f = fixture(), engine = await f.open();
const files = [{ name: 'image.png', bytes: new Blob(['image'], { type: 'image/png' }) }];
const result = engine.stageFiles(files, ([ref], tx) => { tx.dispatch({ type: 'element.create', element: node('a', { assetId: ref.id }) }); });
assert.equal(result.status, 'rejected');
assert.equal(engine.stageFiles(files, () => {}).operationIds.length, 0);
await engine.settle();
assert.equal(f.files.size, 0); assert.equal(f.uploads.length, 0); assert.equal(engine.getSnapshot().runtime.resources.size, 0);
await engine.close();
});
test('unknown resource reference schemas prevent claiming complete garbage-collection coverage', async () => {
const f = fixture(), engine = await f.open();
const opaque = { ...node('future'), componentId: 'future-image', data: { privateResource: 'unknown' } };
const doc = createDocument('project', [...original.canvases.values()], [...original.elements.values(), opaque]);
const snapshot = { ...engine.getSnapshot(), document: doc };
assert.equal(resourcePins(snapshot, snapshot.runtime.ledger, registry, new Map()).complete, false);
const pinned = resourcePins(engine.getSnapshot(), engine.getSnapshot().runtime.ledger, registry, new Map([['export-job', ['keep']]]));
assert.deepEqual([...pinned.pins.get('keep')], ['export-job']);
await engine.close();
});
test('invalid applied asset metadata retains the receipt and pending document without releasing the dependency', async () => {
const f = fixture(), upload = f.assets.upload;
f.assets.upload = async (...args) => { const result = await upload(...args); return { ...result, canonical: { ...result.canonical, id: 'different-resource' } }; };
const errors = [], engine = await f.open({ onError: error => errors.push(error) });
const ref = addImage(engine); await engine.settle();
const request = f.uploads[0], stored = engine.getSnapshot().runtime.ledger.requests.get(request.id);
assert.equal(stored.outcome, 'applied'); assert.equal(stored.phase, 'waiting'); assert.equal(f.writes.length, 0);
assert.equal(engine.getSnapshot().runtime.receipts.get(request.id).id, 'different-resource');
assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id);
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored, undefined); assert.equal(errors[0].message, 'asset_receipt_mismatch');
await engine.close();
});
test('a failed image durable cut keeps the visible element and sends neither bytes nor document', async () => {
const f = fixture(), errors = [], engine = await f.open({ onError: error => errors.push(error) });
await engine.persist();
const commit = f.storage.commit;
f.storage.commit = async () => { throw new Error('quota'); };
const ref = addImage(engine); await new Promise(resolve => setTimeout(resolve, 5));
await assert.rejects(() => engine.settle(), /quota/);
assert.equal(f.uploads.length, 0); assert.equal(f.writes.length, 0); assert.equal(f.files.size, 0);
assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId, ref.id);
await assert.rejects(() => engine.close(), /quota/);
f.storage.commit = commit; await engine.close();
assert.equal(await f.files.get(ref.id).text(), 'image bytes');
assert.equal(decodeRecovery(f.image(), registry).history.undo.length, 1);
assert.ok(errors.length);
});