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

107 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Foundry 模型 loader、RuntimeHost 共享读取及画布 entry 注入测试。只读加载不应提前打开 workspace/project 存储。
* 并发请求可以共享进行中的读取,但空闲 canonical 快照不自动成为下次打开的权威基线;身份退役隔离迟到预览。
*/
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';
// 确定性 UUID 工厂,满足 service 的输入校验并区分账户、workspace 与根 canvas。
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
// 当前身份和 registry;scope 中 projectId 尚未知,由 canonical 模型解析得到真实项目根。
const account = id(1), workspace = id(2), root = id(3), registry = createRegistry(builtinComponents());
// 完整模型查询绑定权限 epoch、导航代次、格式和来源,防止不同读取用途误共享。
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' };
// 手动 gate 控制首次响应及尾部预览返回,精确安排取消/身份退役。
const deferred = () => { let resolve; const promise = new Promise(yes => { resolve = yes; }); return { promise, resolve }; };
// 推进一次事件循环,让共享 loader 和观察者执行至可检查的状态。
const tick = () => new Promise(resolve => setImmediate(resolve));
// 生成版本可变的完整 canonical 响应,标题可用于检测是否误用了旧账户结果。
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: [] } } });
// JSON adapter 只发一个已验证完整块,绑定 query/project、canvas 覆盖和版本,GET 沿用原 AbortSignal。
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');
});
// 局部模型、查询 canvas 不在模型及不匹配迁移头均不能冒充初始就绪,必须在 yield 任何块前拒绝。
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 });
}
});
// 同身份/workspace 并发读取共享端口,取消一方不伤另一方;完成释放后下次重新读取新版本,始终不打开 workspace runtime。
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);
// 两个订阅均已结束,空闲 canonical 结果不能充当下一次权威读取;新调用应看到版本 2。
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(); }
});
// 身份退役关闭旧 loader 并 abort 原信号,新账户用新读取;旧响应迟到不能覆盖当前 Host 身份或模型。
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(); }
});
// entry 解析 slug 后传入真实 account/workspace/canvas 与导航上下文,走注入 readModel;canonical 失败不能偷退回旧 canvas 接口。
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' });
});
// 两个预览观察者共享一个渐进源,取消一个后它不再收到后缀;另一方完成,Host 未创建任何存储 workspace。
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){
// 每个原始读取共用 token/版本;part 将状态包装成连续序号块,gate 只阻塞后缀。
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)});
// 等首个元素真正进入预览再取消 A;记录 A 的通知数,放行 B 的后缀后它必须保持不变。
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();}
});
// onPreview 抛错经 onObserverError 隔离,不阻止 canonical ready;身份退役后不能继续通知第二次读取的预览。
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){
// 两次查询使用不同 gate,分别检查回调异常隔离和身份退役;每次源仍有独立接收/ready 阶段。
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();}
});