105 lines
13 KiB
JavaScript
105 lines
13 KiB
JavaScript
/**
|
||
* 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);
|
||
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)});
|
||
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();}
|
||
});
|