453 lines
46 KiB
JavaScript
453 lines
46 KiB
JavaScript
import {resourceRetentionPins} from '../../src/lib/canvas-runtime/resource-retention.ts';
|
|
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 = {
|
|
fileDeletion: 'atomic-v1',
|
|
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); } for(const id of batch.deleteFiles??[])files.delete(id); 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 = {
|
|
async read(){return{document:server,versions:new Map([['canvas',version]])};},
|
|
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', defaultSize = false) {
|
|
let resource;
|
|
const result = engine.stageFiles([{ name: 'image.png', bytes: new Blob(['image bytes'], { type: 'image/png' }), defaultSize }], ([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('automatic image sizing publishes with creation history across intervening edits and survives reopening', async () => {
|
|
const f = fixture(), gate = deferred(), entered = deferred(); let calls = 0;
|
|
const images = { decode: async () => { calls++; entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } };
|
|
let engine = await f.open({ images });
|
|
try {
|
|
const ref = addImage(engine, 'picture', true); await entered.promise;
|
|
const observations = [];
|
|
const unsubscribe = engine.editor.subscribe((_commit, snapshot) => { if (snapshot.document.elements.get('picture')?.geometry.height === 50) observations.push(snapshot); });
|
|
const target = { id: 'picture', incarnation: 1 };
|
|
engine.editor.dispatch({ type: 'element.geometry', target, patch: { x: 70 } });
|
|
engine.editor.dispatch({ type: 'element.data', target, patch: { title: 'Renamed before decode' } });
|
|
const undo = [...engine.getSnapshot().history.undo]; gate.resolve(); await engine.settle();
|
|
const root = engine.getSnapshot(); assert.equal(root.document.elements.get('picture').geometry.height, 50);
|
|
assert.equal(root.document.elements.get('picture').geometry.x, 70); assert.deepEqual(root.history.undo, undo);
|
|
assert.equal(root.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
|
assert.ok(observations.length > 0);
|
|
for (const snapshot of observations) {
|
|
assert.equal(snapshot.runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
|
assert.equal(snapshot.history.entries.get(undo[0]).changes[0].after.geometry.height, 50);
|
|
assert.equal([...snapshot.operations.values()].some(intent => intent.historyEffect), true);
|
|
}
|
|
unsubscribe();
|
|
const effect = [...root.operations.values()].find(intent => intent.historyEffect);
|
|
assert.equal(effect.origin, 'asset-resolution'); assert.equal(effect.historyTransition.entries.length, 2);
|
|
assert.equal(f.image().formatVersion, 6); decodeRecovery(f.image(), registry);
|
|
for (const version of [1, 2, 3, 4, 5]) { const bad = structuredClone(f.image()); bad.formatVersion = version; assert.throws(() => decodeRecovery(bad, registry), /unsupported_recovery_version/); }
|
|
await engine.close(); engine = await f.open({ images });
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
|
|
assert.equal(engine.editor.history.undo().status, 'applied');
|
|
assert.equal(engine.editor.history.undo().status, 'applied');
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.x, 0);
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
|
|
assert.equal(engine.editor.history.undo().status, 'applied'); assert.equal(engine.getSnapshot().document.elements.has('picture'), false);
|
|
assert.equal(engine.editor.history.redo().status, 'applied');
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
|
|
await engine.settle(); assert.equal(calls, 1); assert.equal(f.uploads.length, 1);
|
|
} finally { gate.resolve(); await engine.close(); }
|
|
});
|
|
|
|
test('a committed user resize permanently revokes default sizing even if undone before decoding', async () => {
|
|
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
|
try {
|
|
const ref = addImage(engine, 'picture', true); await entered.promise;
|
|
engine.editor.dispatch({ type: 'element.geometry', target: { id: 'picture', incarnation: 1 }, patch: { height: 75 } });
|
|
assert.equal(engine.editor.history.undo().status, 'applied'); gate.resolve(); await engine.settle();
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
|
|
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'revoked');
|
|
assert.equal([...engine.getSnapshot().operations.values()].some(intent => intent.historyEffect), false);
|
|
decodeRecovery(f.image(), registry);
|
|
} finally { gate.resolve(); await engine.close(); }
|
|
});
|
|
|
|
test('a pending sizing grant and source bytes survive closing before decode completion', async () => {
|
|
const f = fixture(), gate = deferred(), entered = deferred(); let engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
|
const ref = addImage(engine, 'picture', true); await entered.promise;
|
|
const closing = engine.close(); gate.resolve(); await closing;
|
|
assert.equal(f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing.grants[0].status, 'pending');
|
|
engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) } });
|
|
try { await engine.settle(); assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50); assert.equal(engine.getSnapshot().history.undo.length, 1); decodeRecovery(f.image(), registry); }
|
|
finally { await engine.close(); }
|
|
});
|
|
|
|
test('default sizing waits for a geometry gesture to finish and retains its committed movement', async () => {
|
|
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
|
try {
|
|
addImage(engine, 'picture', true); await entered.promise;
|
|
engine.sessions.attach('view'); assert.equal(engine.sessions.begin('view', [{ id: 'picture', incarnation: 1 }]).enabled, true);
|
|
engine.sessions.update('view', { id: 'picture', incarnation: 1 }, { x: 180 }); gate.resolve(); await engine.settle();
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
|
|
assert.equal(engine.sessions.commit('view').status, 'applied'); await engine.settle();
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.x, 180);
|
|
assert.equal(engine.getSnapshot().history.undo.length, 2);
|
|
assert.equal(engine.editor.history.undo().status, 'applied'); await engine.settle();
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
|
|
} finally { gate.resolve(); await engine.close(); }
|
|
});
|
|
|
|
test('replacing an image resource revokes its pending grant and a late decoder cannot change geometry', async () => {
|
|
const f = fixture(), gate = deferred(), entered = deferred(), engine = await f.open({ images: { decode: async () => { entered.resolve(); await gate.promise; return { width: 4, height: 2 }; } } });
|
|
try {
|
|
const ref = addImage(engine, 'picture', true); await entered.promise;
|
|
engine.editor.dispatch({ type: 'element.data', target: { id: 'picture', incarnation: 1 }, patch: { assetId: 'replacement' } });
|
|
gate.resolve(); await engine.settle();
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
|
|
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'revoked');
|
|
} finally { gate.resolve(); await engine.close(); }
|
|
});
|
|
|
|
test('a denied automatic resize records a recoverable block and explicit retry consumes the original grant', async () => {
|
|
const f = fixture(); let denied = true;
|
|
const engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) }, policy(command) { return command.type === 'element.geometry' && denied ? { enabled: false, code: 'forbidden', targets: ['picture'] } : { enabled: true }; } });
|
|
try {
|
|
const ref = addImage(engine, 'picture', true); await engine.settle();
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
|
|
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'blocked');
|
|
denied = false; await engine.retry(); await engine.settle();
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 50);
|
|
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).sizing.grants[0].status, 'applied');
|
|
assert.equal(engine.getSnapshot().history.undo.length, 1); assert.equal(f.uploads.length, 1);
|
|
} finally { await engine.close(); }
|
|
});
|
|
|
|
test('future default-sizing metadata stays opaque, blocks GC and permits unrelated editing', async () => {
|
|
const f = fixture(); let engine = await f.open(); const ref = addImage(engine); await engine.settle(); await engine.close();
|
|
const future = { version: 2, opaque: { reference: 'keep' } }; f.image().formatVersion = 6; f.image().payload.runtime.resources.find(([id]) => id === ref.id)[1].sizing = future;
|
|
engine = await f.open({ images: { decode: async () => ({ width: 4, height: 2 }) } });
|
|
try {
|
|
await engine.settle(); assert.equal(engine.getResourcePins().complete, false);
|
|
assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).sizing, future);
|
|
assert.equal(engine.editor.dispatch({ type: 'element.data', target: { id: 'picture', incarnation: 1 }, patch: { title: 'Edited' } }).status, 'applied');
|
|
await engine.settle(); decodeRecovery(f.image(), registry);
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height, 100);
|
|
} finally { await engine.close(); }
|
|
});
|
|
|
|
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('replacing an image keeps the old server reference during upload and undo/reopen/redo never uploads again',async()=>{
|
|
const f=fixture();let engine=await f.open();const original=addImage(engine);await engine.settle();const upload=f.assets.upload,gate=deferred(),entered=deferred();let replacement;
|
|
f.assets.upload=async(...args)=>{entered.resolve();await gate.promise;return upload(...args);};
|
|
try{
|
|
const result=engine.stageFiles([{name:'replacement.png',bytes:new Blob(['different image bytes'],{type:'image/png'})}],([ref],tx)=>{replacement=ref;tx.dispatch({type:'element.data',target:{id:'picture',incarnation:1},patch:{assetId:ref.id,src:ref.src}});});assert.equal(result.status,'applied');
|
|
assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,replacement.id);assert.equal(engine.getSnapshot().history.undo.length,2);await entered.promise;
|
|
assert.equal(f.writes.length,1);assert.equal(f.persistence.canonical(f.writes[0]).document.elements.get('picture').data.assetId,original.id);
|
|
assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,original.id);gate.resolve();await engine.settle();
|
|
const last=()=>f.persistence.canonical(f.writes.at(-1)).document.elements.get('picture');assert.equal(last().data.assetId,original.id);assert.equal(last().incarnation,1);assert.equal(f.uploads.length,2);assert.equal(engine.getSnapshot().history.undo.length,1);
|
|
assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(last().data.assetId,replacement.id);assert.equal(f.uploads.length,2);assert.equal(engine.getSnapshot().history.undo.length,2);assert.ok(engine.getResourcePins().pins.has(original.id));
|
|
await engine.close();engine=await f.open();assert.equal(engine.editor.history.undo().status,'applied');await engine.settle();assert.equal(last().data.assetId,original.id);assert.equal(engine.editor.history.redo().status,'applied');await engine.settle();assert.equal(last().data.assetId,replacement.id);assert.equal(f.uploads.length,2);assert.equal(last().incarnation,1);
|
|
}finally{gate.resolve();await engine.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);
|
|
});
|
|
|
|
test('known successful upload coordination recovers stored identity and releases the dependent image without uploading again',async()=>{
|
|
const f=fixture(),accept=f.assets.accept;let fail=true;f.assets.accept=(...args)=>{if(fail)throw Error('codec_temporarily_unavailable');return accept(...args);};
|
|
const engine=await f.open(),resource=addImage(engine);await until(()=>f.uploads.length===1);await engine.settle();const request=f.uploads[0],history=engine.getSnapshot().history;
|
|
assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).phase,'waiting');assert.equal(f.writes.length,0);fail=false;
|
|
const session=await engine.prepareRecovery();assert.deepEqual(session.acknowledgedRequestIds,[request.id]);await session.apply();await engine.settle();
|
|
assert.equal(f.uploads.length,1);assert.equal(f.writes.length,1);assert.equal(engine.getSnapshot().history,history);assert.equal(engine.getSnapshot().runtime.resources.get(resource.id).stored.id,resource.id);assert.equal(engine.getSnapshot().runtime.ledger.requests.get(request.id).body,request.body);
|
|
assert.equal(await f.files.get(resource.id).text(),'image bytes');assert.equal(decodeRecovery(f.image(),registry).runtime.resources.get(resource.id).stored.id,resource.id);await engine.close();
|
|
});
|
|
|
|
|
|
test('digest worker failure remains distinct from missing bytes and explicit retry resumes the original resource',async()=>{
|
|
const {importSourceDigest}=await import('../../src/lib/canvas-runtime/import-integrity.ts'),f=fixture(),errors=[];let calls=0;const engine=await f.open({digests:{async digest(bytes,signal){if(++calls===1)throw Object.assign(Error('worker failed'),{code:'digest_worker_failed'});return importSourceDigest(bytes,signal);}},onError:error=>errors.push(error)});
|
|
try{const ref=addImage(engine);await engine.settle();const resource=engine.getSnapshot().runtime.resources.get(ref.id);assert.deepEqual(engine.getSnapshot().runtime.ledger.operations.get(resource.operationId).blockers,['digest_worker_failed']);assert.equal(f.files.size,1);assert.equal(f.uploads.length,0);await new Promise(resolve=>setTimeout(resolve,10));assert.equal(calls,1,'failed worker retried in a busy loop');await engine.retry();await engine.settle();assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);assert.equal(errors.length,1);}finally{await engine.close();}
|
|
});
|
|
|
|
const resourceClock=()=>{let now=1000;return{advance:ms=>{now+=ms;},now:()=>now,setTimer:(fn,ms)=>setTimeout(fn,ms),clearTimer:id=>clearTimeout(id)};};
|
|
async function unreferenceImage(engine){assert.equal(engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}}).status,'applied');assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'evict old image history'}}).status,'applied');await engine.settle();}
|
|
|
|
test('local collection persists a soft marker, waits retention and atomically deletes bytes with a v3 recovery tombstone',async()=>{
|
|
const f=fixture(),clock=resourceClock();let engine=await f.open({historyLimit:1,clock});const ref=addImage(engine);try{
|
|
await engine.settle();await unreferenceImage(engine);assert.equal(engine.getResourcePins().pins.has(ref.id),false);
|
|
const content=engine.getSnapshot().document,history=engine.getSnapshot().history;
|
|
const soft=await engine.collectResources({retentionMs:100});assert.deepEqual(soft.markedIds,[ref.id]);assert.deepEqual(soft.purgedIds,[]);assert.ok(f.files.has(ref.id));assert.equal(f.image().formatVersion,3);decodeRecovery(f.image(),registry);
|
|
clock.advance(99);assert.deepEqual((await engine.collectResources({retentionMs:100})).purgedIds,[]);clock.advance(1);
|
|
const collected=await engine.collectResources({retentionMs:100});assert.deepEqual(collected.purgedIds,[ref.id]);assert.equal(f.files.has(ref.id),false);assert.equal(engine.getSnapshot().document,content);assert.equal(engine.getSnapshot().history,history);
|
|
const deletion=f.batches.find(batch=>batch.deleteFiles?.includes(ref.id));assert.equal(deletion.image.payload.runtime.resources.find(([id])=>id===ref.id)[1].retirement.purgedAt,1100);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).stored.id,ref.id);
|
|
const cut=decodeRecovery(f.image(),registry);assert.equal(cut.runtime.resources.get(ref.id).retirement.purgedAt,1100);
|
|
for(const version of [1,2]){const bad=structuredClone(f.image());bad.formatVersion=version;assert.throws(()=>decodeRecovery(bad,registry),/unsupported_recovery_version/);}
|
|
await engine.close();engine=await f.open({historyLimit:1,clock});const handle=await engine.displayResource(ref.id);assert.equal(handle.url,f.assets.displayURL(ref.id));handle.release();assert.equal(f.uploads.length,1);assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);
|
|
}finally{await engine.close();}
|
|
});
|
|
|
|
test('document/history/ACK/explicit pins and unknown coverage prevent collection, and repinning resets the soft retention clock',async()=>{
|
|
const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);try{
|
|
await engine.settle();assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);
|
|
engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});await engine.settle();assert.ok([...engine.getResourcePins().pins.get(ref.id)].some(owner=>owner.startsWith('history:')));assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);
|
|
engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'clear history'}});await engine.settle();await engine.collectResources({retentionMs:100});clock.advance(200);
|
|
assert.equal(engine.editor.dispatch({type:'element.create',element:node('again',{assetId:ref.id})}).status,'applied');assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement,undefined);await engine.settle();
|
|
engine.editor.dispatch({type:'element.remove',target:{id:'again',incarnation:1}});engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'clear again'}});await engine.settle();const next=await engine.collectResources({retentionMs:100});assert.deepEqual(next.markedIds,[ref.id]);assert.deepEqual(next.purgedIds,[]);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement.unreferencedAt,1200);
|
|
engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map([['export-job',[ref.id]]])}));assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement,undefined);assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);
|
|
engine.editor.updateRuntime(previous=>({...previous,resourcePins:new Map()}));const root=engine.getSnapshot(),unknown=createDocument('project',[...root.document.canvases.values()],[...root.document.elements.values(),{...node('unknown'),componentId:'future'}]);assert.equal(engine.editor.reconcile(unknown,root.runtime,root.rootRevision).status,'applied');const incomplete=await engine.collectResources({retentionMs:0});assert.equal(incomplete.complete,false);assert.deepEqual(incomplete.purgedIds,[]);assert.ok(f.files.has(ref.id));
|
|
}finally{await engine.close();}
|
|
});
|
|
|
|
test('a display pins before its asynchronous read and resets retirement; releasing it starts a fresh retention window',async()=>{
|
|
const f=fixture(),clock=resourceClock(),engine=await f.open({historyLimit:1,clock}),ref=addImage(engine);const read=f.storage.readFile,gate=deferred(),entered=deferred();let blocked=false;try{
|
|
await engine.settle();await unreferenceImage(engine);await engine.collectResources({retentionMs:100});clock.advance(200);
|
|
f.storage.readFile=async(...args)=>{if(blocked){entered.resolve();await gate.promise;}return read(...args);};blocked=true;const display=engine.displayResource(ref.id);await entered.promise;
|
|
assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement,undefined);assert.ok(engine.getResourcePins().pins.get(ref.id).has('display'));assert.deepEqual((await engine.collectResources({retentionMs:0})).purgedIds,[]);gate.resolve();const handle=await display;assert.match(handle.url,/^blob:/);handle.release();handle.release();assert.equal(engine.getResourcePins().pins.has(ref.id),false);assert.deepEqual((await engine.collectResources({retentionMs:100})).purgedIds,[]);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).retirement.unreferencedAt,1200);
|
|
}finally{gate.resolve();await engine.close();}
|
|
});
|
|
|
|
test('shared display reads and URLs retain byte protection until the last independent reader releases', async () => {
|
|
const f = fixture(), engine = await f.open({ historyLimit: 1 }), ref = addImage(engine), gate = deferred(), entered = deferred();
|
|
const read = f.storage.readFile, createURL = URL.createObjectURL, revokeURL = URL.revokeObjectURL;
|
|
let reads = 0, created = 0, revoked = 0;
|
|
try {
|
|
await engine.settle(); await unreferenceImage(engine);
|
|
f.storage.readFile = async (...args) => { reads++; entered.resolve(); await gate.promise; return read(...args); };
|
|
URL.createObjectURL = bytes => { created++; return createURL(bytes); }; URL.revokeObjectURL = url => { revoked++; return revokeURL(url); };
|
|
const cancel = new AbortController(), first = engine.displayResource(ref.id, { signal: cancel.signal }), second = engine.displayResource(ref.id);
|
|
await entered.promise; assert.equal(reads, 1);
|
|
const cancelled = assert.rejects(first, { code: 'display_aborted' }); cancel.abort(); await cancelled;
|
|
assert.ok(engine.getResourcePins().pins.get(ref.id).has('display')); assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, []);
|
|
gate.resolve(); const b = await second, c = await engine.displayResource(ref.id);
|
|
assert.equal(b.url, c.url); assert.equal(created, 1); assert.equal(reads, 1);
|
|
b.release(); b.release(); assert.equal(revoked, 0); assert.ok(engine.getResourcePins().pins.get(ref.id).has('display'));
|
|
c.release(); assert.equal(revoked, 1); assert.equal(engine.getResourcePins().pins.has(ref.id), false);
|
|
assert.deepEqual((await engine.collectResources({ retentionMs: 0 })).purgedIds, [ref.id]);
|
|
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; URL.revokeObjectURL = revokeURL; }
|
|
});
|
|
|
|
test('project close releases a pending display immediately and never creates a URL from a late read', async () => {
|
|
const f = fixture(), engine = await f.open(), ref = addImage(engine), gate = deferred(), entered = deferred(), read = f.storage.readFile, createURL = URL.createObjectURL;
|
|
let created = 0;
|
|
try {
|
|
await engine.settle(); f.storage.readFile = async (...args) => { entered.resolve(); await gate.promise; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL(bytes); };
|
|
const pending = engine.displayResource(ref.id); await entered.promise;
|
|
const rejected = assert.rejects(pending, { code: 'engine_closing' }); await engine.close(); await rejected;
|
|
assert.equal(created, 0); gate.resolve(); await new Promise(resolve => setImmediate(resolve)); assert.equal(created, 0);
|
|
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
|
});
|
|
|
|
test('display failures retain their cause and a fresh reader can retry without uploading again', async () => {
|
|
const f = fixture(), engine = await f.open(), ref = addImage(engine), read = f.storage.readFile;
|
|
try {
|
|
await engine.settle(); f.storage.readFile = async () => { throw Object.assign(Error('disk_failed'), { code: 'disk_failed' }); };
|
|
await assert.rejects(engine.displayResource(ref.id), { code: 'disk_failed' });
|
|
assert.equal([...engine.getResourcePins().pins.get(ref.id)].includes('display'), false);
|
|
f.storage.readFile = read; const handle = await engine.displayResource(ref.id); assert.match(handle.url, /^blob:/); handle.release(); assert.equal(f.uploads.length, 1);
|
|
} finally { await engine.close(); }
|
|
});
|
|
|
|
test('identity retirement fences new readers and discards a pending local read before creating its URL', async () => {
|
|
const f = fixture(); let current = true;
|
|
const engine = await f.open({ isCurrentIdentity: () => current }), ref = addImage(engine), read = f.storage.readFile, gate = deferred(), entered = deferred();
|
|
const createURL = URL.createObjectURL; let created = 0;
|
|
try {
|
|
await engine.settle(); f.storage.readFile = async (...args) => { entered.resolve(); await gate.promise; return read(...args); }; URL.createObjectURL = bytes => { created++; return createURL(bytes); };
|
|
const pending = engine.displayResource(ref.id); await entered.promise; current = false;
|
|
await assert.rejects(engine.displayResource(ref.id), { code: 'identity_changed' }); const rejected = assert.rejects(pending, { code: 'identity_changed' }); gate.resolve(); await rejected;
|
|
assert.equal(created, 0); assert.equal([...engine.getResourcePins().pins.get(ref.id)].includes('display'), false);
|
|
} finally { gate.resolve(); await engine.close(); URL.createObjectURL = createURL; }
|
|
});
|
|
|
|
test('only recollection of the reserved resource is fenced during deletion; unrelated editing stays available',async()=>{
|
|
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),gate=deferred(),entered=deferred(),commit=f.storage.commit;try{
|
|
await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(args[1].deleteFiles?.length){entered.resolve();await gate.promise;}return commit(...args);};
|
|
const collecting=engine.collectResources({retentionMs:0});await entered.promise;
|
|
assert.equal(engine.editor.dispatch({type:'element.create',element:node('late',{assetId:ref.id})}).code,'resource_collection_in_progress');await assert.rejects(engine.displayResource(ref.id),/resource_collection_in_progress/);
|
|
assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'can still edit'}}).status,'applied');assert.ok(f.files.has(ref.id));gate.resolve();await collecting;assert.equal(f.files.has(ref.id),false);assert.equal(engine.getSnapshot().document.elements.get('a').data.title,'can still edit');
|
|
}finally{gate.resolve();await engine.close();}
|
|
});
|
|
|
|
test('failed deletion keeps bytes, pending recovery cut and reservation until an explicit persistence retry succeeds',async()=>{
|
|
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine),commit=f.storage.commit;let fail=false;try{
|
|
await engine.settle();await unreferenceImage(engine);f.storage.commit=async(...args)=>{if(fail&&args[1].deleteFiles?.length)throw Error('disk unavailable');return commit(...args);};fail=true;
|
|
await assert.rejects(engine.collectResources({retentionMs:0}),/disk unavailable/);assert.ok(f.files.has(ref.id));assert.equal(f.image().payload.runtime.resources.find(([id])=>id===ref.id)[1].retirement.purgedAt,undefined);await assert.rejects(engine.displayResource(ref.id),/resource_collection_in_progress/);
|
|
fail=false;await engine.persist();assert.equal(f.files.has(ref.id),false);assert.ok(engine.getSnapshot().runtime.resources.get(ref.id).retirement.purgedAt!==undefined);const display=await engine.displayResource(ref.id);assert.equal(display.url,f.assets.displayURL(ref.id));display.release();
|
|
}finally{fail=false;await engine.close();}
|
|
});
|
|
|
|
test('a synchronous publication listener cannot persist a purge marker without its file deletion',async()=>{
|
|
const f=fixture(),engine=await f.open({historyLimit:1}),ref=addImage(engine);let unsubscribe;try{
|
|
await engine.settle();await unreferenceImage(engine);unsubscribe=engine.editor.subscribe(()=>{void engine.persist().catch(()=>{});});await engine.collectResources({retentionMs:0});assert.equal(f.files.has(ref.id),false);
|
|
const purged=f.batches.filter(batch=>batch.image.payload.runtime.resources.some(([id,resource])=>id===ref.id&&resource.retirement?.purgedAt!==undefined));assert.ok(purged.length);assert.ok(purged[0].deleteFiles.includes(ref.id),'first durable purge cut omitted physical deletion');
|
|
}finally{unsubscribe?.();await engine.close();}
|
|
});
|
|
|
|
|
|
test('retention covers ACK-only content, opaque job envelopes and historical field schemas independently of visibility',async()=>{
|
|
const f=fixture(),engine=await f.open();try{
|
|
const root=engine.getSnapshot(),empty=createDocument('project',[...root.document.canvases.values()],[]),resource={id:'kept',operationId:'upload',name:'x',mimeType:'image/png',byteLength:1},base={...root,document:empty,runtime:{...root.runtime,resources:new Map([['kept',resource]]),acknowledged:createDocument('project',[...empty.canvases.values()],[node('remote',{assetId:'kept'})])}};
|
|
assert.ok([...resourceRetentionPins(base,registry).pins.get('kept')].some(owner=>owner.startsWith('acknowledged:')));
|
|
const plain={...base,runtime:{...base.runtime,acknowledged:empty}};
|
|
for(const status of ['preparing','running','paused']){const snapshot={...plain,runtime:{...plain.runtime,imports:new Map([['job',{id:'job',status}]])}};assert.ok(resourceRetentionPins(snapshot,registry).pins.get('kept').has('import:job'));}
|
|
for(const status of ['completed','partial','cancelled'])assert.equal(resourceRetentionPins({...plain,runtime:{...plain.runtime,imports:new Map([['job',{id:'job',status}]])}},registry).pins.has('kept'),false);
|
|
assert.ok(resourceRetentionPins({...plain,runtime:{...plain.runtime,runs:new Map([['run',{id:'run',status:'succeeded',outputs:[{opaque:'kept'}]}]])}},registry).pins.get('kept').has('run:run'));
|
|
assert.ok(resourceRetentionPins(plain,registry,new Map(),new Map([['copy-admission',['kept']]])).pins.get('kept').has('copy-admission'));
|
|
const unknown={...plain,runtime:{...plain.runtime,acknowledged:createDocument('project',[...empty.canvases.values()],[{...node('future'),componentId:'future'}])}};assert.equal(resourceRetentionPins(unknown,registry).complete,false);
|
|
const field={kind:'field',target:{id:'deleted',incarnation:1},field:'data.assetId',before:'kept',after:'next'},history={undo:['h'],redo:[],entries:new Map([['h',{id:'h',changes:[field]}]])};assert.equal(resourceRetentionPins({...plain,history},registry).complete,false);
|
|
const malformed={...plain,document:createDocument('project',[...empty.canvases.values()],[node('bad',{assetId:{opaque:'kept'}})])};assert.equal(resourceRetentionPins(malformed,registry).complete,false);
|
|
}finally{await engine.close();}
|
|
});
|
|
|
|
test('late image metadata never changes edited geometry; explicit fitting creates one normal undo unit',async()=>{
|
|
const f=fixture(),gate=deferred(),entered=deferred();const images={decode:async()=>{entered.resolve();await gate.promise;return{width:400,height:200};}},engine=await f.open({images}),ref=addImage(engine);try{await entered.promise;assert.equal(engine.editor.dispatch({type:'element.geometry',target:{id:'picture',incarnation:1},patch:{width:300,height:70}}).status,'applied');gate.resolve();await engine.settle();assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,{version:1,status:'ready',width:400,height:200,source:'decode'});assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.getSnapshot().history.undo.length,2);assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);assert.equal(engine.getSnapshot().history.undo.length,3);await engine.settle();assert.equal(engine.editor.history.undo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,70);assert.equal(engine.editor.history.redo().status,'applied');assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,150);await engine.settle();decodeRecovery(f.image(),registry);assert.equal(f.uploads.length,1);
|
|
}finally{gate.resolve();await engine.close();}
|
|
});
|
|
test('image decode failure does not block upload and retry keeps resource, element and upload identities',async()=>{
|
|
const f=fixture();let fail=true,calls=0;const engine=await f.open({images:{decode:async()=>{calls++;if(fail)throw Error('bad decoder');return{width:40,height:20};}}}),ref=addImage(engine);try{await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'failed');assert.equal(f.uploads.length,1);fail=false;await engine.retry();await engine.settle();assert.equal(calls,2);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');assert.equal(f.uploads.length,1);assert.equal(engine.getSnapshot().document.elements.get('picture').data.assetId,ref.id);assert.equal(engine.getSnapshot().history.undo.length,1);}finally{await engine.close();}
|
|
});
|
|
test('decoder metadata can cross deletion/restoration, but fitting cannot target a stale incarnation or replaced resource',async()=>{
|
|
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:20,height:10};}}}),ref=addImage(engine);try{await entered.promise;engine.editor.dispatch({type:'element.remove',target:{id:'picture',incarnation:1}});engine.editor.history.undo();const restored=engine.getSnapshot().document.elements.get('picture');assert.equal(restored.incarnation,2);gate.resolve();await engine.settle();assert.equal(engine.fitImage({id:'picture',incarnation:1},ref.id).code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},'another-resource').code,'target_changed');assert.equal(engine.fitImage({id:'picture',incarnation:2},ref.id).status,'applied');await engine.settle();assert.equal(engine.getSnapshot().document.elements.get('picture').incarnation,2);assert.equal(engine.getSnapshot().document.elements.get('picture').geometry.height,50);}finally{gate.resolve();await engine.close();}
|
|
});
|
|
test('recovery holds defer image completion metadata until the coordinated root is released',async()=>{
|
|
const f=fixture(),gate=deferred(),entered=deferred(),engine=await f.open({images:{decode:async()=>{entered.resolve();await gate.promise;return{width:10,height:5};}}}),ref=addImage(engine);try{await entered.promise;const recovery=await engine.prepareRecovery(),revision=engine.getSnapshot().rootRevision;gate.resolve();await new Promise(resolve=>setTimeout(resolve,10));assert.equal(engine.getSnapshot().rootRevision,revision);assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'pending');await recovery.cancel();await engine.settle();assert.equal(engine.getSnapshot().runtime.resources.get(ref.id).image.status,'ready');}finally{gate.resolve();await engine.close();}
|
|
});
|
|
|
|
test('future image metadata remains opaque without blocking unrelated editing or being silently reinterpreted',async()=>{
|
|
const f=fixture();let engine=await f.open();const ref=addImage(engine);try{await engine.settle();await engine.close();const future={version:2,status:'future',payload:{retain:true}};f.image().payload.runtime.resources.find(([id])=>id===ref.id)[1].image=future;let calls=0;engine=await f.open({images:{decode:async()=>{calls++;return{width:1,height:1};}}});await engine.settle();assert.equal(calls,0);assert.deepEqual(engine.getSnapshot().runtime.resources.get(ref.id).image,future);assert.equal(engine.getResourcePins().complete,false);assert.equal(engine.editor.dispatch({type:'element.data',target:{id:'a',incarnation:1},patch:{title:'can edit'}}).status,'applied');await engine.settle();assert.deepEqual(decodeRecovery(f.image(),registry).runtime.resources.get(ref.id).image,future);assert.equal(f.uploads.length,1);}finally{await engine.close();}
|
|
});
|