docs(tests): explain renderer fixture setup proxy routes and native gestures

This commit is contained in:
2026-11-12 11:36:18 +08:00
parent bd728d2d94
commit 50675ffed3
+96 -9
View File
@@ -1,8 +1,14 @@
/**
* 复制实际 dashboard/GraphPage/headless renderer/Next proxy 到临时应用,连接本地 HTTP fixture,再用隔离 Chrome 操作真实页面。
* 引擎、ReactFlow、IndexedDB 和代理走应用实现,文档/AI/上传/授权响应由下面的内存服务提供,不是远程 Foundry 验证。
* 执行会启动 Next 编译/开发服务,性能生产模式还会 build;本文件不是轻量 node:test,也不会在纯注释检查时执行。
* 计数与测试入口仅写入临时副本,所有嵌入字符串都是测试输入;finally 释放屏障、连接、进程和临时目录。
*/
import {execFileSync} from 'node:child_process';
import {createCdpCalls} from './cdp-client.mjs';
import {measureRenderer} from './renderer-performance.mjs';
import {createRendererMigrationFixture,verifyRendererMigration} from './renderer-migration.mjs';
/** Actual dashboard, GraphPage, headless renderer, browser storage and Next proxy. */
import assert from 'node:assert/strict';
import { createServer } from 'node:http';
import { mkdtemp, cp, copyFile, symlink, readFile, writeFile, rm, realpath } from 'node:fs/promises';
@@ -11,98 +17,137 @@ import { tmpdir } from 'node:os';
import { spawn } from 'node:child_process';
import { setTimeout as pause } from 'node:timers/promises';
import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts';
// 默认 webpack,可显式选择 turbopack;两者在 fixture 放置路径与启动参数上采用不同约束。
const bundler=process.argv.includes('--turbopack')?'turbopack':'webpack';
// 生产模式只允许与性能入口组合,防止把功能测试默认为生产构建。
const productionMode=process.argv.includes('--production');if(productionMode&&!process.argv.includes('--performance'))throw Error('--production requires --performance');
// migration-only、hooks-only 和 performance 分支各走自己的验证入口;迁移分支禁止混合其他模式。
const migrationOnly=process.argv.includes('--migration-only');
const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000);
if(migrationOnly&&(hooksOnly||performanceMode))throw Error('migration-only requires its own functional entry');
// 性能规模必须是 1..10000 的安全整数;复制期间原生输入探针还要求至少 1000 节点。
if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count');
if(process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'&&(!performanceMode||performanceCount<1000))throw Error('concurrent input requires --performance and at least 1000 nodes');
// 诊断 baseline 只接受十六进制 commit 标识;稍后只替换三个指定模块,不表示回放完整历史版本。
const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline');
const source = resolve(import.meta.dirname, '../..');
// Turbopack fences real dependency paths to its workspace root. Keep its isolated
// fixture under the physical dependency workspace, then remove this exact path
// in finally. Webpack fixtures retain their ordinary OS-temp location.
// Turbopack 要求真实依赖路径位于 workspace root,临时应用放在依赖的物理目录旁;webpack 使用系统临时目录。
const fixtureRoot=bundler==='turbopack'?dirname(await realpath(join(source,'node_modules'))):tmpdir();
// 应用和浏览器 profile 分别随机建目录;进程及临时文件归本次检查持有。
const directory = await mkdtemp(join(fixtureRoot, bundler==='turbopack'?'.headless-renderer-':'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-'));
// 稳定 UUID 形式用于测试作用域/任务身份,与业务元素 a/b 等符号 ID 分开。
const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;
// 各 Map 是内存服务事实源;calls 记录上游实际收到的字节/头,供断言请求次数与协议传递。
const account = id(1), workspace = id(2), root = id(3), other = id(4), partition = id(5), calls = [], uploads = [], documents = new Map(), versions = new Map([[root, 1], [other, 1]]), assets = new Map(), assetBytes=new Map(), aiRuns = new Map();
// 建立合法第一代文本节点;功能模式从两个节点和带可编辑路径的连线开始。
const node = (id, ownerCanvasId, x) => ({ id, ownerCanvasId, incarnation: 1, componentId: 'text', dataVersion: 1, structure: 'node', data: { title: 'Card ' + x, text: 'Editable content' }, geometry: { x, y: 150, width: 260, height: 200, rotation: 0 } });
documents.set(root, createDocument(root, [{ id: root, parent: null }], [node('a', root, 80), node('b', root, 450), { id: 'edge', incarnation: 1, componentId: 'connection', dataVersion: 1, structure: 'connection', ownerCanvasId: root, source: { elementId: 'a' }, target: { elementId: 'b' }, data: {}, path: { type: 'bezier-chain', knots: [{ id: 'k', mode: 'corner', position: { x: 400, y: 250 }, in: { x: -20, y: 0 }, out: { x: 20, y: 0 } }] } }]));
documents.set(other, createDocument(other, [{ id: other, parent: null }], []));
// 性能模式替换根模型:展开的分区含 count 个网格节点,根锚点与入口连线用于复制和离屏边验证。
if(performanceMode){
const frame={...node('perf-frame',root,30),componentId:'frame',data:{title:'Performance partition',text:''},geometry:{x:30,y:50,width:1000,height:600,rotation:0}},children=Array.from({length:performanceCount},(_,index)=>({...node(`perf-${index}`,partition,(index%20)*280),geometry:{x:(index%20)*280,y:Math.floor(index/20)*220,width:260,height:200,rotation:0}})),incoming={id:'perf-incoming',incarnation:1,componentId:'connection',dataVersion:1,structure:'connection',ownerCanvasId:root,source:{elementId:'perf-source'},target:{elementId:'perf-frame'},data:{},path:null};
versions.set(partition,1);documents.set(root,createDocument(root,[{id:root,parent:null},{id:partition,parent:{canvasId:root,entryElementId:frame.id}}],[node('perf-source',root,-300),frame,...children,incoming]));
}
// 应用目录和标题接口返回两个根 board,canvas 规范模型仍由 documents 保存。
const boards = [root, other].map((id, i) => ({ id, workspace_id: workspace, kind: 'board', version: 1, title: i ? 'Other board' : 'Headless board' }));
// 固定 PNG 只供显示 URL 返回;上传元数据和原始上传字节另外保存,不能从此图片推导所有资源都做过解码。
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aB1sAAAAASUVORK5CYII=', 'base64');
// 某个后续场景在前三项后挂住流,验证完整子 owner 可先写,而其他 owner 继续加载。
let scopeStreamRoot=null,releaseScopeStream;const scopeStreamGate=new Promise(resolve=>releaseScopeStream=resolve);
// 分别标记错误尾部与被导航放弃的来源;abandonedGate 可令旧响应晚到。
let brokenStreamRoot=null,abandonedRoot=null,releaseAbandoned;const abandonedGate=new Promise(resolve=>releaseAbandoned=resolve);
// 功能模式首读只发第一个元素即等待,给“边加载边编辑”留出可控窗口。
let releaseFirstModel;const firstModelGate=new Promise(resolve=>{releaseFirstModel=resolve;});let gatedFirstModel=!performanceMode&&!migrationOnly;
// 包含特定导入标题的文档写入可挂起,editReceipts 按请求 ID 保存规范写入结果。
let releaseImport;const importGate=new Promise(resolve=>{releaseImport=resolve;});let importWriteSeen=false;const editReceipts=new Map();
// AI 显式应用先改变服务状态再挂起响应,用于检查本地操作与未确认回执交错。
let releaseApply;const applyGate=new Promise(resolve=>{releaseApply=resolve;});
// 上传收到字节后先记录资源,uploadGate 决定何时确认,使本地预览与后端确认可分开观察。
let releaseUpload; const uploadGate = new Promise(resolve => { releaseUpload = resolve; });
// 将指定参与 canvas 的完整成员编码为局部/完整规范模型;此替身 removedCanvasIds 固定为空,不涵盖任意删除协议。
const model = (document, complete = true, participants=[...document.canvases.keys()]) => ({ documentFormatVersion: 1, projectId: document.projectId, complete, canvases: [...document.canvases.values()].filter(canvas=>participants.includes(canvas.id)), elements: [...document.elements.values()].filter(element=>participants.includes(element.ownerCanvasId)), tombstones: Object.fromEntries(document.tombstones), versions: Object.fromEntries(participants.map(id => [id, versions.get(id)])), removedCanvasIds: [] });
// 后续恢复场景可拒绝下一次编辑,资源采用场景可拒绝或延迟元数据授权。
let rejectNextEdit=false,rejectedEdit,denyAssetMetadata=false,metadataWait,releaseMetadata;
// 仅迁移入口装配专用路由/旧数据集;其他模式不运行迁移 fixture。
const migrationFixture=migrationOnly?createRendererMigrationFixture({id,account,workspace,boards,documents,versions,model}):undefined;
// 上游在真实 HTTP 上接请求,但内部仅操作 Map;异常转成 500,使页面协议错误可观察。
const upstream = createServer(async (request, response) => {
try {
const url = new URL(request.url, 'http://fixture'), path = url.pathname, chunks = [];
for await (const chunk of request) chunks.push(chunk);
const bytes = Buffer.concat(chunks); calls.push({ method: request.method, path, bytes, headers: request.headers });
// 统一 JSON 响应助手;登录、个人资料、workspace 和目录路由只满足实际页面装配。
const reply = (status, body) => { response.writeHead(status, { 'Content-Type': 'application/json' }); response.end(JSON.stringify(body)); };
if (path.endsWith('/auth/logout')) return reply(204, null);
if (path.endsWith('/profile')) return reply(200, { id: account, username: 'fixture', display_name: 'Creator', email: null });
if (path.endsWith('/workspaces')) return reply(200, [{ id: workspace, name: 'Fixture workspace', role: 'admin' }]);
if (path.endsWith('/materials')) return reply(200, { items: boards, next_offset: null });
const board = boards.find(board => path.endsWith('/materials/' + board.id)); if (board) return reply(200, board);
// 迁移专用处理器先尝试接管,再进入公共模型路由,避免两个 fixture 同时修改一个请求。
if(await migrationFixture?.handle(request,path,reply,bytes))return;
const read = path.match(/\/materials\/([^/]+)\/canvas\/model$/);
// 只有协商的 Accept 才返回规范 NDJSON;未协商请求走下面的完整 JSON 路径。
if(read&&request.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'){
const value=model(documents.get(read[1])),token='fixture-snapshot-'+calls.length;let sequence=0;
response.writeHead(200,{'Content-Type':'application/vnd.foundry.canvas-model+ndjson;version=1'});
// 同一 snapshotToken 内序号递增,每帧为一行 JSON;header 先提供完整 canvas、owner 清单和成员计数。
const line=record=>response.write(JSON.stringify({streamVersion:1,snapshotToken:token,sequence:sequence++,...record})+'\n');
line({kind:'header',documentFormatVersion:1,projectId:value.projectId,canvases:value.canvases,versions:value.versions,tombstones:value.tombstones,removedCanvasIds:value.removedCanvasIds,elementCount:value.elements.length,canvasElementCounts:Object.fromEntries(value.canvases.map(canvas=>[canvas.id,value.elements.filter(element=>element.ownerCanvasId===canvas.id).length])),elementOwners:Object.fromEntries(value.elements.map(element=>[element.id,element.ownerCanvasId]))});
// 错误尾部先发送有效首元素,再结束为非法 JSON;应保留可见前缀并报告失败。
if(read[1]===brokenStreamRoot){line({kind:'elements',elements:value.elements.slice(0,1)});response.end('{broken tail}\n');return;}
let start=0;
// 作用域流和被放弃流各有自己的首块大小与 gate;响应已销毁时停止追加尾部。
if(read[1]===scopeStreamRoot){line({kind:'elements',elements:value.elements.slice(0,3)});start=3;await scopeStreamGate;if(response.destroyed)return;}
if(read[1]===abandonedRoot){line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await abandonedGate;if(response.destroyed)return;}
// 首个根读取只触发一次 gate;其余元素随后每帧最多 64 项,再发送 complete 并结束 HTTP body。
if(gatedFirstModel&&read[1]===root&&value.elements.length){gatedFirstModel=false;line({kind:'elements',elements:value.elements.slice(0,1)});start=1;await firstModelGate;}
for(let offset=start;offset<value.elements.length;offset+=64)line({kind:'elements',elements:value.elements.slice(offset,offset+64)});
line({kind:'complete',elementCount:value.elements.length});return response.end();
}
if (read) return reply(200, { model: model(documents.get(read[1])) });
const write = path.match(/\/materials\/([^/]+)\/canvas\/model\/operations$/);
// 规范增量写入路径:检查账户、幂等键与参与版本,在内存服务模型上按顺序应用,再保存回执。此处不实现完整生产幂等数据库。
if (write) {
const batch = JSON.parse(bytes.toString()), previous = documents.get(write[1]);
// 特定导入写入到达后仅设置观测标记并等待 gate,阻塞发生在文档提交前。
if(batch.operations.some(operation=>operation.changes.some(change=>change.kind==='element'&&change.after?.data?.title==='Imported NDJSON node'))){importWriteSeen=true;await importGate;}
assert.equal(request.headers['x-expected-account'], account); assert.equal(request.headers['idempotency-key'], batch.request_id);
// 拒绝指定下一编辑时同时模拟远程 b 文字改成 Remote reviewed;返回绑定原请求的 terminal 零写入证据。
if(rejectNextEdit){rejectNextEdit=false;rejectedEdit=batch;const before=previous.elements.get('b');documents.set(write[1],applyChanges(previous,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:before.data.text,after:'Remote reviewed',beforePresent:true,afterPresent:true}]));versions.set(root,versions.get(root)+1);return reply(409,{protocol_version:2,request_id:batch.request_id,status:'not-applied',terminal:true,error:'version_conflict'});}
for(const [id,version] of Object.entries(batch.expected_versions))assert.equal(version,versions.get(id)??0);
let next = previous; for (const operation of batch.operations) next = applyChanges(next, operation.changes);
documents.set(write[1], next);for(const id of Object.keys(batch.expected_versions))versions.set(id,(versions.get(id)??0)+1);
// 一次有序批次只把参与版本各推进一次,回执仍列出全部逻辑 operation IDs,供客户端覆盖检查。
const receipt={ protocol_version: 2, request_id: batch.request_id, status: 'applied', operation_ids: batch.operations.map(operation => operation.id), model: model(next, false,Object.keys(batch.expected_versions)) };editReceipts.set(batch.request_id,receipt);return reply(200,receipt);
}
// GET 查询原文档请求回执;查不到只给 unknown,不提供未执行证明。
const editQuery=path.match(/\/canvas\/model\/operations\/([^/]+)$/);if(editQuery)return editReceipts.has(editQuery[1])?reply(200,editReceipts.get(editQuery[1])):reply(404,{request_id:editQuery[1],status:'unknown'});
if(path.endsWith('/canvas/ai/models'))return reply(200,{models:[{id:'fixture-text',label:'Fixture text',available:true,input_modalities:['text'],output_modalities:['text']}]});
// AI 受理检查账户、幂等键、If-Match 和完整预期版本向量;先保存配置再冻结计划,不在受理时自动写生成文字。
if(path.endsWith('/canvas/ai/runs')&&request.method==='POST'){
const input=JSON.parse(bytes.toString()),owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(request.headers['x-expected-account'],account);assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');assert.deepEqual(input.expected_versions,Object.fromEntries([...documents.get(root).canvases.keys()].map(id=>[id,versions.get(id)])));
const previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.ai',before:element.data.ai??null,beforePresent:element.data.ai!==undefined,after:{enabled:true,modelId:input.model_id,prompt:input.prompt,parameters:input.parameters,propagate:input.propagate},afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
const runId=id(700+aiRuns.size*10),plan={steps:[{step_id:'step-b',board_id:owner,node_id:input.node_id,canonical_root:root,target_incarnation:input.target_incarnation,kind:'text',model_id:input.model_id,status:'queued',dependencies:[],static_inputs:[]}],edges:[],frozen_versions:Object.fromEntries([...next.canvases.keys()].map(id=>[id,versions.get(id)]))};
// 受理以 202 返回任务/计划/目标 incarnation 及当前 owner 局部模型;事件初始只有 queued。
const admitted={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,admission:'accepted',status:'queued',run_id:runId,plan,canvas_version:versions.get(owner),document_format_version:1,target_incarnation:input.target_incarnation,model:model(next,false,[owner])};
aiRuns.set(runId,{status:'queued',plan,admitted,events:[{seq:1,run_id:runId,type:'run.queued',status:'queued'},{seq:2,run_id:runId,type:'step.queued',step_id:'step-b',board_id:owner,node_id:input.node_id,status:'queued'}]});return reply(202,admitted);
}
const aiAction=path.match(/\/canvas\/ai\/runs\/([^/]+)\/(cancel|apply)$/);
// 取消与应用回执均携带请求/操作/任务身份;取消只改控制状态,应用则检查当前 owner 与版本后修改文字。
if(aiAction){
const run=aiRuns.get(aiAction[1]),input=JSON.parse(bytes.toString()),base={protocol_version:2,request_id:input.request_id,operation_id:input.operation_id,action:'run.'+aiAction[2],status:'applied',run_id:aiAction[1]};
assert.equal(request.headers['idempotency-key'],input.request_id);assert.equal(request.headers['x-expected-account'],account);
// 取消追加 completed(cancelled) 事件并立即回执,不经过 applyGate。
if(aiAction[2]==='cancel'){run.status='cancelled';run.events.push({seq:run.events.length+1,run_id:aiAction[1],type:'run.completed',status:'cancelled'});return reply(200,{...base,run_status:'cancelled'});}
const owner=documents.get(root).elements.get(input.node_id).ownerCanvasId;assert.equal(input.target_canvas_id,owner);assert.equal(input.target_incarnation,1);assert.equal(request.headers['if-match'],'"'+versions.get(owner)+'"');
const output=run.events.find(event=>event.type==='step.output'&&event.output.output_id===input.output_id).output,previous=documents.get(root),element=previous.elements.get(input.node_id),next=applyChanges(previous,[{kind:'field',target:{id:element.id,incarnation:element.incarnation},field:'data.text',before:element.data.text,after:output.text,beforePresent:true,afterPresent:true}]);documents.set(root,next);versions.set(owner,versions.get(owner)+1);
// 应用输出先在服务模型中生效并追加 step.applied,再等待回执 gate,模拟成功已发生但客户端仍在途。
const applied={...base,step_id:'step-b',board_id:owner,origin_board_id:run.plan.steps[0].board_id,canonical_root:root,node_id:input.node_id,output_id:input.output_id,document_format_version:1,target_incarnation:1,canvas_version:versions.get(owner),model:model(next,false,[owner])};run.events.push({...applied,seq:run.events.length+1,type:'step.applied'});await applyGate;return reply(200,applied);
}
// 事件页按 after_seq 过滤,next_seq 为最后返回序号;此 fixture 一页返回全部剩余事件且无后续页。
const aiPoll=path.match(/\/canvas\/ai\/runs\/([^/]+)$/);
if(aiPoll){const run=aiRuns.get(aiPoll[1]),after=Number(url.searchParams.get('after_seq')||0),events=run.events.filter(event=>event.seq>after);return reply(200,{run_id:aiPoll[1],status:run.status,plan:run.plan,steps:[],events,next_seq:events.at(-1)?.seq??after,has_more:false,outputs:[]});}
// 用真实 Request.formData 解析代理送来的 multipart,按资源 ID 保存字节和元数据;等上传 gate 后才返回 201。
if (path.endsWith('/assets') && request.method === 'POST') {
const upload = new Request('http://fixture', { method: 'POST', body: bytes, headers: { 'Content-Type': request.headers['content-type'] } }), form = await upload.formData(), file = form.get('image');
const asset = { id: request.headers['x-resource-id'], bytes: file.size, mime_type: 'image/png', width: 4, height: 2, url: `https://api.foundry.entroia.com/projects/endoleap/workspaces/${workspace}/assets/${request.headers['x-resource-id']}` };
@@ -110,24 +155,31 @@ const upstream = createServer(async (request, response) => {
return reply(201, { protocol_version: 2, request_id: request.headers['idempotency-key'], status: 'applied', asset });
}
const assetMetadata=path.match(/\/assets\/metadata\/([^/]+)$/);
// 元数据可延迟/拒绝;成功时对真实上传字节算 SHA-256,给授权采用与导入检查使用。
if(assetMetadata){if(metadataWait)await metadataWait;if(denyAssetMetadata||!assets.has(assetMetadata[1]))return reply(404,{error:'not_found'});const original=assetBytes.get(assetMetadata[1]),sha256=[...new Uint8Array(await crypto.subtle.digest('SHA-256',original))].map(n=>n.toString(16).padStart(2,'0')).join('');return reply(200,{metadata_version:1,asset:assets.get(assetMetadata[1]),sha256});}
// 普通资产显示路径返回固定 PNG,和上面的原始上传/摘要元数据检查属于不同证据。
if (/\/assets\/[^/]+$/.test(path)) { response.writeHead(200, { 'Content-Type': 'image/png' }); return response.end(png); }
reply(404, { error: 'not_found' });
} catch (error) { response.writeHead(500, { 'Content-Type': 'application/json' }); response.end(JSON.stringify({ error: error.message })); }
});
// 先绑定本地上游,短暂监听器再为 Next 选端口;释放保留端口后才启动 Next,不是持续端口占用锁。
await new Promise(resolve => upstream.listen(0, '127.0.0.1', resolve));
const reserve = createServer(); await new Promise(resolve => reserve.listen(0, '127.0.0.1', resolve)); const port = reserve.address().port; await new Promise(resolve => reserve.close(resolve));
let next, chrome, socket, logs = '';
try {
// 所有源码插桩只写入临时副本,原仓库 src 不会被这些 replace 修改。
await cp(join(source, 'src'), join(directory, 'src'), { recursive: true });
await cp(join(source, 'public'), join(directory, 'public'), { recursive: true });
// baseline 模式只将 engine/scheduler/queries 替换为指定 commit 的文件,属于局部诊断对照。
if(performanceMode&&baseline)for(const file of ['src/lib/canvas-runtime/engine.ts','src/lib/canvas-runtime/scheduler.ts','src/lib/canvas-core/queries.ts'])await writeFile(join(directory,file),execFileSync('git',['show',`${baseline}:${file}`],{cwd:source}));
// 堆诊断仅在临时 storage.commit 注入回执保留计数,记录 Root/文档/回执元素规模,不能直接当作完整保留堆分析。
if(performanceMode&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'){
const file=join(directory,'src/lib/canvas-runtime/storage.ts'),text=await readFile(file,'utf8'),marker='async commit(leader: StorageLeader, batch: DurableBatch) {';assert.ok(text.includes(marker));await writeFile(file,text.replace(marker,marker+`const host=globalThis as typeof globalThis&{__fixtureReceiptRetention?:unknown};const payload=batch.image.payload as any;const receipts=payload.runtime?.receipts??[];const counts=receipts.map((row:any)=>Array.isArray(row[1]?.model?.elements)?row[1].model.elements.length:0);host.__fixtureReceiptRetention={rootRevision:batch.image.rootRevision,documentElements:payload.document?.elements?.length,receipts:receipts.length,modelElements:counts.reduce((a:number,b:number)=>a+b,0),maxModelElements:Math.max(0,...counts)};`));
}
// Count delivered measurement batches in the isolated fixture only.
// 包装批量测量更新,记录每批 ID 数;assert 源码锚点存在,避免插桩未命中却继续报告。
const internalsFile=join(directory,'src/lib/canvas-bindings/node-internals.ts'),internals=await readFile(internalsFile,'utf8');assert.ok(internals.includes('if(ids.size)update([...ids]);'));await writeFile(internalsFile,internals.replace('if(ids.size)update([...ids]);',`if(ids.size){const host=globalThis as typeof globalThis&{__fixtureInternalsBatches?:number[]};(host.__fixtureInternalsBatches??=[]).push(ids.size);update([...ids]);}`));
// 功能模式复制插件、hooks 与选择观察 fixture,并把组件接入临时页面;性能模式不装这些观察者。
if(!performanceMode){
await copyFile(join(source,'tests/headless/renderer-extension.tsx'),join(directory,'src/components/canvas/renderer-fixture.tsx'));
await copyFile(join(source,'tests/headless/react-hooks-fixture.tsx'),join(directory,'src/components/canvas/hooks-fixture.tsx'));
@@ -135,22 +187,25 @@ try {
const canvasFile=join(directory,'src/components/canvas/headless-graph-canvas.tsx'),canvasSource=await readFile(canvasFile,'utf8');
await writeFile(canvasFile,canvasSource.replace('"use client";','"use client";\nimport {NativeSelectionFixture} from "./selection-listener-fixture";').replace('<Background variant=','<NativeSelectionFixture/><Background variant='));
// Count actual scene rebuilds in the isolated fixture copy; production has no diagnostic global.
// 只统计完整场景 rebuild;已有 patch 路径不计数,逐元素 projection 另外统计,避免混用两个指标。
const sceneFile=join(directory,'src/lib/canvas-bindings/scene-projection.ts'),sceneSource=await readFile(sceneFile,'utf8');assert.ok(sceneSource.includes('const result=patched??projectCanvas('));await writeFile(sceneFile,sceneSource.replace('const result=patched??projectCanvas(',`const host=globalThis as typeof globalThis&{__fixtureSceneBuilds?:number};if(!patched)host.__fixtureSceneBuilds=(host.__fixtureSceneBuilds??0)+1;const result=patched??projectCanvas(`));
const projectionFile=join(directory,'src/lib/canvas-bindings/projection.ts'),projectionSource=await readFile(projectionFile,'utf8');assert.ok(projectionSource.includes(':ProjectionNode{'));await writeFile(projectionFile,projectionSource.replace(':ProjectionNode{',`:ProjectionNode{const host=globalThis as typeof globalThis&{__fixtureProjectedNodes?:Record<string,number>};host.__fixtureProjectedNodes??={};host.__fixtureProjectedNodes[element.id]=(host.__fixtureProjectedNodes[element.id]??0)+1;`));
// Delay only the full-source Worker result in the isolated copy, independently of parser I/O.
// 摘要 gate 延迟的是 Worker 已算出的全源结果,独立于后续 GatedDocument 的 parser I/O 屏障。
const digestFile=join(directory,'src/lib/canvas-runtime/browser-digests.ts'),digestSource=await readFile(digestFile,'utf8');assert.ok(digestSource.includes('else finish(undefined,value.sha256);'));await writeFile(digestFile,digestSource.replace('else finish(undefined,value.sha256);',`else {const host=globalThis as typeof globalThis&{__fixtureDigestGate?:Promise<void>;__fixtureDigestWaiting?:boolean};if(host.__fixtureDigestGate){host.__fixtureDigestWaiting=true;void host.__fixtureDigestGate.then(()=>finish(undefined,value.sha256));}else finish(undefined,value.sha256);}`));
// A fixture Host action exercises the public engine API; no debug entry point ships in the app.
// 通过临时 effect 暴露实际 engine 和公开 collectImportSources 动作;卸载时只清理同一注册函数。
const importsFile=join(directory,'src/components/canvas/headless-imports.tsx'),importsSource=await readFile(importsFile,'utf8');
await writeFile(importsFile,importsSource.replace('import { useCallback,','import { useEffect, useCallback,').replace(' const subscribe =',` useEffect(()=>{const host=globalThis as typeof globalThis&{__headlessCollectSources?:()=>ReturnType<ProjectEngine['collectImportSources']>;__fixtureActualEngine?:ProjectEngine};const collect=()=>engine.collectImportSources({retentionMs:0});host.__headlessCollectSources=collect;host.__fixtureActualEngine=engine;return()=>{if(host.__headlessCollectSources===collect)delete host.__headlessCollectSources;};},[engine]);
const subscribe =`));
// 在 Host 扩展装配点安装自定义 renderer 与 HookChecks,锚点改变则测试准备失败,不静默少测插件。
const hostFile=join(directory,'src/components/workspace/canvas-runtime-host.tsx'),host=await readFile(hostFile,'utf8');assert.ok(host.includes('extensions=[]'),'host extension installation point changed');await writeFile(hostFile,host.replace('"use client";','"use client";\nimport {fixtureExtension} from "@/components/canvas/renderer-fixture";\nimport {HookChecks} from "@/components/canvas/hooks-fixture";').replace('extensions=[]','extensions=[fixtureExtension]').replace('{children}</Renderers.Provider>','{children}<HookChecks/></Renderers.Provider>'));
}
// 临时应用沿用项目配置和 loader,再链接现有 node_modules,不另装依赖。
for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs', 'next.config.ts']) await copyFile(join(source, file), join(directory, file));
await cp(join(source,'scripts/loaders'),join(directory,'scripts/loaders'),{recursive:true});
if(!performanceMode){
const loaderFile=join(directory,'scripts/loaders/xyflow-retained.cjs');
// 附加 loader 包装统计 ReactFlow 原生选择 selector 扫描次数;字符串写入临时 loader,不影响产品构建。
const instrument=`
const nativeTransform=module.exports;
module.exports=function(source){return nativeTransform.call(this,source).replace('const selector$l = (s) => {','const selector$l = (s) => { globalThis.__fixtureSelectionScans=(globalThis.__fixtureSelectionScans??0)+1;');};
@@ -159,50 +214,64 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
}
await symlink(join(source, 'node_modules'), join(directory, 'node_modules'));
// 统一 Next 子进程启动和日志尾部收集;独立进程组供 finally 一并终止,API origin 指向本次上游。
const launchNext=args=>{
const child=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),...args],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'endoleap'},stdio:['ignore','pipe','pipe']});
child.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});child.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});return child;
};
let buildMs;
// 生产性能入口先完成 Next build 并记录耗时,非零退出立即失败;其他入口用 dev,仍可能触发页面编译。
if(productionMode){
console.log('BUILD production fixture');const started=Date.now();next=launchNext(['build',`--${bundler}`]);
await new Promise((resolve,reject)=>{next.once('error',reject);next.once('exit',(code,signal)=>code===0?resolve():reject(Error(`production fixture build failed (${code??signal})\n${logs}`)));});
buildMs=Date.now()-started;console.log(`BUILD complete ${buildMs} ms`);
}
next=launchNext([productionMode?'start':'dev',...productionMode?[]:[`--${bundler}`],'--hostname','127.0.0.1','--port',String(port)]);
// 有界探测真实代理 profile,并检查 Next 提前退出;后续页面等待还会验证应用是否实际可用。
for (let i = 0; i < 120; i++) { if (next.exitCode !== null) throw new Error(logs); try { const r = await fetch(`http://127.0.0.1:${port}/api/foundry/profile`, { signal: AbortSignal.timeout(1000) }); if (r.ok) break; } catch {} await pause(250); }
// 只保留本次隔离 Chrome 的日志尾部,CHROME_BIN 可覆盖可执行路径,不连接日常 profile。
let browserLogs='';
chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore','ignore','pipe'] });
chrome.stderr.on('data',chunk=>{browserLogs=(browserLogs+chunk).slice(-16000);});
// 从本次 profile 的 DevToolsActivePort 发现调试端口,失败则不继续浏览器命令。
let debugPort;
for (let i = 0; i < 100; i++) { try { debugPort = Number((await readFile(join(profile, 'DevToolsActivePort'), 'utf8')).split('\n')[0]); break; } catch { await pause(100); } }
assert.ok(debugPort, 'Chrome did not start');
const tabs = await fetch(`http://127.0.0.1:${debugPort}/json/list`).then(response => response.json());
socket = new WebSocket(tabs.find(tab => tab.type === 'page').webSocketDebuggerUrl); await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
const exceptions=[];
// 使用有截止时间的共享 CDP 调用器;收集页面异常与 fatal 事件,便于失败时定位。
const call=createCdpCalls(socket,{onEvent(message){if(['Inspector.targetCrashed','Target.targetCrashed','Inspector.detached','Page.javascriptDialogOpening'].includes(message.method))browserLogs=(browserLogs+'\nCDP '+JSON.stringify(message)).slice(-16000);if(message.method==='Runtime.consoleAPICalled'&&message.params.type==='error')console.log('BROWSER ERROR',message.params.args.map(arg=>arg.description??arg.value).join(' '));if(message.method==='Runtime.exceptionThrown')exceptions.push(message.params.exceptionDetails.exception?.description??message.params.exceptionDetails.text);}});
// 页面表达式等待 Promise 并按值返回;exceptionDetails 直接转换为测试失败。
const evaluate = async expression => { const r = await call('Runtime.evaluate', { expression, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description ?? r.exceptionDetails.text); return r.result?.value; };
// 最多 200 轮条件检查,先检查页面异常,每轮约隔 100ms;失败附作业摘要、页面文本和 Next 日志,整体等待仍包含各 CDP 调用耗时。
async function until(expression, label) { for (let i = 0; i < 200; i++) { if (exceptions.length) throw new Error(exceptions.join('\n')); if (await evaluate(expression)) return; await pause(100); } throw new Error(label + '\n' + JSON.stringify(await evaluate(`[...window.__fixtureActualEngine?.imports.getJobs().values()??[]].map(job=>({id:job.id,status:job.status,error:job.error,sourceError:job.source?.integrityError}))`)) + '\n' + await evaluate('document.body.innerText') + '\n' + logs); }
// 启用页面/异常事件并固定 1280×900 视口,减少交互坐标和虚拟化断言随屏幕环境漂移。
await call('Runtime.enable');await call('Page.enable');await call('Target.setDiscoverTargets',{discover:true}); await call('Emulation.setDeviceMetricsOverride', { width: 1280, height: 900, deviceScaleFactor: 1, mobile: false });
const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${migrationFixture?.states[0].root??root}`;
await call('Page.navigate', { url: route });
// 按模式选择性能、迁移、hooks 或完整交互;每条专用入口成功后都回到公共 finally 清理。
if(performanceMode){
await measureRenderer({call,evaluate,documents,calls,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',bundler,buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')});
}else if(migrationOnly){await verifyRendererMigration({fixture:migrationFixture,call,evaluate,until,port,workspace,account,pause});
}else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report);
}else{
// 完整交互先要求首个元素在尾部 gate 前可编辑,b 尚不存在、创建工具禁用且没有文档 POST。
await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');
assert.equal(await evaluate('!!document.querySelector(".react-flow__node[data-id=b]")'),false);
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
assert.equal(await evaluate('document.querySelector("[aria-label=添加文本卡片]").disabled'),true);
await until('document.querySelector(".react-flow__node[data-id=a]").getBoundingClientRect().x>300','initial partial node remained under the sidebar');
// 使用 CDP 原生双击进入已载入文本,输入 partial-local;来源还不完整时不能发送文档请求。
const partialTextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=a] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...partialTextPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...partialTextPoint,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=a] textarea')&&!document.activeElement.readOnly`,'loaded prefix text did not enter edit mode');
await call('Input.insertText',{text:' partial-local'});const partialText=await evaluate('document.activeElement.value');assert.ok(partialText.endsWith(' partial-local'));assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,0);
await evaluate(`window.partialTextarea=document.activeElement;void 0`);
// 捕获同一个 textarea 和 viewport 后放行来源,完成不得替换焦点 DOM 或重置视口。
const previewTransform=await evaluate('document.querySelector("[data-headless-canvas] .react-flow__viewport").style.transform');
await writeFile('/private/tmp/headless-loading-editable.png',Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
// 只有这里才放行首读尾部;随后核对服务文字和 undo,证明前缀编辑的历史继续可用。
releaseFirstModel();
await until('!!document.querySelector("[data-headless-canvas]:not([data-model-loading])") && document.querySelectorAll(".react-flow__node").length === 2','full model did not finish the partial editor');
assert.equal(await evaluate('window.partialTextarea.isConnected&&document.activeElement===window.partialTextarea'),true,'tail completion remounted the active text editor');
@@ -211,11 +280,13 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});await call('Input.dispatchKeyEvent',{type:'keyUp',key:'Escape',code:'Escape',windowsVirtualKeyCode:27});
await evaluate(`document.querySelector('[aria-label="撤销"]').click()`);for(let i=0;i<100&&documents.get(root).elements.get('a').data.text!=='Editable content';i++)await pause(20);assert.equal(documents.get(root).elements.get('a').data.text,'Editable content');
console.log('PASS actual GraphPage edits the first prefix before the gated tail, keeps its active textarea/history/viewport and sends only after durable completion');
// 启用原生选择监听后改选 b,停用时事件数和 selector 扫描数都应冻结,再启用后继续收到事件。
await until('!!window.__fixtureNativeSelection','native selection fixture did not mount');
await evaluate('window.__priorNativeSelection=window.__fixtureNativeSelection.selected();window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await evaluate('window.__fixtureNativeSelection.select(["b"]);true');await until('window.__fixtureNativeSelectionEvents.at(-1)?.nodes.join() === "b"','native selection hook missed its selected node');
await evaluate('window.__fixtureNativeSelection.enable(false);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
const unsubscribedSelectionEvents=await evaluate('window.__fixtureNativeSelectionEvents.length');
// 实际扫描计数必须先大于零,避免“从未装上监听器”也通过卸载后零变化的检查。
const subscribedSelectionScans=await evaluate('window.__fixtureSelectionScans??0');assert.ok(subscribedSelectionScans>0,'native scan instrumentation did not observe the active hook');
await evaluate('window.__fixtureNativeSelection.select(["a"]);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureNativeSelectionEvents.length'),unsubscribedSelectionEvents,'native selection hook fired after unsubscription');assert.equal(await evaluate('window.__fixtureSelectionScans??0'),subscribedSelectionScans,'unsubscribed native selection still scanned the graph');
await evaluate('window.__fixtureNativeSelection.enable(true);new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
@@ -227,10 +298,13 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(calls.some(call => call.path.endsWith('/canvas')), false, 'canonical project read the legacy snapshot');
assert.equal(await evaluate('!!document.querySelector("[aria-label=画布快捷操作]") && !document.querySelector("[aria-label=展开侧边栏]")'), true);
// 按钮辅助函数走实际页面 click;下一个键盘脚本只检查事件是否被 canvas 声明消费。
const click = label => evaluate(`document.querySelector('[aria-label=${JSON.stringify(label)}]').click()`);
// Ctrl/Meta、IME 组合态和未知键不被抢占,普通 h/v 工具快捷键应 preventDefault。
assert.deepEqual(await evaluate(`(()=>{const canvas=document.querySelector('[data-headless-canvas]');const fire=(key,options={})=>{const e=new KeyboardEvent('keydown',{key,bubbles:true,cancelable:true,...options});canvas.dispatchEvent(e);return e.defaultPrevented;};return [fire('h',{ctrlKey:true}),fire('v',{metaKey:true}),fire('h',{isComposing:true}),fire('F9'),fire('h'),fire('v')];})()`),[false,false,false,false,true,true]);
console.log('PASS canvas keyboard routing leaves modifiers, IME and unknown keys unclaimed and consumes plain tool shortcuts');
// 创建、undo、redo 同时核对 DOM 节点数与 fixture 文档元素数,后者还含原连线,不能直接把两种计数混用。
await click('添加文本卡片'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'create did not render');
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
assert.equal(documents.get(root).elements.size, 4);
@@ -239,15 +313,18 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(documents.get(root).elements.size, 3);
await click('重做'); await until('document.querySelectorAll(".react-flow__node").length === 3', 'redo did not render');
for (let i = 0; i < 100 && documents.get(root).elements.size !== 4; i++) await pause(20);
// 记录拖动前文档请求数,并在页面收集原生 pointer/mouse/capture 事件供失败诊断。
const beforeDrag = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
await evaluate(`window.inputEvents=[];for(const type of ['pointerdown','mousedown','pointerup','mouseup','pointercancel','lostpointercapture'])window.addEventListener(type,event=>window.inputEvents.push({type,x:event.clientX,y:event.clientY,target:event.target.outerHTML?.slice(0,120)}),true);`);
const rect = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()');
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: rect.x, y: rect.y, button: 'left', buttons: 1, clickCount: 1 });
// 十帧 pointer move 只更新预览,释放时只允许一个文档请求,最终位置以 pointerup 坐标为准。
for (let step = 1; step <= 10; step++) await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: rect.x + step * 5, y: rect.y + step * 3, button: 'left', buttons: 1 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: rect.x + 50, y: rect.y + 30, button: 'left', buttons: 0 });
for (let i = 0; i < 100 && documents.get(root).elements.get('b').geometry.x === 450; i++) await pause(20);
assert.ok(Math.abs(documents.get(root).elements.get('b').geometry.x - 500) < 1, JSON.stringify({ geometry: documents.get(root).elements.get('b').geometry, rect, writes: calls.filter(call => call.path.endsWith('/model/operations')).map(call => JSON.parse(call.bytes.toString()).operations), inputs: await evaluate('window.inputEvents'), text: await evaluate('document.body.innerText') }));
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeDrag + 1, 'pointer frames became document requests');
// 再次移动后用 Escape 取消,再发送迟到 mouseReleased;服务几何和请求数都不能变化。
const beforeCancel = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
const moved = await evaluate('(()=>{const r=document.querySelector(".react-flow__node[data-id=b]").getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()');
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: moved.x, y: moved.y, button: 'left', buttons: 1, clickCount: 1 });
@@ -256,6 +333,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchKeyEvent', { type: 'keyUp', key: 'Escape', code: 'Escape', windowsVirtualKeyCode: 27 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', x: moved.x + 35, y: moved.y + 25, button: 'left', buttons: 0 });
await pause(100); assert.equal(documents.get(root).elements.get('b').geometry.x, 500); assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel);
// 拖动中切 H/pan 也必须取消所属预览;后来的 pointerup 不能提交,V 恢复选择工具及已提交位置。
const toolPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+80,y:r.y+30}})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...toolPoint,button:'left',buttons:1,clickCount:1});
await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:toolPoint.x+35,y:toolPoint.y+25,button:'left',buttons:1});
@@ -270,6 +348,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await until(`document.querySelector('[aria-label="选择工具(V)"]')?.getAttribute('aria-pressed')==='true'`,'selection tool did not render');
await until(`(()=>{const n=document.querySelector('.react-flow__node[data-id=b]'),m=new DOMMatrix(n.style.transform);return Math.abs(m.m41-500)<.01&&!n.classList.contains('dragging');})()`,'canceled native node did not return to its committed position');
console.log('PASS actual drag-to-pan tool switch cancels preview and late pointerup produces no document request');
// 用实际 resize handle 开始缩放,期间合成 foreign pointerId=999 事件,不能提交或取消当前手势。
const corner = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label="拖动右下角调整大小"]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height/2}})()`);
await call('Input.dispatchMouseEvent', { type: 'mousePressed', x: corner.x, y: corner.y, button: 'left', buttons: 1, clickCount: 1 });
await call('Input.dispatchMouseEvent', { type: 'mouseMoved', x: corner.x + 20, y: corner.y + 10, button: 'left', buttons: 1 });
@@ -279,10 +358,12 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
for (let i=0;i<100&&documents.get(root).elements.get('b').geometry.width!==300;i++)await pause(20);
assert.equal(documents.get(root).elements.get('b').geometry.width,300,JSON.stringify({geometry:documents.get(root).elements.get('b').geometry,inputs:await evaluate('window.inputEvents'),text:await evaluate('document.body.innerText')}));assert.equal(documents.get(root).elements.get('b').geometry.height,220);
assert.equal(calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length, beforeCancel+1);
// resize 模式 Escape 分三种:原生 select 内、子控件已 preventDefault、工具栏自身;只有未被控件接管的工具栏 Escape 清理模式。
await click('八向拉伸模式');await until(`document.querySelector('[data-resize-toggle="b"]')?.getAttribute('aria-pressed')==='true'`,'resize mode did not open');
await evaluate(`document.querySelector('[data-node-style="b"] select').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','native selector Escape dismissed the canvas mode');
await evaluate(`(()=>{const button=document.querySelector('[data-resize-toggle="b"]');button.addEventListener('keydown',event=>event.preventDefault(),{once:true});button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));})()`);await pause(50);assert.equal(await evaluate(`document.querySelector('[data-resize-toggle="b"]').getAttribute('aria-pressed')`),'true','toolbar ignored a child control that had claimed Escape');
await evaluate(`document.querySelector('[data-resize-toggle="b"]').dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}))`);await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');return !!node&&[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].every(view=>view.modes.get('b')!=='resize')&&!node.querySelector('[data-resize-handle]')&&!document.querySelector('[data-resize-toggle="b"][aria-pressed="true"]');})()`,'toolbar did not dismiss its own resize mode').catch(async error=>{console.log('MODE DIAGNOSTIC',await evaluate(`JSON.stringify({views:[...window.__fixtureActualEngine.sessions.getSnapshot().views.values()].map(view=>({id:view.id,modes:[...view.modes]})),buttons:[...document.querySelectorAll('[data-resize-toggle="b"]')].map(node=>({pressed:node.getAttribute('aria-pressed'),html:node.outerHTML})),target:document.querySelector('.react-flow__node[data-id=b]')?.outerHTML.slice(0,1500)})`));throw error;});
// 真实双击进入文本,IME 组合再提交中文;undo 应只撤销该输入,不回退此前已提交的缩放。
const textPoint = await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]').getBoundingClientRect();return {x:r.x+60,y:r.y+30}})()`);
await call('Input.dispatchMouseEvent', { type: 'mousePressed', ...textPoint, button: 'left', buttons: 1, clickCount: 2 });
await call('Input.dispatchMouseEvent', { type: 'mouseReleased', ...textPoint, button: 'left', buttons: 0, clickCount: 2 });
@@ -298,15 +379,17 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(documents.get(root).elements.get('b').data.text,'Editable content');assert.equal(documents.get(root).elements.get('b').geometry.width,300);
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'edits did not settle before hit-test');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// Hit from the pane beyond the native edge interaction stroke, using screen-space tolerance.
// 沿可见曲线法线寻找落在 pane 的点,特意避开节点并超出原生 SVG 交互 stroke,检验屏幕容差命中。
const nearCurve=await evaluate(`(()=>{const path=document.querySelector('.react-flow__edge[data-id="edge"] .react-flow__edge-path');if(!path)return null;const length=path.getTotalLength(),matrix=path.getScreenCTM();for(let t=.1;t<.99;t+=.01){const a=path.getPointAtLength(length*t),b=path.getPointAtLength(length*t+1),p=new DOMPoint(a.x,a.y).matrixTransform(matrix),q=new DOMPoint(b.x,b.y).matrixTransform(matrix),dx=q.x-p.x,dy=q.y-p.y,d=Math.hypot(dx,dy);for(const sign of [-1,1]){const x=p.x-sign*dy/d*11,y=p.y+sign*dx/d*11;if(document.elementFromPoint(x,y)?.classList.contains('react-flow__pane')&&![...document.querySelectorAll('.react-flow__node')].some(node=>{const r=node.getBoundingClientRect();return x>=r.left-13&&x<=r.right+13&&y>=r.top-13&&y<=r.bottom+13;}))return{x,y};}}return null;})()`);
assert.ok(nearCurve,'no pane point beyond the SVG interaction stroke');
// 边命中是只读选择:出现曲线控制点但不得产生文档 operation。
const beforeHit=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
await call('Input.dispatchMouseEvent',{type:'mousePressed',...nearCurve,button:'left',buttons:1,clickCount:1});
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...nearCurve,button:'left',buttons:0,clickCount:1});
await until(`!!document.querySelector('[aria-label="曲线点 1"]')`,'pane near-curve hit did not select the edge');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeHit,'read-only hit query created a document operation');
console.log('PASS screen-tolerance pane hit selects the displayed curve beyond native SVG stroke without a model operation');
// 重叠区域控制点仍需可命中,拖动末坐标保存为 (440,265),整个曲线手势只新增一次请求。
const curveHandle=await evaluate(`(()=>{const r=document.querySelector('[aria-label="曲线点 1"]').getBoundingClientRect();return{x:r.x+r.width/2,y:r.y+r.height/2};})()`);
assert.equal(await evaluate(`document.elementFromPoint(${curveHandle.x},${curveHandle.y})?.getAttribute('aria-label')`),'曲线点 1','selected curve control must remain hit-testable where a card overlaps');
const beforeCurve = calls.filter(call => call.method === 'POST' && call.path.endsWith('/model/operations')).length;
@@ -602,9 +685,13 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
console.log('PASS logout retires the runtime before dropping its view without a render error');
}
} finally {
// 统一放开挂起响应并关闭 CDP;不依赖正在等待 gate 的请求自行退出。
releaseScopeStream();releaseAbandoned();releaseFirstModel();releaseImport();releaseApply();releaseUpload(); socket?.close();
// 先请求浏览器退出,三秒仍未结束则 SIGKILL,再等待 exit 并销毁输出流。
if (chrome) { const exit = new Promise(resolve => { if (chrome.exitCode !== null) resolve(); else chrome.once('exit', resolve); }); chrome.kill(); await Promise.race([exit,pause(3000)]);if(chrome.exitCode===null)chrome.kill('SIGKILL');await exit;chrome.stdin?.destroy();chrome.stdout?.destroy();chrome.stderr?.destroy(); }
// 按进程组向 Next 发 SIGTERM,超时用 SIGKILL,避免只杀父进程留下编译子进程。
if (next?.pid) { const exit = new Promise(resolve => { if (next.exitCode !== null) resolve(); else next.once('exit', resolve); }); try { process.kill(-next.pid, 'SIGTERM'); } catch {} await Promise.race([exit, pause(3000)]); if (next.exitCode === null) try { process.kill(-next.pid, 'SIGKILL'); } catch {} }
// 强制关闭上游残留连接再停止监听,最后删除本次应用和 profile 两个确切目录。
upstream.closeAllConnections(); await new Promise(resolve => upstream.close(resolve));
await rm(directory, { recursive: true, force: true, maxRetries: 5 }); await rm(profile, { recursive: true, force: true, maxRetries: 5 });
}