Files
Endoleap/tests/headless/model-loader.test.mjs

86 lines
10 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createRegistry, builtinComponents } from '../../src/lib/canvas-core/index.ts';
import { createRuntimeHost } from '../../src/lib/canvas-runtime/host.ts';
import { loadQueryKey } from '../../src/lib/canvas-runtime/loader.ts';
import { createFoundryModelLoader } from '../../src/lib/canvas-service/model-loader.ts';
import { loadCanvasEntry } from '../../src/lib/canvas-entry.ts';
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
const account = id(1), workspace = id(2), root = id(3), registry = createRegistry(builtinComponents());
const scope = { accountId: account, workspaceId: workspace, projectId: null, canvasId: root, authEpoch: 1, navigationGeneration: 7, format: 'canonical-model-v1', source: 'foundry', viewKey: 'canonical-project', queryFingerprint: 'full-project' };
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
const tick = () => new Promise(resolve => setImmediate(resolve));
const reply = (version = 1, title = account) => ({ status: 200, body: { model: { documentFormatVersion: 1, projectId: root, complete: true, canvases: [{ id: root, parent: null }], elements: [{ id: 'node', incarnation: 1, ownerCanvasId: root, componentId: 'text', dataVersion: 1, structure: 'node', data: { title, text: '' }, geometry: { x: 0, y: 0, width: 100, height: 80, rotation: 0 } }], tombstones: {}, versions: { [root]: version }, removedCanvasIds: [] } } });
test('Foundry adapter emits one validated complete chunk with bound query, project, coverage and version metadata', async () => {
const abort = new AbortController(), calls = [];
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async input => { calls.push(input); return reply(3); } });
const chunks = []; for await (const chunk of port(scope, abort.signal)) chunks.push(chunk);
assert.equal(chunks.length, 1); assert.equal(chunks[0].complete, true); assert.equal(chunks[0].queryKey, loadQueryKey(scope)); assert.equal(chunks[0].projectId, root);
assert.deepEqual(chunks[0].coverage, [`canvas:${root}`]); assert.equal(chunks[0].serverVersions[root], 3); assert.ok(chunks[0].byteLength > 0); assert.equal(chunks[0].value.document.elements.get('node').data.title, account);
assert.strictEqual(calls[0].signal, abort.signal); assert.equal(calls[0].method, 'GET');
});
test('partial canonical replies, foreign requested canvases and unbound migration headers cannot masquerade as initial readiness', async () => {
for (const [response, expected] of [[{ ...reply(), body: { model: { ...reply().body.model, complete: false } } }, 'incomplete_initial_model'], [reply(), 'project_mismatch'], [{ status: 409, body: { error: 'canonical_migration_required', document_format_version: 1, root_id: root, source_versions: { [root]: 1 } } }, 'project_mismatch']]) {
const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async () => response }), query = expected === 'incomplete_initial_model' ? scope : { ...scope, canvasId: id(99) };
await assert.rejects(async () => { for await (const _ of port(query, new AbortController().signal)) assert.fail('invalid response yielded a chunk'); }, { code: expected });
}
});
test('Host shares concurrent model reads per identity/workspace but never reuses an idle authoritative snapshot', async () => {
const gate = deferred(), calls = []; let factories = 0, version = 1;
const host = createRuntimeHost({ registry, workspace() { assert.fail('readonly load opened a project/storage runtime'); }, modelLoader(accountId, workspaceId, _epoch, current) {
factories++; assert.equal(accountId, account); return createFoundryModelLoader({ workspaceId, registry, transport: async input => { calls.push(input); if (calls.length === 1) await gate.promise; assert.equal(current(), true); return reply(version); } });
} });
try {
const abort = new AbortController(), a = host.loadModel(account, workspace, root, { signal: abort.signal, navigationGeneration: 1 }), b = host.loadModel(account, workspace, root, { navigationGeneration: 2 });
await tick(); const rejected = assert.rejects(a, { code: 'load_aborted' }); abort.abort(); await rejected; assert.equal(calls[0].signal.aborted, false);
gate.resolve(); assert.equal((await b).versions.get(root), 1); assert.equal(calls.length, 1); assert.equal(factories, 1);
version = 2; assert.equal((await host.loadModel(account, workspace, root)).versions.get(root), 2); assert.equal(calls.length, 2); assert.equal(host.getWorkspaces().size, 0);
} finally { gate.resolve(); await host.close(); }
});
test('Host retirement aborts the old read and a late response cannot pollute the new account reader', async () => {
const gate = deferred(), calls = [];
const host = createRuntimeHost({ registry, workspace() { assert.fail('unexpected project open'); }, modelLoader(accountId, workspaceId) {
return createFoundryModelLoader({ workspaceId, registry, transport: async input => { calls.push({ accountId, input }); if (accountId === account) await gate.promise; return reply(1, accountId); } });
} });
try {
const old = host.loadModel(account, workspace, root); await tick(); const rejected = assert.rejects(old, { code: 'loader_closed' }); await host.retireIdentity(); await rejected;
const next = await host.loadModel(id(8), workspace, root); assert.equal(next.document.elements.get('node').data.title, id(8));
gate.resolve(); await tick(); assert.equal(host.identity().accountId, id(8)); assert.equal(calls[0].input.signal.aborted, true); assert.equal(calls.length, 2);
} finally { gate.resolve(); await host.close(); }
});
test('entry injection uses resolved IDs and navigation context while preserving canonical-first legacy policy', async () => {
const board = { id: root, workspace_id: workspace, slug: 'board', kind: 'board', title: 'Board', version: 1 }, inputs = [], reads = [];
const options = { workspaceRef: 'workspace-slug', boardRef: 'board', registry, navigationGeneration: 9, request: async path => { reads.push(path); return path === 'profile' ? { id: account } : board; },
readModel: async input => { inputs.push(input); const port = createFoundryModelLoader({ workspaceId: workspace, registry, transport: async () => reply() }); for await (const chunk of port(scope, new AbortController().signal)) return chunk.value; }, transport() { assert.fail('entry bypassed the installed loader'); } };
const result = await loadCanvasEntry(options); assert.equal(result.mode, 'headless'); assert.deepEqual(inputs[0], { accountId: account, workspaceId: workspace, canvasId: root, signal: undefined, navigationGeneration: 9 }); assert.equal(reads.some(path => path.endsWith('/canvas')), false);
await assert.rejects(() => loadCanvasEntry({ ...options, readModel: async () => { throw Object.assign(Error('forbidden'), { code: 'forbidden' }); } }), { code: 'forbidden' });
});
test('Host previews share one source but cancelled observers never receive a late prefix or open storage',async()=>{
const gate=deferred(),aStates=[],bStates=[];let reads=0;
const first=reply().body.model.elements[0],second={...first,id:'second'},canvases=[{id:root,parent:null}],serverVersions={[root]:1};
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened storage');},modelLoader(){return async function*(scope){
reads++;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'one-read',serverVersions,permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
yield part(0,{status:'receiving',projectId:root,canvases,elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:2});
yield part(1,{status:'receiving',projectId:root,canvases:[],elements:[first],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:1,totalElements:2});
await gate.promise;
yield part(2,{status:'receiving',projectId:root,canvases:[],elements:[second],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:2,totalElements:2});
yield part(3,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,canvases,[first,second]),versions:new Map([[root,1]])},true);
};}});
const abort=new AbortController();const a=host.loadModel(account,workspace,root,{signal:abort.signal,onPreview:value=>aStates.push(value)}),b=host.loadModel(account,workspace,root,{onPreview:value=>bStates.push(value)});
try{while(!aStates.some(value=>value.receivedElements===1))await tick();assert.equal(reads,1);assert.equal(aStates.at(-1).document.elements.size,1);const rejected=assert.rejects(a,{code:'load_aborted'});abort.abort();await rejected;const count=aStates.length;gate.resolve();const complete=await b;assert.equal(complete.document.elements.size,2);assert.equal(aStates.length,count);assert.equal(bStates.at(-1).document.elements.size,2);assert.equal(host.getWorkspaces().size,0);}finally{gate.resolve();await host.close();}
});
test('preview callback failures do not stop canonical loading and identity retirement fences future previews',async()=>{
const gate=deferred(),secondGate=deferred(),errors=[],states=[];let reading=0;
const host=createRuntimeHost({registry,workspace(){assert.fail('preview opened a runtime');},onObserverError:error=>errors.push(error),modelLoader(){return async function*(scope){
const pass=++reading;const part=(sequence,value,complete=false)=>({queryKey:loadQueryKey(scope),projectId:root,snapshotToken:'read',serverVersions:{[root]:1},permissionEpoch:scope.authEpoch,sequence,coverage:[],complete,byteLength:1,value});
yield part(0,{status:'receiving',projectId:root,canvases:[{id:root,parent:null}],elements:[],tombstones:new Map(),versions:new Map([[root,1]]),receivedElements:0,totalElements:0});await(pass===1?gate.promise:secondGate.promise);
yield part(1,{status:'ready',document:(await import('../../src/lib/canvas-core/document.ts')).createDocument(root,[{id:root,parent:null}]),versions:new Map([[root,1]])},true);
};}});
try{
const read=host.loadModel(account,workspace,root,{onPreview(){throw Error('observer');}});await tick();gate.resolve();assert.equal((await read).status,'ready');assert.equal(errors.length,1);
const next=host.loadModel(account,workspace,root,{onPreview:value=>states.push(value)});const rejected=assert.rejects(next,{code:'loader_closed'});while(!states.length)await tick();await host.retireIdentity();secondGate.resolve();await rejected;const count=states.length;await tick();assert.equal(states.length,count);
}finally{gate.resolve();secondGate.resolve();await host.close();}
});