/** * 实际 GraphPage/ReactFlow 页面与 IndexedDB 的输入、保留和深复制探针,HTTP 后端由 fixture 提供。 * 调用者注入 CDP、页面 evaluate、服务文档及报告路径;本文件不独立启动浏览器或构建项目。 * 将场景断言与耗时/堆采样分别报告:计时是本次环境的观测,不构成通用 SLA;内嵌页面脚本保持测试输入原字节。 */ import assert from 'node:assert/strict'; import {editDuringCopy} from './renderer-copy-input.mjs'; import {writeFile} from 'node:fs/promises'; import {setTimeout as pause} from 'node:timers/promises'; import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts'; // 依次验证原生编辑、离屏输入保留、宿主面板、portal 和大分区复制,再写报告与成功截图。 export async function measureRenderer({call,evaluate,documents,calls=[],root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',bundler='webpack',buildMs}){ // 保存测量墙上起点、堆样本及复制状态;target 用于离屏裁剪断言,不是最终服务端元素总数。 const target=2*count+3,started=Date.now(),heapSamples=[];let copying=false,nextHeap=0; // 每次条件轮询有四分钟墙上截止并检查页面异常;复制中可每十秒取堆与回执保留快照,写 .heap.json。单次 await 仍依赖注入调用器返回。 const wait=async(expression,label)=>{const deadline=Date.now()+240000;while(Date.now()=nextHeap){const heap=await call('Runtime.getHeapUsage'),retention=await evaluate('window.__fixtureReceiptRetention');heapSamples.push({wallMs:Date.now()-started,serverElements:documents.get(root)?.elements.size,heap,retention});nextHeap=Date.now()+10000;await writeFile(reportPath+'.heap.json',JSON.stringify({baseline,heapSamples},null,2));}if(await evaluate(expression))return;await pause(100);}throw Error(label);}; let report,duringCopyInput; // 环境开关决定是否在复制期间额外输入;默认仅测复制,报告 scenario 据此区分。 const concurrentInput=process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'; try{ // 等真实 frame 与至少两个节点挂载;未选择时不应保留空节点工具栏订阅。 await wait(`!!document.querySelector('.react-flow__node[data-id="perf-frame"]')&&document.querySelectorAll('.react-flow__node').length>=2`,'initial large canvas did not render'); assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),0,'unselected nodes retained empty toolbar subscriptions'); // 读取页面 performance.now,是相对页面时间原点的就绪观测,不是函数 Date.now 起点的独立渲染耗时。 const initialReadyMs=await evaluate('performance.now()');console.log('PERF initial scene ready '+Math.round(initialReadyMs)+' ms'); // 使用原生输入进入编辑;仅脚本 focus 不会覆盖真实编辑模式切换。 await wait(`!!document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]')`,'first visible text card missing'); // 记录双击前后 DOM 数量,用于断言进入单卡编辑不会把离屏全集挂载。 const beforeEditing=await evaluate('document.querySelectorAll(".react-flow__node").length'); const editPoint=await evaluate(`(()=>{const rect=document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]').getBoundingClientRect();return{x:rect.x+60,y:rect.y+30};})()`); // 经 CDP 发送原生双击,随后验证 activeElement 为可写 textarea,避免只用脚本 focus 绕过编辑模式。 await call('Input.dispatchMouseEvent',{type:'mousePressed',...editPoint,button:'left',buttons:1,clickCount:2}); await call('Input.dispatchMouseEvent',{type:'mouseReleased',...editPoint,button:'left',buttons:0,clickCount:2}); await wait(`document.activeElement===document.querySelector('.react-flow__node[data-id="perf-0"] textarea')&&!document.activeElement.readOnly`,'native text editing did not start'); await evaluate('window.__performanceEditor=document.activeElement;new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); const duringEditing=await evaluate('document.querySelectorAll(".react-flow__node").length'); if(count>=100)assert.ok(duringEditing<=beforeEditing+10,'editing one visible card mounted the offscreen canvas'); // 输入后同一 DOM、焦点和文本都要保留,再开启 IME 并将目标平移到屏幕外。 await call('Input.insertText',{text:'Native editing remains responsive'}); assert.equal(await evaluate('window.__performanceEditor.isConnected&&document.activeElement===window.__performanceEditor&&window.__performanceEditor.value.includes("Native editing remains responsive")'),true,'native input lost its DOM, focus or value'); await evaluate('window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector(".react-flow__viewport")).transform);true'); // 先建立组合输入再平移;目标确实离屏且仍保留输入 DOM,正常规模下不能因保留一个 editor 挂载全集。 await call('Input.imeSetComposition',{text:'保',selectionStart:1,selectionEnd:1}); await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); assert.equal(await evaluate('window.__performanceEditor.isConnected&&document.activeElement===window.__performanceEditor&&window.__performanceEditor.value.includes("保")'),true,'offscreen pan discarded native IME'); assert.equal(await evaluate('(()=>{const r=window.__performanceEditor.getBoundingClientRect();return r.right<0||r.bottom<0||r.left>innerWidth||r.top>innerHeight;})()'),true,'IME probe did not move its owner offscreen'); const offscreenEditing=await evaluate('document.querySelectorAll(".react-flow__node").length'); if(count>=100)assert.ok(offscreenEditing<=10,'retaining one offscreen editor mounted the complete canvas'); // 离屏提交组合文本后恢复 viewport 并 blur,等同步完成再开始复制计时,排除上一轮未保存输入干扰。 await call('Input.insertText',{text:'保留输入'}); assert.equal(await evaluate('window.__performanceEditor.value.endsWith("保留输入")'),true,'offscreen IME commit lost content'); await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;})()`); await evaluate('document.activeElement.blur();delete window.__performanceEditor;new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'native input did not synchronize before copy measurement'); const inputCheck={beforeEditing,duringEditing,offscreenEditing}; console.log('PERF native editing '+JSON.stringify(inputCheck)); // 记录 AI 任务 POST 基数;修改面板设置或复制只能改配置/文档,不应启动生成。 const runStartsBefore=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length; await wait(`!!document.querySelector('[aria-label="AI 创作"]')&&!document.querySelector('[aria-label="AI 创作"]').disabled`,'AI panel action did not become available'); await evaluate(`document.querySelector('[aria-label="AI 创作"]').click()`); await wait(`document.querySelector('[aria-label="AI 模型"]')?.value==='fixture-text'`,'AI model did not load'); await evaluate(`(()=>{window.__performancePanel=document.querySelector('[aria-label="AI 指令"]');window.__performancePanel.focus();window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);})()`); await call('Input.insertText',{text:'Stable host panel draft'}); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); // 宿主 AI 面板与节点编辑不同:面板持有草稿不应额外保留所有节点。 inputCheck.panelFocusedNodes=await evaluate('document.querySelectorAll(".react-flow__node").length'); if(count>=100)assert.ok(inputCheck.panelFocusedNodes<=beforeEditing+10,'host-panel focus mounted the complete canvas'); await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); // 平移后面板 DOM、焦点和草稿都保留,但画布离屏节点应归零。 assert.equal(await evaluate('window.__performancePanel.isConnected&&document.activeElement===window.__performancePanel&&window.__performancePanel.value==="Stable host panel draft"'),true,'independent panel lost its focus or draft during pan'); inputCheck.offscreenPanel=await evaluate('document.querySelectorAll(".react-flow__node").length'); assert.equal(inputCheck.offscreenPanel,0,'host-panel input pinned unrelated offscreen nodes'); await evaluate('window.__performancePanel.blur();new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); await wait(`[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'AI settings did not save before copy'); // blur 后从 fixture 服务文档核对配置已经保存,再恢复视口并关闭面板。 assert.equal(documents.get(root).elements.get('perf-0').data.ai?.prompt,'Stable host panel draft','host-panel blur lost saved settings'); await evaluate(`(()=>{document.querySelector('[aria-label="关闭 AI 工具"]').click();const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__performancePanel;delete window.__editViewport;})()`); assert.equal(calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length,runStartsBefore,'editing settings started a generation task'); // 通过 elementFromPoint 搜索真正空白 pane 坐标并原生点击,清空之前编辑选择,避免污染 frame 工具栏计数。 const panePoint=await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect();for(let y=rect.y+5;yrequestAnimationFrame(()=>requestAnimationFrame(resolve)))`); assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),1,'selected frame did not own exactly one toolbar'); await wait(`!!document.querySelector('[aria-label="分区名称"]')`,'selected frame did not expose its portal title'); await evaluate(`(()=>{window.__performancePortal=document.querySelector('[aria-label="分区名称"]');window.__performancePortal.focus();window.__performancePortal.select();window.__editViewport=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);})()`); await call('Input.insertText',{text:'Retained portal title'}); await evaluate(`document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:20000,deltaY:20000,bubbles:true,cancelable:true}))`); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); assert.equal(await evaluate('window.__performancePortal.isConnected&&document.activeElement===window.__performancePortal&&window.__performancePortal.value==="Retained portal title"'),true,'portal title draft was lost during offscreen pan'); // 分区标题 portal 离屏时只保留必要 owner;blur 后应释放全部离屏节点,回到原视口再确认标题保存及重挂载。 inputCheck.offscreenPortal=await evaluate('document.querySelectorAll(".react-flow__node").length'); if(count>=100)assert.ok(inputCheck.offscreenPortal<=10,'one portal draft mounted the complete canvas'); await evaluate('window.__performancePortal.blur();true'); await wait('document.querySelectorAll(".react-flow__node").length===0','released portal retained offscreen owners'); await evaluate(`(()=>{const now=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform),before=window.__editViewport;document.querySelector('.react-flow__pane').dispatchEvent(new WheelEvent('wheel',{deltaX:(now.e-before.e)/.5,deltaY:(now.f-before.f)/.5,bubbles:true,cancelable:true}));delete window.__editViewport;delete window.__performancePortal;})()`); await wait(`document.querySelector('[aria-label="分区名称"]')?.value==='Retained portal title'&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'portal title did not save and remount'); // 捕获复制开始前的原文,用来证明并发输入不进入已经捕获的副本。 const sourceText=documents.get(root).elements.get('perf-0').data.text; // 页面脚本以 rAF 采样新 DOM、进度完成、节点峰值、帧间隔,并在支持时观察 longtask;tick 反复采样,read 汇总,stop 取消并断开 observer。 // maxMountedNodes 仅是采样帧看到的最大值;prepareMs 是从采样开始到准备提示消失的时间,未见提示时为 null,不是准备阶段独立耗时。 await evaluate(`(()=>{ const originals=new Set(['perf-source','perf-frame',...Array.from({length:${count}},(_,i)=>'perf-'+i)]),start=performance.now(),tasks=[],gaps=[]; let raf,last=start,sawPreparing=false,firstDOM=null,firstFrame=null,localComplete=null,localCompleteFrame=null,prepared=null,maxNodes=0; const supported=PerformanceObserver.supportedEntryTypes.includes('longtask'),observer=supported?new PerformanceObserver(list=>tasks.push(...list.getEntries().map(entry=>({start:entry.startTime-start,duration:entry.duration})))):null; observer?.observe({type:'longtask'}); function tick(now){gaps.push(now-last);last=now;const mounted=[...document.querySelectorAll('.react-flow__node')];maxNodes=Math.max(maxNodes,mounted.length);if(mounted.some(node=>!originals.has(node.dataset.id))){if(firstDOM===null)firstDOM=now-start;else if(firstFrame===null)firstFrame=now-start;}if(document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')){if(localComplete===null)localComplete=now-start;else if(localCompleteFrame===null)localCompleteFrame=now-start;}if(document.querySelector('[aria-label="正在准备复制"]'))sawPreparing=true;else if(sawPreparing&&prepared===null)prepared=now-start;raf=requestAnimationFrame(tick);} raf=requestAnimationFrame(tick); window.__canvasPerformance={read(){const sorted=[...gaps].sort((a,b)=>a-b),p95=sorted[Math.max(0,Math.ceil(sorted.length*.95)-1)]??0;return{frameGapP95Ms:p95,elapsedMs:performance.now()-start,firstDOMMs:firstDOM,firstPresentedFrameMs:firstFrame,localCompleteMs:localComplete,localCompleteFrameMs:localCompleteFrame,prepareMs:prepared,maxMountedNodes:maxNodes,nodeCount:document.querySelectorAll('.react-flow__node').length,longTasksSupported:supported,longTaskCount:tasks.length,maxLongTaskMs:Math.max(0,...tasks.map(task=>task.duration)),totalLongTaskMs:tasks.reduce((total,task)=>total+task.duration,0),maxFrameGapMs:Math.max(0,...gaps),longTasks:tasks};},stop(){cancelAnimationFrame(raf);if(observer){tasks.push(...observer.takeRecords().map(entry=>({start:entry.startTime-start,duration:entry.duration})));observer.disconnect();}return this.read();}}; document.querySelector('[aria-label="深复制选中内容"]').click(); })()`); // 页面脚本安装采样后点击复制;Node 侧此后才启用复制期间的可选堆采样。 copying=true;console.log('PERF copy requested'); // 可选探针通过原生输入继续编辑源卡,校验完整文字增量;副本随后仍须保留复制起点文本。 if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+(count>=10000?'复制期间输入 A B C M T':'复制期间输入 A B C'),'concurrent typing lost pre-existing source text');console.log('PERF concurrent input '+JSON.stringify(duringCopyInput));} await wait(`document.querySelectorAll('.react-flow__node').length>0&&document.querySelector('[aria-label="画布导入"]')?.textContent.includes('已加入画布')&&[...document.querySelectorAll('[role=status]')].some(item=>item.textContent.trim()==='已同步')`,'large copy did not finish rendering and synchronization'); await evaluate('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))'); // 完成进度与已同步后再等两帧,要求 renderer 测量出现合批;统计批量大小而非只计渲染总数。 const internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch'); // 原文档有 count 张卡片、source、frame 和入边;副本增加 count 张卡片、frame′ 和新入边,因此总元素为 2*count+5。 // 进度 count+3 还计入新子 canvas,canvas 记录不属于 document.elements,不能拿进度数直接推元素总量。 // 停止页面采样并验证服务元素数、复制 canvas 和上级连线。firstPresentedFrame 是检测新 DOM 后的下一次 rAF 近似值,不是 compositor 上屏证明。 const measurements=await evaluate('window.__canvasPerformance.stop()'),document=documents.get(root),copiedCanvas=[...document.canvases.values()].find(canvas=>canvas.parent&&canvas.id!==partition); assert.equal(document.elements.size,2*count+5);assert.ok(copiedCanvas);assert.ok([...document.elements.values()].some(element=>element.structure==='connection'&&element.source.elementId==='perf-source'&&element.target.elementId===copiedCanvas.parent.entryElementId));assert.ok(document.elements.has('perf-incoming'));assert.ok(measurements.firstPresentedFrameMs!==null&&measurements.localCompleteFrameMs!==null);if(count>=100)assert.ok(measurements.nodeCountelement.structure==='node'&&element.ownerCanvasId===copiedCanvas.id&&element.geometry.x===0&&element.geometry.y===0); assert.equal(copiedFirst?.data.text,sourceText,'copy snapshot absorbed a later original edit'); assert.equal(document.elements.get('perf-0').data.text,duringCopyInput.value,'ACK overwrote concurrent original text'); } // 再次核对整个配置/复制场景没有启动 AI 任务;生成计费不属于深复制的副作用。 const runStartsDuringScenario=calls.filter(call=>call.method==='POST'&&call.path.endsWith('/ai/runs')).length-runStartsBefore; assert.equal(runStartsDuringScenario,0,'deep copy or settings unexpectedly started generation'); // 离屏源仍需保留两条可见分区连线,随后把最后一个副本节点平移到屏幕内,检查实际挂载。 const visibleEdges=await evaluate('document.querySelectorAll(".react-flow__edge").length');assert.equal(visibleEdges,2,'frame connections did not render for the offscreen source'); // 进度完成只证明模型/作业状态;还要平移后看到副本卡片实际挂载。 // 按原节点局部位置找到复制后的末项,而非只依赖进度或服务记录宣称渲染完成。 const originalLast=document.elements.get(`perf-${count-1}`),copiedLast=[...document.elements.values()].find(element=>element.structure==='node'&&element.ownerCanvasId===copiedCanvas.id&&element.geometry.x===originalLast.geometry.x&&element.geometry.y===originalLast.geometry.y);assert.ok(copiedLast); // 从副本 owner 的局部坐标换算世界中心,给页面 viewport 平移定位使用。 const world=toWorld(canvasTransform(document,copiedLast.ownerCanvasId),{x:copiedLast.geometry.x+copiedLast.geometry.width/2,y:copiedLast.geometry.y+copiedLast.geometry.height/2}); await evaluate(`(()=>{const pane=document.querySelector('.react-flow__pane'),rect=pane.getBoundingClientRect(),matrix=new DOMMatrix(getComputedStyle(document.querySelector('.react-flow__viewport')).transform);pane.dispatchEvent(new WheelEvent('wheel',{deltaX:(${world.x}*matrix.a+matrix.e-rect.width/2)/.5,deltaY:(${world.y}*matrix.a+matrix.f-rect.height/2)/.5,bubbles:true,cancelable:true}));})()`); await wait(`!!document.querySelector('.react-flow__node[data-id="${copiedLast.id}"] textarea')`,'offscreen copied card did not mount after viewport pan'); // 记录平移后的实际挂载数量,和之前的可见连线数一起写入报告。 const viewportCheck={visibleEdgesBeforePan:visibleEdges,copiedId:copiedLast.id,mountedAfterPan:await evaluate('document.querySelectorAll(".react-flow__node").length')}; // 报告同时保存输入断言上下文、浏览器/构建模式、采样、fixture 文档计数与测量批次,便于跨环境比较。 report={inputCheck,duringCopyInput,runStartsDuringScenario,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} ${bundler}; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks`,count,initialReadyMs,buildMs,internals:{batches:internalsBatches.length,maxBatch:Math.max(0,...internalsBatches),totalRequests:internalsBatches.reduce((sum,size)=>sum+size,0)},measurements,viewportCheck,browser:await call('Browser.getVersion'),serverElements:document.elements.size,serverCanvases:document.canvases.size,wallMs:Date.now()-started}; // 仅成功路径截图,输出到报告同名 PNG;截图是场景结束视口,不是每个计时点的屏幕证据。 const screenshot=await call('Page.captureScreenshot',{format:'png'});await writeFile(reportPath.endsWith('.json')?reportPath.slice(0,-5)+'.png':reportPath+'.png',Buffer.from(screenshot.data,'base64')); // 失败也尽力停止页面采样并写诊断报告,然后重抛;不能把部分观测输出当作场景通过。 }catch(error){let measurements;try{measurements=await evaluate('window.__canvasPerformance?.stop()');}catch{}report={duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',count,buildMode,buildMs,error:error.message,measurements,serverElements:documents.get(root)?.elements.size,serverCanvases:documents.get(root)?.canvases.size,wallMs:Date.now()-started};await writeFile(reportPath,JSON.stringify(report,null,2));throw error;} // 成功报告落盘后打印紧凑摘要,长任务明细仍保存在 JSON 文件。 await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath); }