docs(tests): explain renderer recovery AI import and retained ownership flows

This commit is contained in:
2026-11-12 11:43:08 +08:00
parent 50675ffed3
commit 6ad003eb37
+77 -3
View File
@@ -403,6 +403,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);
await until(`[...document.querySelectorAll('[data-headless-canvas] [role="status"]')].some(element=>element.textContent.trim()==='已同步')`,'curve edit did not settle before opening recovery editor');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 在 b 的实际可见 label 中扫描 elementFromPoint,找未被遮挡的原生双击位置,避免脚本 focus 跳过页面编辑流程。
const recoveryPoint=await evaluate(`(()=>{const label=document.querySelector('.react-flow__node[data-id=b] [aria-label^="单击选中"]'),r=label.getBoundingClientRect();
for(let y=Math.max(0,r.top)+8;y<Math.min(innerHeight,r.bottom);y+=8)for(let x=Math.max(0,r.left)+8;x<Math.min(innerWidth,r.right);x+=8)
if(label.contains(document.elementFromPoint(x,y)))return{x,y};
@@ -410,57 +411,77 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...recoveryPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...recoveryPoint,button:'left',buttons:0,clickCount:2});
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'recovery edit did not open');
// 下一写入明确拒绝并由 fixture 改出远程值;本地文字保留,页面应显示可进入恢复的动作。
rejectNextEdit=true;await call('Input.insertText',{text:'Local recovery'});await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected content did not expose recovery');
// 保存本地候选文字和原 rejected requestId;恢复选择必须呈现 Remote reviewed 作为用户审阅基线。
const localRecoveryText=await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value`),rejectedId=rejectedEdit.request_id;
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')`,'recovery choices did not load');
assert.ok(await evaluate(`document.querySelector('[role=dialog]').innerText.includes('Remote reviewed')`),'review omitted the server value');
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);
// 选择 keep-local 后先预览,按钮可用才 apply;等待对话框关闭与服务端新内容,不用 UI 关闭代替数据核对。
await click('预览处理方案');await until(`!document.querySelector('[aria-label="应用处理方案"]').disabled`,'reviewed recovery plan did not become applicable');await click('应用处理方案');
await until(`!document.querySelector('[role=dialog]')`,'recovery dialog did not close after persistence');
for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!==localRecoveryText;i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,localRecoveryText);
// 替代请求必须有新 requestId/operationId,条件 before 是已审阅远程值;下一次 undo 也必须回到这个远程基线。
const recompiled=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).at(-1),recompiledBody=JSON.parse(recompiled.bytes.toString());assert.notEqual(recompiledBody.request_id,rejectedId);assert.notEqual(recompiledBody.operations[0].id,rejectedEdit.operations[0].id);assert.equal(recompiledBody.operations[0].changes[0].before,'Remote reviewed');
await click('撤销');for(let i=0;i<100&&documents.get(root).elements.get('b').data.text!=='Remote reviewed';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Remote reviewed');
console.log('PASS actual recovery dialog reviews remote/local content, recompiles with new identities, saves before dispatch and preserves the reviewed undo baseline');
// 改测结构创建拒绝:界面要标明创建元素和关联结构变更,应用后保留该新元素的撤销能力。
rejectNextEdit=true;await click('添加文本卡片');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected creation did not expose recovery');const createdId=rejectedEdit.operations[0].changes.find(change=>change.kind==='element'&&change.before===null).id;
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('创建元素')`,'creation review omitted the structural change');
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('关联结构变更')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'structural plan did not expose related changes');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'structural review did not finish');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.ok(documents.get(root).elements.has(createdId));rejectNextEdit=true;await click('撤销');await until(`!!document.querySelector('[aria-label="处理同步修改"]')`,'rejected undo did not expose recovery');
// 再拒绝该创建的 undo,恢复文案要识别为未同步撤销,选择保留撤销而不是重新创建普通内容操作。
await click('处理同步修改');await until(`!!document.querySelector('select[aria-label="处理第1项修改"]')&&document.querySelector('[role=dialog]').innerText.includes('未同步的撤销')`,'undo review did not identify the history action');assert.ok(await evaluate(`document.querySelector('select[aria-label="处理第1项修改"]').innerText.includes('保留这次撤销')`));
await evaluate(`(()=>{const select=document.querySelector('select[aria-label="处理第1项修改"]');select.value='keep-local';select.dispatchEvent(new Event('change',{bubbles:true}));})()`);await click('预览处理方案');await until(`document.querySelector('[role=dialog]').innerText.includes('重做记录由')&&!document.querySelector('[aria-label="应用处理方案"]').disabled`,'undo recovery omitted its redo stack');await click('应用处理方案');await until(`!document.querySelector('[role=dialog]')`,'undo recovery did not finish');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
// 恢复撤销后 redo 以第二代还原,再 undo 删除;检查的是栈方向和生命期,不只是节点是否存在。
await click('重做');for(let i=0;i<100&&!documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.get(createdId).incarnation,2);await click('撤销');for(let i=0;i<100&&documents.get(root).elements.has(createdId);i++)await pause(20);assert.equal(documents.get(root).elements.has(createdId),false);
console.log('PASS actual rejected undo review labels the traversal, previews both stacks, replans deletion and retains redo with a new lifecycle');
console.log('PASS actual structural recovery dialog reviews a rejected creation and related changes, applies it with a new identity and retains undo');
// 辅助动作先原生点击空白清选择,再选 b 下缘并等 AI 工具可用;避免复用上个场景的选中状态。
const selectB=async()=>{await call('Input.dispatchMouseEvent',{type:'mousePressed',x:1050,y:650,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:1050,y:650,button:'left',buttons:0,clickCount:1});await pause(50);const point=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id=b]').getBoundingClientRect();return {x:r.x+r.width/2,y:r.y+r.height-30}})()`);await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI tool did not accept the selected element');};
// 从实际 AI 面板读取 fixture 模型,设置 prompt 后执行;受理可见时服务只应创建一个任务。
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="AI 模型"] option[value="fixture-text"]')`,'AI catalog did not load through the host');
await evaluate(`(()=>{const input=document.querySelector('[aria-label="AI 指令"]');Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype,'value').set.call(input,'Summarize this content');input.dispatchEvent(new Event('input',{bubbles:true}));})()`);
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI command did not become executable');await click('执行 AI');
await until(`document.body.innerText.includes('已受理')`,'AI admission did not persist');assert.equal(aiRuns.size,1);
// 记录 CSS zoom、内容宽高和字号,后续比较这些具体样式值;没有在此做完整像素或全部排版属性的等价检查。
const visualMetric=`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]'),frame=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return {zoom:frame.style.zoom,width:frame.style.width,height:frame.style.height,font:getComputedStyle(node.querySelector('textarea')).fontSize}})()`,beforeVisual=await evaluate(visualMetric);
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);await until(`location.pathname.endsWith('${other}')&&document.querySelectorAll('.react-flow__node').length===0`,'AI view detach failed');
// 导航到另一个项目后,由 fixture 在根项目创建缩放分区并移动 b,保留其元素身份,再追加生成结果事件。
const run=aiRuns.get(id(700)),beforeAI=documents.get(root),beforeNode=beforeAI.elements.get('b');
const frame={...node('relocation-frame',root,30),componentId:'frame',geometry:{x:30,y:50,width:1000,height:600,rotation:0},data:{title:'Relocated partition',text:'',presentation:{contentScale:2}}};
// 按新 owner 的局部坐标换算几何并写 unitScale=.5;这是测试准备的已知变换,不由浏览器拖拽自动产生。
const movedModel=applyChanges(beforeAI,[{kind:'element',id:frame.id,before:null,after:frame},{kind:'canvas',id:partition,before:null,after:{id:partition,parent:{canvasId:root,entryElementId:frame.id}}},{kind:'field',target:{id:'b',incarnation:1},field:'ownerCanvasId',before:root,after:partition},{kind:'field',target:{id:'b',incarnation:1},field:'geometry',before:beforeNode.geometry,after:{...beforeNode.geometry,x:(beforeNode.geometry.x-54)/2,y:(beforeNode.geometry.y-86)/2,width:beforeNode.geometry.width/2,height:beforeNode.geometry.height/2}},{kind:'field',target:{id:'b',incarnation:1},field:'data.presentation',before:beforeNode.data.presentation??null,beforePresent:Object.hasOwn(beforeNode.data,'presentation'),after:{...beforeNode.data.presentation,unitScale:.5},afterPresent:true}]);
const generated=applyChanges(movedModel,[{kind:'field',target:{id:'b',incarnation:1},field:'data.text',before:beforeNode.data.text,after:'Generated while another canvas was open',beforePresent:true,afterPresent:true}]);documents.set(root,generated);versions.set(root,versions.get(root)+1);versions.set(partition,2);run.status='succeeded';
run.events.push({seq:3,run_id:id(700),type:'step.output',step_id:'step-b',board_id:root,node_id:'b',output:{output_id:id(701),kind:'text',text:'Generated while another canvas was open'}},{seq:4,run_id:id(700),type:'step.applied',step_id:'step-b',board_id:partition,origin_board_id:root,canonical_root:root,node_id:'b',canvas_version:versions.get(partition),document_format_version:1,target_incarnation:1,model:model(generated,true)},{seq:5,run_id:id(700),type:'run.completed',status:'succeeded'});
// 离开视图后仍应继续轮询持久任务;另一项目页面必须保持无节点,不能显示原项目晚到结果。
const pollsBefore=calls.filter(call=>call.path.includes('/ai/runs/')).length;
for(let i=0;i<200&&calls.filter(call=>call.path.includes('/ai/runs/')).length===pollsBefore;i++)await pause(20);
assert.ok(calls.filter(call=>call.path.includes('/ai/runs/')).length>pollsBefore,'view detach stopped persistent AI polling');await pause(200);
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'),0,'AI output leaked into a different project view');
await call('Page.reload');await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'other project reload failed');
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);await until(`!!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelector('.react-flow__node[data-id=b] textarea')?.value==='Generated while another canvas was open'`,'AI output did not survive reload');
// 重载并返回原项目,面板要恢复 RunRecord;核对仅一个生成 POST、一个 b,以及缩放前后相同视觉采样。
await selectB();await click('AI 创作');await until(`document.body.innerText.includes('已更新当前元素')`,'durable RunRecord did not recover');await click('关闭 AI 工具');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,1,'AI recovery invoked generation twice');assert.equal(documents.get(root).elements.get('b').geometry.width,150);assert.equal(documents.get(root).elements.get('b').data.presentation.unitScale,.5);assert.deepEqual(await evaluate(visualMetric),beforeVisual,'normalized units changed actual rendered size or typography');assert.equal(documents.get(root).elements.get('b').ownerCanvasId,partition);assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node[data-id=b]').length`),1);
console.log('PASS actual AI panel follows a scaled owner move with stable rendered size/typography, polls after navigation, and restores one element/RunRecord without re-execution');
// 下一任务走实际取消按钮,完成后保留当前内容,再启动第三个任务构造可显式选择的冲突输出。
await selectB();await click('AI 创作');await until(`!!document.querySelector('[aria-label="执行 AI"]')&&!document.querySelector('[aria-label="执行 AI"]').disabled`,'AI action did not reopen');await click('执行 AI');await until(`!!document.querySelector('[aria-label="取消生成"]')`,'cancel control did not appear');await click('取消生成');await until(`document.body.innerText.includes('已取消生成')`,'durable cancellation did not finish');assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');
await until(`!document.querySelector('[aria-label="执行 AI"]').disabled`,'cancelled run did not release execution');await click('执行 AI');
for(let i=0;i<100&&!aiRuns.has(id(720));i++)await pause(20);assert.ok(aiRuns.has(id(720)));
// fixture 注入 output/conflict/completed 事件;冲突只保留结果,不应自动覆盖当前元素。
const conflicted=aiRuns.get(id(720));conflicted.status='conflict';conflicted.events.push({seq:3,run_id:id(720),type:'step.output',step_id:'step-b',board_id:partition,node_id:'b',output:{output_id:id(721),kind:'text',text:'Chosen conflict result'}},{seq:4,run_id:id(720),type:'step.conflict',step_id:'step-b',board_id:partition,node_id:'b',status:'conflict',error:'target_changed'},{seq:5,run_id:id(720),type:'run.completed',status:'conflict'});
await until(`[...document.querySelectorAll('button')].some(button=>button.textContent==='使用此结果替换当前内容')`,'conflict result did not expose explicit application');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent==='使用此结果替换当前内容').click()`);
// 选择输出后本地立即变化;applyGate 尚未释放时先 undo,应立即恢复旧内容,再允许服务回执协调。
await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Chosen conflict result'`,'chosen output was not optimistic');await click('撤销');await until(`document.querySelector('.react-flow__node[data-id=b] textarea').value==='Generated while another canvas was open'`,'unknown apply could not be locally undone');releaseApply();
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Generated while another canvas was open';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Generated while another canvas was open');await click('重做');
// redo 恢复已选择输出,不重新生成或重复 apply;最后精确检查取消一次、应用一次、总受理三次。
for(let i=0;i<200&&documents.get(root).elements.get('b').data.text!=='Chosen conflict result';i++)await pause(20);assert.equal(documents.get(root).elements.get('b').data.text,'Chosen conflict result');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/cancel')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/apply')).length,1);assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,3);await click('关闭 AI 工具');
console.log('PASS actual AI cancellation and optimistic conflict application use durable actions; undo/redo while awaiting acknowledgment never regenerate');
// 在页面生成真实 4×2 PNG 并经 File input 导入;上传 gate 仍挂起,本地 Blob URL 和尺寸应先出现。
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'});const transfer=new DataTransfer();transfer.items.add(new File([bytes],'pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length === 5 && [...document.querySelectorAll('[data-headless-canvas] img')].some(image=>image.getAttribute('src')?.startsWith('blob:'))`, 'pending image did not appear before upload');
// 从 Blob 预览找到新元素,等待 height=130;比例操作可用后,一次 undo 应删除整个已补尺寸创建,redo 恢复尺寸。
const pendingImageId=await evaluate(`document.querySelector('[data-headless-canvas] img[src^="blob:"]').closest('.react-flow__node').getAttribute('data-id')`);
await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').style.height==='130px'`,'default image size did not automatically use locally decoded 4:2 aspect ratio before upload ACK');
await evaluate(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`!document.querySelector('[aria-label="按图片比例调整"]').disabled`,'decoded image dimensions were not available before upload ACK');await click('按图片比例调整');
@@ -468,72 +489,91 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await click('重做');await until(`document.querySelector('.react-flow__node[data-id="${pendingImageId}"]')?.style.height==='130px'`,'redo lost the enriched creation dimensions');assert.equal(uploads.length<=1,true);
await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="${pendingImageId}"]');const content=[...node.querySelectorAll('div')].find(element=>element.style.zoom);return content?.style.height==='130px';})()`,'image content retained a stale measured height after automatic sizing and redo');
console.log('PASS default image sizing precedes upload ACK; creation undo/redo includes decoded dimensions without a second history unit or upload');
// 截图是此时 pending 图片状态的辅助产物,随后另外核对上游上传数与文档元素数。
const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-canvas-integration.png',Buffer.from(screenshot.data,'base64'));
for (let i = 0; i < 100 && !uploads.length; i++) await pause(20);
assert.equal(uploads.length, 1); assert.equal(documents.get(root).elements.size, 5);
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${other}"]').click()`);
await until(`location.pathname.endsWith('${other}') && !!document.querySelector('[data-headless-canvas]') && document.querySelectorAll('.react-flow__node').length === 0`, 'other board did not open');
// 导航到另一 board 后才确认上传;原项目后台继续写图片,当前页面不得被其晚到响应污染。
releaseUpload();
for (let i = 0; i < 200 && documents.get(root).elements.size !== 6; i++) await pause(20);
assert.equal(documents.get(root).elements.size, 6, 'view detach aborted the upload or document operation');
assert.equal(await evaluate('document.querySelectorAll(".react-flow__node").length'), 0, 'late result appeared on the wrong board');
// 另一项目用资源选择器采用已上传身份;先验证每次采用都重新授权,拒绝时不能创建元素或 undo 单元。
await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'other opened project image did not appear in the picker');
const pickerScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-resource-picker.png',Buffer.from(pickerScreenshot.data,'base64'));
denyAssetMetadata=true;await click('添加图片:视觉参考');await until(`document.querySelector('[role="dialog"] [role="alert"]')?.textContent.includes('访问权限')`,'fresh authorization rejection was not readable');assert.equal(documents.get(other).elements.size,0);assert.equal(await evaluate(`document.querySelector('[aria-label="撤销"]').disabled`),true);denyAssetMetadata=false;
// 授权读取挂起时按 Escape 关闭选择器,再放行旧响应;已关闭会话不得接纳这个迟到结果。
metadataWait=new Promise(resolve=>releaseMetadata=resolve);const metadataCalls=calls.filter(call=>call.path.includes('/assets/metadata/')).length;await click('添加图片:视觉参考');for(let i=0;i<100&&calls.filter(call=>call.path.includes('/assets/metadata/')).length===metadataCalls;i++)await pause(20);assert.ok(calls.filter(call=>call.path.includes('/assets/metadata/')).length>metadataCalls,'picker did not start authorization');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 until(`!document.querySelector('[role="dialog"]')`,'Escape did not close the resource picker');releaseMetadata();metadataWait=undefined;await pause(100);assert.equal(documents.get(other).elements.size,0,'closed picker admitted a late authorization result');
// 重新打开后成功采用同一 assetId,比例来自授权元数据且上传总数仍为一;再检查 undo/redo 进入第二代。
await click('复用其它画布图片');await until(`!!document.querySelector('[role="dialog"] [aria-label="添加图片:视觉参考"]')`,'resource picker did not reopen');await click('添加图片:视觉参考');await until(`!document.querySelector('[role="dialog"]')&&document.querySelectorAll('.react-flow__node').length===1`,'adopted image did not appear');for(let i=0;i<100&&documents.get(other).elements.size!==1;i++)await pause(20);const adopted=[...documents.get(other).elements.values()][0];assert.equal(adopted.data.assetId,uploads[0].id);assert.equal(uploads.length,1,'cross-project picker uploaded a duplicate');assert.equal(adopted.geometry.width/adopted.geometry.height,2);
await click('撤销');for(let i=0;i<100&&documents.get(other).elements.size;i++)await pause(20);assert.equal(documents.get(other).elements.size,0);await click('重做');for(let i=0;i<100&&documents.get(other).elements.get(adopted.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(other).elements.get(adopted.id).incarnation,2);assert.equal(uploads.length,1);console.log('PASS cross-project resource picker reauthorizes, cancels a late result, creates one shared image and supports undo/redo without uploading again');
await evaluate(`document.querySelector('a[href="/dashboard/${workspace}/${root}"]').click()`);
// 返回原项目并重载,文档/资源身份继续存在且没有退回 legacy canvas 读取。
await until('document.querySelectorAll(".react-flow__node").length === 5', 'returning project lost its engine');
assert.equal(uploads.length, 1);
await call('Page.reload'); await until('document.querySelectorAll(".react-flow__node").length === 5', 'page reload did not recover the document');
assert.equal(uploads.length, 1); assert.equal(calls.some(call => call.path.endsWith('/canvas')), false);
console.log('PASS pending image displays before upload; navigation preserves work and fences late view updates; reload retains resource identity');
console.log('PASS canvas floating controls remain visible while the sidebar is expanded');
// 完整页面模式也调用独立 HookChecks,报告来自真实 React mount 与内存端口组合,具体步骤在 hooks fixture 中说明。
const hookReports=await evaluate('window.__runCanvasHookChecks()');for(const report of hookReports)console.log('PASS React hooks: '+report);
// 导入 NDJSON 含一条合法记录和一个坏尾行;合法项先本地加入,服务写入仍由 importGate 挂住。
const beforeImport=documents.get(root).elements.size,importRecord={kind:'element',key:'ui-import',record:{id:'ui-import',incarnation:1,structure:'node',ownerCanvasId:'$target',componentId:'text',dataVersion:1,data:{title:'Imported NDJSON node',text:'Visible before ACK'},geometry:{x:700,y:450,width:240,height:160,rotation:0}}},nativeText=JSON.stringify(importRecord)+'\n{bad}';
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(nativeText)}],'canvas.ndjson',{type:'application/x-ndjson'}));const input=document.querySelector('[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelectorAll('.react-flow__node').length===6&&document.body.innerText.includes('部分加入')`,'native import did not show its successful prefix and partial report');
// 确认网络已进入屏障但服务元素数未增加,页面却已有新节点和 partial 报告,区分显示与 ACK。
for(let i=0;i<100&&!importWriteSeen;i++)await pause(20);assert.ok(importWriteSeen);assert.equal(documents.get(root).elements.size,beforeImport,'import waited for its backend before displaying content');
// 展开失败详情检查可读原因,截图后才释放文档 gate;重载要同时恢复内容与 partial 作业报告。
await evaluate(`[...document.querySelectorAll('summary')].find(summary=>summary.textContent.includes('条未加入记录')).click()`);await until(`document.body.innerText.includes('记录格式错误')`,'import failure reason was hidden');
const importScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-integration.png',Buffer.from(importScreenshot.data,'base64'));releaseImport();
for(let i=0;i<200&&documents.get(root).elements.size!==beforeImport+1;i++)await pause(20);assert.equal(documents.get(root).elements.size,beforeImport+1);await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('部分加入')`,'import task and content did not recover after reload');
// 记录模型 POST 基数,通过临时 Host 公开动作清理导入源;UI 状态和重载结果要保留,但清理不能产生内容操作。
const beforeSourceCollection=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length;
await until(`typeof window.__headlessCollectSources==='function'`,'fixture Host action was not installed');const sourceCollection=await evaluate(`window.__headlessCollectSources()`);assert.equal(sourceCollection.purgedIds.length,1);
await evaluate(`(()=>{const details=document.querySelector('[aria-label="画布导入"] details');if(!details.open)details.querySelector('summary').click();})()`);
await until(`document.body.innerText.includes('本地源文件已清理')`,'source collection status was not displayed');assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/model/operations')).length,beforeSourceCollection,'source GC created a document operation');
const sourceScreenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile('/private/tmp/headless-import-source-collection.png',Buffer.from(sourceScreenshot.data,'base64'));await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('本地源文件已清理')`,'retired source status/content did not recover');
console.log('PASS Host-triggered source collection displays its status, survives reload and creates zero model operations');
// 源文件已经清理,undo 仍能移除成功前缀;终态导入作业重载后也不能复活已撤销记录。
await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import undo did not remove its successful content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'import recovered an undone record');
console.log('PASS actual NDJSON import displays before backend ACK, reports a failed record, restores task/source state and never revives undone content');
await evaluate(`document.querySelector('.react-flow__node[data-id=b]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await until(`!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'copy selection button remained disabled');
// 工具栏复制走持久导入作业,准备完成后重载保持结果;undo 再重载不能由作业重放补回内容。
await click('深复制选中内容');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy did not publish through the durable task pipeline');
await until(`document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="正在准备复制"]')`,'copy preparation did not finish');
await pause(300);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===6&&document.querySelector('[aria-label="画布导入"]').textContent.includes('复制选中内容')`,'copy task did not recover after reload');
await click('撤销');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy history did not undo its published content');await pause(200);await call('Page.reload');await until(`document.querySelectorAll('.react-flow__node').length===5`,'copy replay revived an undone record');
console.log('PASS actual deep copy toolbar action publishes a durable task, survives reload and does not revive its undone content');
// Portalled frame-title drafts are not represented by a node editing mode.
// portal 分区标题草稿由显式 overlay owner 持有,不以节点 mode 代表。
await evaluate(`document.querySelector('.react-flow__node[data-id="relocation-frame"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);
await until(`!!document.querySelector('[aria-label="分区名称"]')`,'frame title editor did not open');
// 折叠只影响投影:b 模型端点仍存在且上级连线仍画到分区锚点;展开后成员重新出现。
await click('收起子画布');await until(`!document.querySelector('.react-flow__node[data-id=b]')&&document.querySelectorAll('.react-flow__edge').length>0`,'collapsed partition did not render its upper-canvas connection');assert.equal(documents.get(root).elements.get('edge').target.elementId,'b');assert.ok(documents.get(root).elements.has('b'));await click('展开子画布');await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'expanding partition did not restore the member');
console.log('PASS collapsed partitions retain model endpoints and render upper-canvas connections through headless frame anchors');
// portal 标题需显式注册 view/target;其工具栏按钮的普通 h 键不能被 canvas 工具路由抢走。
assert.ok(await evaluate(`document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]')?.dataset.canvasOverlayView`),'portalled frame title did not register its explicit view/target placement');
assert.equal(await evaluate(`(()=>{const overlay=document.querySelector('[aria-label="分区名称"]').closest('[data-canvas-overlay-target]'),button=overlay.querySelector('button'),event=new KeyboardEvent('keydown',{key:'h',bubbles:true,cancelable:true});button.dispatchEvent(event);return event.defaultPrevented;})()`),false,'portalled toolbar button key was claimed by the canvas tool');
await evaluate(`(()=>{window.pinnedFrameTitle=document.querySelector('[aria-label="分区名称"]');window.pinnedFrameTitle.focus();window.pinnedFrameTitle.select();})()`);await call('Input.insertText',{text:'Pinned partition draft'});
// 两个页面脚本分别大幅平移和回到原点,供 portal 与 IME retention 检查共用;不直接写业务几何。
const panFar=`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`,panHome=`(()=>{const matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:matrix.e/.5,deltaY:matrix.f/.5,bubbles:true,cancelable:true}));})()`;
// 标题仍在编辑时把所属节点移出视口,要求相同 DOM、焦点和草稿保留;blur 保存后恢复裁剪,回视口再挂载。
await evaluate(panFar);await pause(250);assert.equal(await evaluate(`window.pinnedFrameTitle.isConnected&&document.activeElement===window.pinnedFrameTitle&&window.pinnedFrameTitle.value==='Pinned partition draft'`),true,'viewport culling discarded an unsaved frame title');
await evaluate('window.pinnedFrameTitle.blur()');for(let i=0;i<100&&documents.get(root).elements.get('relocation-frame').data.title!=='Pinned partition draft';i++)await pause(20);assert.equal(documents.get(root).elements.get('relocation-frame').data.title,'Pinned partition draft');
await until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after title blur');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'panning back did not remount the node');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);
// 文本 IME 组合态也要在离屏时保留同一编辑器;提交后 Escape 释放,再平移回来核对已保存文字。
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'text editor did not enter pinned mode');await evaluate('window.pinnedText=document.activeElement; void 0');await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1});await evaluate(panFar);await pause(250);
assert.equal(await evaluate(`window.pinnedText.isConnected&&document.activeElement===window.pinnedText&&window.pinnedText.value.includes('保')`),true,'viewport culling interrupted active IME');await call('Input.insertText',{text:'保留输入'});for(let i=0;i<100&&!documents.get(root).elements.get('b').data.text.endsWith('保留输入');i++)await pause(20);assert.ok(documents.get(root).elements.get('b').data.text.endsWith('保留输入'));
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 until(`document.querySelectorAll('.react-flow__node').length===0`,'viewport culling did not resume after IME editing');await evaluate(panHome);await until(`!!document.querySelector('.react-flow__node[data-id=b] textarea')`,'edited node did not remount');assert.ok(await evaluate(`document.querySelector('.react-flow__node[data-id=b] textarea').value.endsWith('保留输入')`));
console.log('PASS native viewport culling retains unsaved portalled frame titles and active IME across pan, then resumes and remounts saved content');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id=b]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);
await until(`document.activeElement===document.querySelector('.react-flow__node[data-id=b] textarea')&&!document.activeElement.readOnly`,'retiring editor did not focus');
// 删除仍有焦点的节点时不能等待 blur 才释放所有权;旧 textarea 脱离后应恢复裁剪,历史可重新还原内容。
assert.equal(await evaluate(`(()=>{window.__retiredEditor=document.activeElement;const engine=window.__fixtureActualEngine,element=engine.getSnapshot().document.elements.get('b');return engine.editor.dispatch({type:'element.remove',target:{id:element.id,incarnation:element.incarnation}}).status;})()`),'applied');
await until(`!document.querySelector('.react-flow__node[data-id=b]')`,'focused node did not retire');assert.equal(await evaluate('window.__retiredEditor.isConnected'),false);
await evaluate(panFar);await until(`document.querySelectorAll('.react-flow__node').length===0`,'detached focus left the graph permanently retained');
@@ -541,7 +581,7 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
console.log('PASS deleting a focused renderer releases detached ownership without requiring blur and history restores its content');
// Exceed the curve-inspection budget without retaining unrelated child cards.
// 4097 条边超过曲线检查预算,但仅三个端点/入口 owner 需要挂载;200 个无关离屏子卡应继续裁剪。
const budgetRoot=id(870),budgetChild=id(871),budgetFrame={...node('budget-frame',budgetRoot,0),componentId:'frame',geometry:{x:0,y:400,width:1000,height:600,rotation:0}},budgetNodes=[node('budget-a',budgetRoot,80),node('budget-b',budgetRoot,450),budgetFrame,...Array.from({length:200},(_,index)=>node('budget-hidden-'+index,budgetChild,12000+index*280))],budgetEdges=Array.from({length:4097},(_,index)=>({id:'budget-edge-'+index,incarnation:1,structure:'connection',componentId:'connection',dataVersion:1,ownerCanvasId:budgetRoot,source:{elementId:'budget-a'},target:{elementId:'budget-b'},data:{},path:null}));
documents.set(budgetRoot,createDocument(budgetRoot,[{id:budgetRoot,parent:null},{id:budgetChild,parent:{canvasId:budgetRoot,entryElementId:budgetFrame.id}}],[...budgetNodes,...budgetEdges]));versions.set(budgetRoot,1);versions.set(budgetChild,1);boards.push({id:budgetRoot,workspace_id:workspace,kind:'board',version:1,title:'Curve budget board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${budgetRoot}`});
@@ -549,21 +589,25 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="budget-hidden-0"]')`),false);assert.equal(documents.get(budgetRoot).elements.size,4300);assert.equal(await evaluate('window.__fixtureActualEngine.getSnapshot().document.elements.size'),4300);assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get('budget-hidden-199')?.ownerCanvasId`),budgetChild);
console.log('PASS exhausted curve inspection retains all 4097 edges and their endpoint owners while 200 unrelated offscreen cards stay culled');
// Read a fresh project whose known envelope contains opaque component/version payloads.
// 构造未知 component、未来 dataVersion 和不透明 path/ports/presentation;明确区分已知外壳与无法解释的内部字段。
const opaqueRoot=id(800),opaqueChild=id(801),opaqueU={...node('opaque-u',opaqueRoot,50),componentId:'plugin:future',dataVersion:9,data:{title:'Opaque source',ports:{find:'future field'},presentation:{unitScale:'future'},assetId:'opaque-asset',__headlessFallback:{handles:'untrusted'},opaque:{preserve:true}}},opaqueV={...node('opaque-v',opaqueRoot,400),dataVersion:99,data:{title:'Future card',text:{preserve:true},ports:'future field'}},opaqueFrame={...node('opaque-frame',opaqueRoot,100),componentId:'frame',dataVersion:99,geometry:{x:100,y:450,width:350,height:220,rotation:0},data:{title:'Future partition',ports:{preserve:true},presentation:{unitScale:'future'}}},opaqueKnown=node('opaque-known',opaqueRoot,800),opaqueMember=node('opaque-member',opaqueChild,20),opaqueEdge={id:'opaque-edge',incarnation:1,structure:'connection',componentId:'plugin:connection',dataVersion:9,ownerCanvasId:opaqueRoot,source:{elementId:opaqueU.id,portId:'custom-out'},target:{elementId:opaqueV.id,portId:'custom-in'},path:{type:'future',preserve:true},data:{appearance:{color:{preserve:true}}}},regularEdge={...opaqueEdge,id:'opaque-regular',componentId:'connection',dataVersion:1,target:{elementId:opaqueKnown.id,portId:'left'},path:null,data:{}},partitionEdge={...regularEdge,id:'opaque-partition',source:{elementId:opaqueKnown.id,portId:'bottom'},target:{elementId:opaqueMember.id}};
const opaqueOriginal=createDocument(opaqueRoot,[{id:opaqueRoot,parent:null},{id:opaqueChild,parent:{canvasId:opaqueRoot,entryElementId:opaqueFrame.id}}],[opaqueU,opaqueV,opaqueFrame,opaqueKnown,opaqueMember,opaqueEdge,regularEdge,partitionEdge]);documents.set(opaqueRoot,opaqueOriginal);versions.set(opaqueRoot,1);versions.set(opaqueChild,1);boards.push({id:opaqueRoot,workspace_id:workspace,kind:'board',version:1,title:'Compatibility board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${opaqueRoot}`});await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque placeholders or their original connections did not render');
assert.equal(await evaluate(`document.querySelectorAll('.react-flow__node').length`),4);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="opaque-member"]')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('draggable')`),false);assert.equal(await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('textarea,input').length`),0);assert.ok(await evaluate(`[...document.querySelector('.react-flow__node[data-id="opaque-u"]').querySelectorAll('[data-handleid]')].some(handle=>handle.dataset.handleid==='custom-out'&&!handle.classList.contains('connectablestart')&&!handle.classList.contains('connectableend'))`));
// 本场景的 opaque-u 占位仍可选且有视觉反馈,但不可拖动/编辑/连线;已知邻居编辑不得改写不透明记录。
const opaqueBorder=await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`);await evaluate(`document.querySelector('.react-flow__node[data-id="opaque-u"]').dispatchEvent(new MouseEvent('click',{bubbles:true}))`);await until(`document.querySelector('.react-flow__node[data-id="opaque-u"]').classList.contains('selected')`,'opaque selection did not publish');assert.notEqual(await evaluate(`getComputedStyle(document.querySelector('[data-id="opaque-u"] [role=note]')).borderColor`),opaqueBorder,'placeholder selection was not visible');
await evaluate(`(()=>{const card=document.querySelector('.react-flow__node[data-id="opaque-known"]');card.dispatchEvent(new MouseEvent('click',{bubbles:true}));card.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="opaque-known"] textarea')&&!document.activeElement.readOnly`,'known neighbour was not editable');await evaluate('document.activeElement.select()');await call('Input.insertText',{text:'Known edit with opaque neighbours'});await evaluate('document.activeElement.blur()');for(let i=0;i<100&&documents.get(opaqueRoot).elements.get('opaque-known').data.text!=='Known edit with opaque neighbours';i++)await pause(20);assert.equal(documents.get(opaqueRoot).elements.get('opaque-known').data.text,'Known edit with opaque neighbours');for(const record of [opaqueU,opaqueV,opaqueFrame,opaqueEdge])assert.deepEqual(documents.get(opaqueRoot).elements.get(record.id),record);assert.equal(calls.some(call=>call.path.endsWith('/assets/opaque-asset')),false);
// 重载后占位和连接仍在,未知 frame 的子成员不应递归展示;并核对未请求 opaque-asset 的显示字节。
await call('Page.reload');await until(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length===3&&document.querySelectorAll('.react-flow__edge').length===3`,'opaque content or connections did not survive recovery');assert.equal(documents.get(opaqueRoot).elements.get('opaque-partition').target.elementId,'opaque-member');
await click('适应画布');await pause(100);await click('缩小');await pause(100);await click('缩小');await pause(100);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await writeFile(join(tmpdir(),'headless-opaque-renderer.png'),Buffer.from((await call('Page.captureScreenshot',{format:'png'})).data,'base64'));
console.log('PASS unknown types and future versions render safe read-only placeholders with custom/partition connections, preserve opaque payloads through edits and recovery, and never load opaque asset fields');
// 宿主扩展安装三个计量节点、两条自定义边和一条内置边,检查自定义端口与原始 data/path 保留。
const extensionRoot=id(810),meter={...node('meter-one',extensionRoot,350),componentId:'fixture:meter',data:{metric:3,raw:{retained:true}}},meterTwo={...meter,id:'meter-two',geometry:{...meter.geometry,x:750},data:{metric:9}},meterThree={...meter,id:'meter-three',geometry:{...meter.geometry,x:1150},data:{metric:12}},meterEdge={id:'meter-edge',incarnation:1,structure:'connection',componentId:'fixture:metric-edge',dataVersion:1,ownerCanvasId:extensionRoot,source:{elementId:meter.id,portId:'custom-out'},target:{elementId:meterTwo.id,portId:'custom-in'},data:{metric:55},path:{type:'metric-curve',retained:true}};
documents.set(extensionRoot,createDocument(extensionRoot,[{id:extensionRoot,parent:null}],[meter,meterTwo,meterThree,meterEdge,{...meterEdge,id:'unrelated-meter-edge',source:{elementId:'meter-two',portId:'custom-out'},target:{elementId:'meter-three',portId:'custom-in'}},{...meterEdge,id:'meter-builtin-edge',componentId:'connection',data:{},path:null}]));versions.set(extensionRoot,1);boards.push({id:extensionRoot,workspace_id:workspace,kind:'board',version:1,title:'Extension board'});await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${extensionRoot}`});await until(`document.querySelectorAll('[data-renderer-fixture="meter"]').length===3&&document.querySelectorAll('[data-renderer-fixture="metric-edge"]').length===2`,'host-installed custom node/edge renderers did not mount');await until(`document.querySelectorAll('.react-flow__edge').length===3`,'built-in edge did not connect custom renderer ports');assert.equal(await evaluate(`document.querySelectorAll('[aria-label="暂不支持编辑的元素"]').length`),0);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension baseline did not settle');await pause(200);await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersBefore=await evaluate('({...window.__fixtureRenders})'),projectsBefore=await evaluate('({...window.__fixtureProjectedNodes})');await click('Increase meter-one');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data,{metric:4,raw:{retained:true,edited:true}});assert.deepEqual(documents.get(extensionRoot).elements.get('meter-edge'),meterEdge);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension edit/ACK did not settle');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const rendersAfter=await evaluate('({...window.__fixtureRenders})');assert.ok(rendersAfter['meter-one']>rendersBefore['meter-one'],'edited custom node did not render');assert.equal(rendersAfter['meter-two'],rendersBefore['meter-two'],'unrelated custom node rendered during edit/ACK');assert.equal(rendersAfter['unrelated-meter-edge'],rendersBefore['unrelated-meter-edge'],'unrelated custom edge rendered during edit/ACK');const projectsAfter=await evaluate('({...window.__fixtureProjectedNodes})');assert.ok(projectsAfter['meter-one']>projectsBefore['meter-one']);assert.equal(projectsAfter['meter-two'],projectsBefore['meter-two'],'unrelated node projection rebuilt during edit/ACK');assert.equal(projectsAfter['meter-three'],projectsBefore['meter-three'],'unrelated node projection rebuilt during edit/ACK');console.log('PASS actual registered renderer updates the edited node while unrelated node/edge render counts remain unchanged through ACK');
await until(`window.__fixtureActualEngine?.projectId===${JSON.stringify(extensionRoot)}||window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(extensionRoot)}`,'extension engine unavailable');
// 完整 scene rebuild 与逐节点 projection 分开计数;viewport/tool/mode 来回变化不应触发整图重建。
const beforeScene=await evaluate('window.__fixtureSceneBuilds');assert.ok(beforeScene>0);
await evaluate(`(()=>{const e=window.__fixtureActualEngine,v=[...e.sessions.getSnapshot().views.values()][0];window.__fixtureViewBefore={id:v.id,viewport:v.viewport,tool:v.tool};e.sessions.viewport(v.id,{x:80,y:45,zoom:1.2});e.sessions.tool(v.id,'pan');e.sessions.mode(v.id,'meter-one','projection-check');})()`);
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');await pause(100);
@@ -572,23 +616,31 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');assert.equal(await evaluate('window.__fixtureSceneBuilds'),beforeScene);
console.log('PASS actual CanvasView viewport/tool/mode transitions reuse the scene without projection traversal');
// 插件按钮的 metric/raw 两次修改属于同一事务历史;undo/redo/reload 均通过公开命令和存储恢复。
await click('撤销');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==3;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,3);assert.deepEqual(documents.get(extensionRoot).elements.get('meter-one').data.raw,{retained:true});await click('重做');for(let i=0;i<100&&documents.get(extensionRoot).elements.get('meter-one').data.metric!==4;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.metric,4);await call('Page.reload');await until(`document.querySelector('[data-id="meter-one"] [data-renderer-fixture="meter"]')?.textContent.includes('Metric 4')&&!!document.querySelector('[data-renderer-fixture="metric-edge"]')`,'registered renderer edits did not recover');
console.log('PASS Host-installed custom model/node/edge definitions render without core type switches; command-backed edits preserve raw fields and recover through undo/redo/reload');
// 无 rendered target 的旧 editing facade 必须无效,不能仅凭元素 ID 越过作用域校验。
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,before=engine.getSnapshot();window.__fixtureUnbound['meter-one'].rename('meter-one','unbound callback');return engine.getSnapshot()===before;})()`),true,'headless legacy editing without a rendered target was allowed');
// 目标按钮先 preventDefault 的 Escape 保留手势;后续另测 shadow textarea、select 和未消费的外部 Escape。
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),button=document.createElement('button');document.querySelector('[data-headless-canvas]').append(button);button.addEventListener('keydown',event=>event.preventDefault());engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const token=engine.sessions.getSnapshot().gesture?.token;button.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true}));const retained=!!token&&engine.sessions.getSnapshot().gesture?.token===token;button.remove();engine.sessions.cancel('fixture_cleanup');return retained;})()`),true,'capture-phase Escape canceled a gesture before the target could claim it');
assert.deepEqual(await evaluate(`(()=>{const engine=window.__fixtureEngine,view=[...engine.sessions.getSnapshot().views.values()][0],record=engine.getSnapshot().document.elements.get('meter-one'),canvas=document.querySelector('[data-headless-canvas]'),host=document.createElement('div');canvas.append(host);const shadow=host.attachShadow({mode:'open'}),textarea=document.createElement('textarea');shadow.append(textarea);const select=document.createElement('select');canvas.append(select);const fire=element=>{const event=new KeyboardEvent('keydown',{key:'h',composed:true,bubbles:true,cancelable:true});element.dispatchEvent(event);return event.defaultPrevented;};const protectedKeys=[fire(textarea),fire(select)];host.remove();select.remove();engine.sessions.begin(view.id,[{id:record.id,incarnation:record.incarnation}]);const outside=document.createElement('button');document.body.append(outside);const event=new KeyboardEvent('keydown',{key:'Escape',bubbles:true,cancelable:true});outside.dispatchEvent(event);outside.remove();return{protectedKeys,tool:engine.sessions.getSnapshot().views.get(view.id).tool,canceled:!engine.sessions.getSnapshot().gesture,claimed:event.defaultPrevented};})()`),{protectedKeys:[false,false],tool:'select',canceled:true,claimed:true});
console.log('PASS keyboard targets claim Escape before canvas cancellation; native selects and shadow text retain their input, while unclaimed outside Escape cancels the owned gesture');
// 删除再 undo 后元素进入第二代;捕获的第一代旧 facade 必须拒绝编辑并保持 Root 引用不变。
const staleEdit=await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],target=engine.getSnapshot().document.elements.get('meter-one');window.__fixtureStaleLegacy=old;engine.editor.dispatch({type:'element.remove',target:{id:target.id,incarnation:target.incarnation}});engine.editor.history.undo();const restored=engine.getSnapshot();old.rename('meter-one','stale callback');return{unchanged:engine.getSnapshot()===restored,incarnation:restored.document.elements.get('meter-one').incarnation};})()`);assert.equal(staleEdit.incarnation,2);assert.equal(staleEdit.unchanged,true,'old rendered edit callback modified a restored incarnation');
console.log('PASS old rendered edit callbacks cannot modify a restored incarnation');
// 等待 renderer 绑定新身份后新 facade 才可写;旧目标 resize 清理也不能取消另一个目标的 mode。
await until(`window.__fixtureLegacy['meter-one']!==window.__fixtureStaleLegacy&&window.__fixtureEngine.getSnapshot().document.elements.get('meter-one').incarnation===2`,'restored renderer did not bind a fresh edit target');await evaluate(`window.__fixtureLegacy['meter-one'].rename('meter-one','fresh callback')`);for(let i=0;i<200&&documents.get(extensionRoot).elements.get('meter-one')?.data.title!=='fresh callback';i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.get('meter-one').data.title,'fresh callback');
assert.equal(await evaluate(`(()=>{const engine=window.__fixtureEngine,old=window.__fixtureLegacy['meter-one'],other=window.__fixtureLegacy['meter-two'];old.resize('meter-one');other.resize('meter-two');old.resize(null);const view=[...engine.sessions.getSnapshot().views.values()][0],retained=view.modes.get('meter-two')==='resize';other.resize(null);return retained;})()`),true,'old target mode cleanup canceled another target');
await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'extension state did not settle before insertion');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionBefore=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})'),extensionSize=documents.get(extensionRoot).elements.size;await click('添加文本卡片');
// 新增普通文本并等 ACK 后,三个未变化计量节点 projection 计数与完整 scene 计数应都不增加。
for(let i=0;i<200&&documents.get(extensionRoot).elements.size!==extensionSize+1;i++)await pause(20);assert.equal(documents.get(extensionRoot).elements.size,extensionSize+1);await until(`[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'inserted card did not synchronize');await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');const insertionAfter=await evaluate('({projects:{...window.__fixtureProjectedNodes},scenes:window.__fixtureSceneBuilds})');for(const id of ['meter-one','meter-two','meter-three'])assert.equal(insertionAfter.projects[id],insertionBefore.projects[id],'ordinary card insertion rebuilt '+id);assert.equal(insertionAfter.scenes,insertionBefore.scenes,'ordinary card insertion rebuilt the whole scene');console.log('PASS actual toolbar card insertion and ACK retain every unrelated node projection and avoid full scene traversal');
// 导航前保存旧 editing facade;视图 detach 后项目 engine 可保留,但旧视图回调不得再写。
await evaluate(`(()=>{window.__detachedEngine=window.__fixtureEngine;window.__detachedEditing=window.__fixtureLegacy['meter-one'];[...document.querySelectorAll('a')].find(link=>link.pathname.endsWith('/${other}')).click();})()`);await until(`location.pathname.endsWith('${other}')&&!!document.querySelector('[data-headless-canvas]')`,'renderer view did not detach on navigation');assert.equal(await evaluate(`(()=>{const before=window.__detachedEngine.getSnapshot();window.__detachedEditing.rename('meter-one','detached callback');return window.__detachedEngine.getSnapshot()===before;})()`),true,'detached renderer wrote into its retained project');
console.log('PASS restored renderers can edit with fresh bindings and detached views cannot write through old callbacks');
// 合法首元素后返回非法流尾,页面保留 partial editor 和可读错误;修复 fixture 后重新加载成为完整编辑器,全程无模型写入。
brokenStreamRoot=id(830);const brokenRoot=brokenStreamRoot;
documents.set(brokenRoot,createDocument(brokenRoot,[{id:brokenRoot,parent:null}],[{...node('prefix-visible',brokenRoot,80),data:{title:'Preserved prefix',text:'prefix-kept'}},node('tail-node',brokenRoot,450)]));versions.set(brokenRoot,1);boards.push({id:brokenRoot,workspace_id:workspace,kind:'board',version:1,title:'Interrupted board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${brokenRoot}`});
@@ -598,29 +650,37 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&document.querySelectorAll('.react-flow__node').length===2&&!document.querySelector('[role="alert"]')`,'retry did not replace the failed prefix with a complete editor');
console.log('PASS malformed model tail retains its legal partial editor, reports a readable error and retries into the complete editor without model writes');
// 首帧包含完整根 owner,却缺子 canvas 一项;下面检查根的编辑、创建、导入、复制和图片可先 ACK,不必等子尾部。
scopeStreamRoot=id(850);const scopedRoot=scopeStreamRoot,scopedChild=id(851),scopeFrame={...node('scope-frame',scopedRoot,450),componentId:'frame',geometry:{x:450,y:150,width:600,height:450,rotation:0}};
documents.set(scopedRoot,createDocument(scopedRoot,[{id:scopedRoot,parent:null},{id:scopedChild,parent:{canvasId:scopedRoot,entryElementId:scopeFrame.id}}],[node('scope-node',scopedRoot,80),scopeFrame,node('scope-child',scopedChild,20),node('scope-tail',scopedChild,320)]));versions.set(scopedRoot,1);versions.set(scopedChild,1);boards.push({id:scopedRoot,workspace_id:workspace,kind:'board',version:1,title:'Scope ACK board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${scopedRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="scope-node"]')`,'scope fixture did not show its certified owner');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
await until(`(()=>{const layer=document.querySelector('.react-flow__node[data-id="scope-node"] [aria-label^="单击选中"]');if(!layer)return false;const r=layer.getBoundingClientRect();for(const x of [.25,.5,.75,.9])for(const y of [.25,.5,.75]){const point={x:r.x+r.width*x,y:r.y+r.height*y};if(layer.contains(document.elementFromPoint(point.x,point.y))){window.__scopeEditPoint=point;return true;}}return false;})()`,'scope text remained covered by another surface');const scopePoint=await evaluate('window.__scopeEditPoint');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'certified scope text did not enter editing');await call('Input.insertText',{text:' ACK before tail'});const scopeText=await evaluate('document.activeElement.value');
// 完整根作用域先到版本 2,子作用域仍为 1 且 scope-tail 未显示,证明不是等整个项目完成才发送。
for(let i=0;i<150&&documents.get(scopedRoot).elements.get('scope-node').data.text!==scopeText;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get('scope-node').data.text,scopeText,'certified scope edit waited for another owner tail');assert.equal(versions.get(scopedRoot),2);assert.equal(versions.get(scopedChild),1);assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);assert.equal(await evaluate(`!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`),false);
assert.equal(await evaluate(`document.querySelector('button[aria-label="添加文本卡片"]').disabled`),false);await click('添加文本卡片');
// 来源未完整时新建卡片的 undo/redo 仍使用新 incarnation;根版本递增到 5,不能把出生与旧源清单混淆。
for(let i=0;i<150&&documents.get(scopedRoot).elements.size!==5;i++)await pause(20);const born=[...documents.get(scopedRoot).elements.values()].find(element=>!['scope-node','scope-frame','scope-child','scope-tail'].includes(element.id));assert.ok(born,'certified scope did not create a new card before the tail');await click('撤销');for(let i=0;i<150&&documents.get(scopedRoot).elements.has(born.id);i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.has(born.id),false);await click('重做');for(let i=0;i<150&&documents.get(scopedRoot).elements.get(born.id)?.incarnation!==2;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);assert.equal(versions.get(scopedRoot),5);
assert.equal(await evaluate(`document.querySelector('button[aria-label="导入画布记录"]').disabled`),false);
// 在认证根内从文件输入加入记录,再复制已闭合的 scope-node;每项都检查尾部到达前服务模型已增加。
await evaluate(`(()=>{const input=document.querySelector('input[data-canvas-import]'),transfer=new DataTransfer();transfer.items.add(new File([JSON.stringify({kind:'element',key:'partial-import',record:{id:'partial-import',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'Imported before tail',text:'streaming'},geometry:{x:0,y:0,width:100,height:80,rotation:0}}})+'\\n'],'partial.ndjson',{type:'application/x-ndjson'}));input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));return true;})()`);
for(let i=0;i<200&&![...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail');i++)await pause(20);assert.ok([...documents.get(scopedRoot).elements.values()].some(element=>element.data.title==='Imported before tail'),'partial page import did not reach server before tail');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:1});await until(`!document.querySelector('button[aria-label="深复制选中内容"]').disabled`,'closed source copy stayed disabled during loading');const copyCount=documents.get(scopedRoot).elements.size;await click('深复制选中内容');
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===copyCount;i++)await pause(20);assert.equal(documents.get(scopedRoot).elements.size,copyCount+1,'page copy did not publish before tail');await until(`!!document.querySelector('button[aria-label="深复制选中内容"]')`,'copy did not finish');assert.equal(await evaluate(`document.querySelector('button[aria-label="添加图片"]').disabled`),false);const beforeImage=documents.get(scopedRoot).elements.size,uploadsBefore=uploads.length;
await evaluate(`(async()=>{const canvas=new OffscreenCanvas(4,2);canvas.getContext('2d').fillRect(0,0,4,2);const bytes=await canvas.convertToBlob({type:'image/png'}),transfer=new DataTransfer();transfer.items.add(new File([bytes],'loading-pixel.png',{type:'image/png'}));const input=document.querySelector('[data-headless-canvas] input[type=file]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
// 真实 PNG 在同一认证根上传并自动定高 130,loading 标志仍在;保存此时版本供尾部合并检查。
for(let i=0;i<200&&documents.get(scopedRoot).elements.size===beforeImage;i++)await pause(20);const loadingImage=[...documents.get(scopedRoot).elements.values()].find(element=>element.componentId==='image');assert.ok(loadingImage,'scoped image waited for the unrelated tail');assert.equal(uploads.length,uploadsBefore+1);assert.equal(loadingImage.geometry.height,130,'scoped image lost default sizing');assert.equal(await evaluate(`!!document.querySelector('[data-headless-canvas][data-model-loading]')`),true);const scopeVersion=versions.get(scopedRoot);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...scopePoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...scopePoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="scope-node"] textarea')&&!document.activeElement.readOnly`,'original text focus did not return after structural history');
// 放行最初捕获尾部后保留同一 textarea/焦点/文字、第二代出生记录和已推进版本,不能重复发送旧来源。
await evaluate('window.__scopeTextarea=document.activeElement;true');releaseScopeStream();await until(`!!document.querySelector('[data-headless-canvas]:not([data-model-loading])')&&!!document.querySelector('.react-flow__node[data-id="scope-tail"]')`,'original stream did not complete after a scoped ACK');assert.equal(await evaluate('window.__scopeTextarea.isConnected&&document.activeElement===window.__scopeTextarea'),true);assert.equal(await evaluate('window.__scopeTextarea.value'),scopeText);assert.equal(versions.get(scopedRoot),scopeVersion,'old tail caused a duplicate write');assert.equal(documents.get(scopedRoot).elements.get(born.id)?.incarnation,2);console.log('PASS certified owner image/import/copy, edits and create/undo/redo ACK before another canvas tail, then original-source completion preserves content, focus, history and advanced versions');
// 另一个 partial 项目在未完整 owner 上有本地编辑;导航离开后由 project owner 采用新完整读取完成保存和同步。
abandonedRoot=id(840);const backgroundRoot=abandonedRoot;
documents.set(backgroundRoot,createDocument(backgroundRoot,[{id:backgroundRoot,parent:null}],[node('background-node',backgroundRoot,80),node('background-tail',backgroundRoot,450)]));versions.set(backgroundRoot,1);boards.push({id:backgroundRoot,workspace_id:workspace,kind:'board',version:1,title:'Background loading board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${backgroundRoot}`});await until(`!!document.querySelector('[data-headless-canvas][data-model-loading] .react-flow__node[data-id="background-node"]')`,'background fixture did not open the prefix');
// 先原生编辑前缀并断言无 POST,再导航另一项目;检查后台取得非 NDJSON 新完整读且晚到旧流不能改当前路由。
const backgroundPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="background-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+60,y:r.y+30};})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...backgroundPoint,button:'left',buttons:1,clickCount:2});await call('Input.dispatchMouseEvent',{type:'mouseReleased',...backgroundPoint,button:'left',buttons:0,clickCount:2});await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="background-node"] textarea')&&!document.activeElement.readOnly`,'background prefix did not enter editing');await call('Input.insertText',{text:' background-local'});const backgroundText=await evaluate('document.activeElement.value');
assert.equal(calls.filter(call=>call.method==='POST'&&call.path.includes('/'+backgroundRoot+'/canvas/model/operations')).length,0);
@@ -628,56 +688,70 @@ module.exports=function(source){return nativeTransform.call(this,source).replace
for(let i=0;i<150&&documents.get(backgroundRoot).elements.get('background-node').data.text!==backgroundText;i++)await pause(20);assert.equal(documents.get(backgroundRoot).elements.get('background-node').data.text,backgroundText,'leaving the view stranded a local edit behind the loading hold');assert.ok(calls.some(call=>call.method==='GET'&&call.path.endsWith('/'+backgroundRoot+'/canvas/model')&&call.headers.accept!=='application/vnd.foundry.canvas-model+ndjson;version=1'));
releaseAbandoned();await pause(100);assert.ok((await evaluate('location.pathname')).endsWith(other));console.log('PASS leaving a partial editor hands completion to the project owner, persists and synchronizes its edit through a fresh read without reopening the view');
// JSON 数组通过真实 file input 进入原生记录导入,既核对页面和服务内容,也在 reload 后检查来源文件名与作业。
await evaluate(`(()=>{const transfer=new DataTransfer(),record={kind:'element',key:'json-ui',record:{id:'json-ui',incarnation:1,ownerCanvasId:'$target',componentId:'text',dataVersion:1,structure:'node',data:{title:'JSON array UI',text:'JSON array UI text'},geometry:{x:80,y:100,width:240,height:160,rotation:0}}};transfer.items.add(new File([JSON.stringify([record])],'records.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelector('[aria-label="画布导入"]').textContent.includes('已加入画布')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')`,'JSON array did not render through the file picker');
for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI');i++)await pause(20);assert.ok([...documents.get(other).elements.values()].some(e=>e.data.title==='JSON array UI'));
await call('Page.reload');await until(`[...document.querySelectorAll('[data-headless-canvas] textarea')].some(input=>input.value==='JSON array UI text')&&document.querySelector('[aria-label="画布导入"]').textContent.includes('records.json')`,'JSON array task/content did not recover after reload');
console.log('PASS JSON array file picker imports and synchronizes native records then recovers its original task and content after reload');
// 临时拦截带 download 的 anchor,只读取 Blob 导出文本;随即恢复原 click,不真的发起下载。
await evaluate(`(()=>{const original=HTMLAnchorElement.prototype.click;HTMLAnchorElement.prototype.click=function(){if(this.download){window.__exportedDocument=fetch(this.href).then(response=>response.text());return;}return original.call(this);};document.querySelector('[aria-label="导出图谱"]').click();HTMLAnchorElement.prototype.click=original;})()`);
// 完整文档导出格式为 1,重导入应增加与源元素数相同的新元素;parserVersion=5 核对使用流式文档解释器。
const exported=await evaluate('window.__exportedDocument');assert.equal(JSON.parse(exported).documentFormatVersion,1);const beforeDocumentImport=documents.get(other).elements.size,roundTripElementCount=JSON.parse(exported).elements.length;
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(exported)}],'round-trip.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'exported document did not reimport');
for(let i=0;i<200&&documents.get(other).elements.size!==beforeDocumentImport+roundTripElementCount;i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeDocumentImport+roundTripElementCount);await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('round-trip.json')&&[...document.querySelectorAll('[data-headless-canvas] textarea')].filter(input=>input.value==='JSON array UI text').length===2`,'document round trip task/content did not recover');
assert.equal(await evaluate(`(()=>{const job=[...window.__fixtureActualEngine.imports.getJobs().values()].find(job=>job.source?.name==='round-trip.json');return job?.parserVersion;})()`),5,'document picker did not recover the streaming interpreter');
console.log('PASS actual toolbar JSON export reimports through file picker with fresh elements and recovers after reload');
// 文档图片故意带现有 assetId 与不可信旧 src,导入必须重新授权并重建显示引用,不重复上传。
const importedAssetId=[...assets.keys()][0],uploadsBeforeDocumentImage=uploads.length,imageImportDocument={documentFormatVersion:1,projectId:'image-source',canvases:[{id:'image-source',parent:null}],elements:[{id:'image-from-file',incarnation:1,ownerCanvasId:'image-source',componentId:'image',dataVersion:1,structure:'node',geometry:{x:400,y:100,width:200,height:100,rotation:0},data:{title:'Document imported image',text:'',assetId:importedAssetId,src:'https://untrusted.test/stale'}}],tombstones:{}};
const beforeImageImport=documents.get(other).elements.size;
await evaluate(`(()=>{const transfer=new DataTransfer();transfer.items.add(new File([${JSON.stringify(JSON.stringify(imageImportDocument))}],'image-document.json',{type:'application/json'}));const input=document.querySelector('input[data-canvas-import]');input.files=transfer.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
for(let i=0;i<200&&![...documents.get(other).elements.values()].some(e=>e.data.title==='Document imported image');i++)await pause(20);assert.equal(documents.get(other).elements.size,beforeImageImport+1);const importedImage=[...documents.get(other).elements.values()].find(e=>e.data.title==='Document imported image');assert.equal(importedImage.data.assetId,importedAssetId);assert.notEqual(importedImage.data.src,'https://untrusted.test/stale');assert.equal(uploads.length,uploadsBeforeDocumentImage);
await call('Page.reload');await until(`document.querySelector('[aria-label="画布导入"]')?.textContent.includes('image-document.json')&&!!document.querySelector('.react-flow__node[data-id="${importedImage.id}"] img')`,'authorized document image did not recover after reload');assert.equal(uploads.length,uploadsBeforeDocumentImage);
console.log('PASS document image import reauthorizes existing resource, ignores stale URL and recovers without reuploading');
// 分别控制 parser I/O 与全源摘要:先挂住摘要 Worker 结果,让解析完成和来源验证可以独立观察。
await evaluate(`(()=>{window.__fixtureDigestWaiting=false;window.__fixtureDigestGate=new Promise(resolve=>{window.__releaseSourceDigest=()=>{delete window.__fixtureDigestGate;resolve();};});})()`);
// GatedDocument.slice 只延迟 16KiB 解析读取且偏移至少 32KiB 的尾部;首项可先入画布,摘要由另一 gate 控制。
await evaluate(`(()=>{const engine=window.__fixtureActualEngine,view=[...engine.sessions.getSnapshot().views.values()][0];let release;const gate=new Promise(resolve=>release=resolve);window.__releaseDocumentTail=()=>{window.__documentTailReleased=true;release();};class GatedDocument extends Blob{slice(start=0,end=this.size,...rest){const part=super.slice(start,end,...rest);if(end-start!==16384||start<32768||window.__documentTailReleased)return part;return{async arrayBuffer(){window.__documentTailWaiting=true;await gate;return part.arrayBuffer();}};}}const element=(id,text)=>({id,incarnation:1,structure:'node',ownerCanvasId:'stream-source',componentId:'text',dataVersion:1,data:{title:id,text},geometry:{x:0,y:0,width:260,height:200,rotation:0}}),input={documentFormatVersion:1,projectId:'stream-source',canvases:[{id:'stream-source',parent:null}],elements:[element('dom-prefix','Document progressive prefix'),element('dom-tail','x'.repeat(100000))],tombstones:{}};window.__streamDocumentJob=engine.imports.startDocument(new GatedDocument([JSON.stringify(input)]),view.canvasId);})()`);
// 确认 parser 尾部实际等待且未放开时,第一项 textarea 已挂载;随后原生输入必须能修改该前缀。
await until(`(()=>{const job=window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob),id=job.stableIds['element:dom-prefix'];window.__streamDocumentFirst=id;return window.__documentTailWaiting&&!window.__documentTailReleased&&!!document.querySelector('.react-flow__node[data-id="'+id+'"] textarea');})()`,'document prefix did not display while parser tail was blocked');
await evaluate(`(()=>{const node=document.querySelector('.react-flow__node[data-id="'+window.__streamDocumentFirst+'"]');node.dispatchEvent(new MouseEvent('click',{bubbles:true}));node.querySelector('[aria-label^="单击选中"]').dispatchEvent(new MouseEvent('dblclick',{bubbles:true}));})()`);await until(`document.activeElement===document.querySelector('.react-flow__node[data-id="'+window.__streamDocumentFirst+'"] textarea')&&!document.activeElement.readOnly`,'progressive document prefix did not accept editing');await call('Input.insertText',{text:' edited before tail'});assert.equal(await evaluate(`window.__fixtureActualEngine.getSnapshot().document.elements.get(window.__streamDocumentFirst).data.text.endsWith('edited before tail')`),true);assert.equal(await evaluate('window.__documentTailReleased===true'),false);await evaluate('window.__releaseDocumentTail()');
// parser completed 时 sha256 仍缺失且服务还无首项;释放摘要后才同步,并保留摘要/尾部等待期间的用户编辑。
await until(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).status==='completed'&&window.__fixtureDigestWaiting`,'streaming document did not complete parsing while full SHA was blocked');const streamFirstId=await evaluate('window.__streamDocumentFirst');assert.equal(await evaluate(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).source.sha256`),undefined);assert.equal(documents.get(other).elements.has(streamFirstId),false,'unverified source reached backend');await evaluate('window.__releaseSourceDigest()');for(let i=0;i<200&&!documents.get(other).elements.get(streamFirstId)?.data.text.endsWith('edited before tail');i++)await pause(20);assert.ok(documents.get(other).elements.get(streamFirstId).data.text.endsWith('edited before tail'));assert.equal(await evaluate(`window.__fixtureActualEngine.imports.getJobs().get(window.__streamDocumentJob).committedSourceKeys.length`),2);
console.log('PASS actual document prefix displays and accepts native text edits before full SHA; parser completion cannot sync until verification, then preserves those edits');
// 两端节点矩形在视口外,但自定义曲线弯入视口;不能只用端点矩形决定把可见边和必要 owner 裁掉。
const bowRoot=id(75);versions.set(bowRoot,1);boards.push({id:bowRoot,workspace_id:workspace,kind:'board',version:1,title:'Bowed paint board'});documents.set(bowRoot,createDocument(bowRoot,[{id:bowRoot,parent:null}],[{...node('bow-source',bowRoot,0),geometry:{x:0,y:2000,width:260,height:200,rotation:0}},{...node('bow-target',bowRoot,600),geometry:{x:600,y:2000,width:260,height:200,rotation:0}},{id:'bow-edge',incarnation:1,ownerCanvasId:bowRoot,structure:'connection',componentId:'connection',dataVersion:1,data:{},source:{elementId:'bow-source'},target:{elementId:'bow-target'},path:{type:'bezier-chain',knots:[{id:'bow-knot',mode:'corner',position:{x:400,y:0},in:{x:-100,y:0},out:{x:100,y:0}}]}}]));
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${bowRoot}`});await until(`!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path')`,'bowed curve fixture did not open');
await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),r=pane.getBoundingClientRect(),m=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(400*m.a+m.e-r.width/2)/.5,deltaY:(m.f-r.height/2)/.5,bubbles:true,cancelable:true}));})()`);
await until(`(()=>{const viewport=document.querySelector('.react-flow__pane').getBoundingClientRect(),nodes=[...document.querySelectorAll('.react-flow__node')];return nodes.length===2&&nodes.every(node=>{const r=node.getBoundingClientRect();return r.bottom<viewport.top||r.top>viewport.bottom;})&&!!document.querySelector('.react-flow__edge[data-id="bow-edge"] .react-flow__edge-path');})()`,'offscreen endpoints caused visible bowed paint to unmount');
assert.equal(documents.get(bowRoot).elements.size,3);console.log('PASS bowed curve paint remains mounted with both endpoint rectangles outside the viewport');
// 嵌套节点的拖动预览绑定起点坐标上下文;外部移动其 ancestor 时必须取消该预览。
const contextRoot=id(852),contextChild=id(853),contextFrame={...node('context-frame',contextRoot,400),componentId:'frame',geometry:{x:400,y:100,width:640,height:400,rotation:0}},contextNode={...node('context-node',contextChild,50),geometry:{x:50,y:60,width:260,height:200,rotation:0}};
documents.set(contextRoot,createDocument(contextRoot,[{id:contextRoot,parent:null},{id:contextChild,parent:{canvasId:contextRoot,entryElementId:contextFrame.id}}],[contextFrame,contextNode]));versions.set(contextRoot,1);versions.set(contextChild,1);boards.push({id:contextRoot,workspace_id:workspace,kind:'board',version:1,title:'Gesture context board'});
await call('Page.navigate',{url:`http://127.0.0.1:${port}/dashboard/${workspace}/${contextRoot}`});await until(`!!document.querySelector('.react-flow__node[data-id="context-node"]')&&[...document.querySelectorAll('[role="status"]')].some(item=>item.textContent.trim()==='已同步')`,'nested gesture context board did not settle');
await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');
// 先 elementFromPoint 证实子节点可点,原生按下/移动后等待实际手势 token,再通过公开命令移动父 frame。
const contextPoint=await evaluate(`(()=>{const r=document.querySelector('.react-flow__node[data-id="context-node"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:r.x+r.width*.3,y:r.y+Math.min(20,r.height*.2)}})()`);
assert.equal(await evaluate(`document.elementFromPoint(${contextPoint.x},${contextPoint.y})?.closest('.react-flow__node')?.getAttribute('data-id')`),'context-node','nested test pointer is covered by another surface');
await call('Input.dispatchMouseEvent',{type:'mousePressed',...contextPoint,button:'left',buttons:1,clickCount:1});await call('Input.dispatchMouseEvent',{type:'mouseMoved',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:1});
await until(`window.__fixtureActualEngine?.getSnapshot().document.projectId===${JSON.stringify(contextRoot)}&&window.__fixtureActualEngine.sessions.getSnapshot().gesture?.targets.some(target=>target.id==='context-node')`,'nested node did not acquire its drag');
assert.equal(await evaluate(`window.__fixtureActualEngine.editor.dispatch({type:'element.geometry',target:{id:'context-frame',incarnation:1},patch:{x:600}}).status`),'applied');
// 父变换发布后预览应清空;迟到 mouseReleased 不得写子局部几何,页面改按新父位置投影。
assert.equal(await evaluate('window.__fixtureActualEngine.sessions.getSnapshot().gesture'),null,'ancestor edit retained the old coordinate preview');
await call('Input.dispatchMouseEvent',{type:'mouseReleased',x:contextPoint.x+35,y:contextPoint.y+25,button:'left',buttons:0});
for(let i=0;i<100&&documents.get(contextRoot).elements.get('context-frame').geometry.x!==600;i++)await pause(20);
assert.equal(documents.get(contextRoot).elements.get('context-frame').geometry.x,600);assert.deepEqual(documents.get(contextRoot).elements.get('context-node').geometry,contextNode.geometry);
await until(`(()=>{const node=document.querySelector('.react-flow__node[data-id="context-node"]');return node&&Math.abs(new DOMMatrix(node.style.transform).m41-674)<.01&&!node.classList.contains('dragging');})()`,'nested node did not return to its new ancestor projection');
// 检查服务收到的 changes 只涉及父移动,不含 context-node,补足单看 DOM 复位无法证明的“未写旧坐标”。
const contextWrites=calls.filter(call=>call.method==='POST'&&call.path.includes('/'+contextRoot+'/canvas/model/operations')).flatMap(call=>JSON.parse(call.bytes.toString()).operations.flatMap(operation=>operation.changes));assert.ok(contextWrites.length);assert.equal(contextWrites.some(change=>change.target?.id==='context-node'),false,'late nested drag wrote the old coordinate result');
console.log('PASS ancestor movement cancels the actual nested drag; late pointerup preserves child geometry and renders its new parent position');
// 最后走实际账户菜单退出登录,要求安全到 /login 且无 render error;另核对至少有一次真实代理 NDJSON 协商。
await click('账户和工作空间');await until(`!!document.querySelector('#workspace-account:popover-open')`,'account menu did not open');await evaluate(`[...document.querySelectorAll('button')].find(button=>button.textContent.trim()==='退出登录').click()`);
await until(`location.pathname==='/login'&&!document.body.innerText.includes('This page couldn’t load')`,'logout did not safely detach the view');
assert.ok(calls.some(call=>call.path.endsWith('/canvas/model')&&call.headers.accept==='application/vnd.foundry.canvas-model+ndjson;version=1'));