From 26d1688f2f80c39e370720965fbeba329575ca8a Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 11 Nov 2026 01:33:54 +0800 Subject: [PATCH] Verify native input and snapshot isolation during progressive copy --- headless/README.md | 13 +++++++ tests/headless/renderer-copy-input.mjs | 52 +++++++++++++++++++++++++ tests/headless/renderer-performance.mjs | 15 +++++-- tests/headless/renderer.integration.mjs | 1 + 4 files changed, 78 insertions(+), 3 deletions(-) create mode 100644 tests/headless/renderer-copy-input.mjs diff --git a/headless/README.md b/headless/README.md index c39f06f..99b4024 100644 --- a/headless/README.md +++ b/headless/README.md @@ -1015,3 +1015,16 @@ Chrome input, checks mounted-node growth, inserts text and checks DOM identity, focus and content before measuring deep copy. It then clears selection with a native empty-pane click. Reports include `inputCheck.beforeEditing` and `inputCheck.duringEditing`; this preflight is outside the copy timing window. + +Set `HEADLESS_PERFORMANCE_EDIT_DURING_COPY=1` with `--performance` and at least +1000 nodes to add a mixed-workload probe. After the first copied frame, it finds +an exposed native hit point on the original card, double-clicks it, composes +Chinese text and inserts three text chunks while copying. It checks focus/DOM +retention, bounded mounted nodes and that input actually overlapped publication. +After all ACKs it checks the original's new text and the copy's captured old text +separately. No synthetic click passes through an overlapping copied card. +`duringCopyInput.samples` records browser input-event time to the second rAF, +latest value and retained focus. These frame opportunities exclude time before +the browser dispatches the input event; they are not end-to-end key latency or +proof that every intermediate composition string reached the display. Mixed +workload timings must be kept separate from the no-input copy baseline. diff --git a/tests/headless/renderer-copy-input.mjs b/tests/headless/renderer-copy-input.mjs new file mode 100644 index 0000000..ee7f009 --- /dev/null +++ b/tests/headless/renderer-copy-input.mjs @@ -0,0 +1,52 @@ +/** Native input while a real progressive copy is still publishing. */ +import assert from 'node:assert/strict'; + +export async function editDuringCopy({call,evaluate,wait,count}){ + assert.ok(count>=1000,'concurrent input probe requires at least 1000 source nodes'); + await wait('window.__canvasPerformance.read().firstPresentedFrameMs!==null','copy did not present before native input'); + assert.equal(await evaluate('window.__canvasPerformance.read().localCompleteMs===null'),true,'copy finished before input probe'); + // Copied cards overlap originals. Find an actually exposed point; never send a + // synthetic double-click through the copied card to the original underneath. + const point=await evaluate(`(()=>{ + const label=document.querySelector('.react-flow__node[data-id="perf-0"] [aria-label^="单击选中"]'); + if(!label)throw Error('original text label is missing'); + const rect=label.getBoundingClientRect(); + for(let y=Math.max(0,rect.top)+3;y{ + const element=document.activeElement,samples=[]; + const input=()=>{ + const sample={inputAt:performance.now(),value:element.value,copyActive:window.__canvasPerformance.read().localCompleteMs===null}; + samples.push(sample); + requestAnimationFrame(()=>requestAnimationFrame(()=>{ + sample.frameAt=performance.now();sample.frameDelayMs=sample.frameAt-sample.inputAt; + sample.retained=element.isConnected&&document.activeElement===element; + sample.presentedValue=element.value;sample.mountedNodes=document.querySelectorAll('.react-flow__node').length; + })); + }; + element.addEventListener('input',input); + window.__copyInputProbe={element,samples,dispose(){element.removeEventListener('input',input);}}; + })()`); + await call('Input.imeSetComposition',{text:'复',selectionStart:1,selectionEnd:1}); + await call('Input.insertText',{text:'复制期间输入'}); + for(const text of [' A',' B',' C']){ + await call('Input.insertText',{text}); + await wait(`window.__copyInputProbe.samples.at(-1)?.frameAt!==undefined`,'native input did not reach its next frame'); + } + const result=await evaluate(`(()=>{ + const probe=window.__copyInputProbe,result={samples:probe.samples,value:probe.element.value,retained:probe.element.isConnected&&document.activeElement===probe.element}; + probe.dispose();probe.element.blur();delete window.__copyInputProbe;return result; + })()`); + assert.equal(result.retained,true,'copy discarded native input DOM or focus'); + assert.ok(result.value.endsWith('复制期间输入 A B C'),'native composition/typing was overwritten'); + assert.ok(result.samples.some(sample=>sample.copyActive),'all native input happened after copy completion'); + assert.ok(result.samples.every(sample=>sample.retained),'copy interrupted an input presentation frame'); + assert.ok(result.samples.every(sample=>sample.mountedNodes<100),'native editing mounted offscreen nodes during copy'); + return result; +} diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs index d561880..912609e 100644 --- a/tests/headless/renderer-performance.mjs +++ b/tests/headless/renderer-performance.mjs @@ -1,5 +1,6 @@ /** Actual GraphPage/React Flow + IndexedDB probe; fixture HTTP server, not production service. */ 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'; @@ -7,7 +8,8 @@ import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts' export async function measureRenderer({call,evaluate,documents,root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline,buildMode='development',buildMs}){ const target=2*count+3,started=Date.now(),heapSamples=[];let copying=false,nextHeap=0; 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; + let report,duringCopyInput; + const concurrentInput=process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'; try{ 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'); @@ -37,6 +39,7 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co await wait(`!!document.querySelector('[aria-label="深复制选中内容"]')&&!document.querySelector('[aria-label="深复制选中内容"]').disabled`,'partition selection did not enable deep copy'); await evaluate(`new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))`); assert.equal(await evaluate('document.querySelectorAll(".react-flow__node-toolbar").length'),1,'selected frame did not own exactly one toolbar'); + const sourceText=documents.get(root).elements.get('perf-0').data.text; 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; @@ -48,11 +51,17 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co document.querySelector('[aria-label="深复制选中内容"]').click(); })()`); copying=true;console.log('PERF copy requested'); + if(concurrentInput){duringCopyInput=await editDuringCopy({call,evaluate,wait,count});assert.equal(duringCopyInput.value,sourceText+'复制期间输入 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)))'); const internalsBatches=await evaluate('window.__fixtureInternalsBatches??[]');assert.ok(internalsBatches.some(size=>size>1),'mounted nodes did not share a renderer measurement batch'); 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'); + } const visibleEdges=await evaluate('document.querySelectorAll(".react-flow__edge").length');assert.equal(visibleEdges,2,'frame connections did not render for the offscreen source'); // Completion is a model/progress check. Verify an offscreen copied card actually mounts after panning. 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); @@ -60,8 +69,8 @@ export async function measureRenderer({call,evaluate,documents,root,partition,co 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')}; - report={inputCheck,baseline,heapSamples,browserLogs:browserLogs(),scenario:'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} webpack; 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}; + report={inputCheck,duringCopyInput,baseline,heapSamples,browserLogs:browserLogs(),scenario:concurrentInput?'actual GraphPage deep copy with concurrent native input':'actual GraphPage deep copy of expanded partition',mode:`Next ${buildMode} webpack; 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}; 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={baseline,heapSamples,browserLogs:browserLogs(),scenario:'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;} + }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;} await writeFile(reportPath,JSON.stringify(report,null,2));console.log('MEASURE '+JSON.stringify({...report,measurements:{...report.measurements,longTasks:undefined}}));console.log('REPORT '+reportPath); } diff --git a/tests/headless/renderer.integration.mjs b/tests/headless/renderer.integration.mjs index d6706f4..8c2a133 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -13,6 +13,7 @@ import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts const productionMode=process.argv.includes('--production');if(productionMode&&!process.argv.includes('--performance'))throw Error('--production requires --performance'); const hooksOnly=process.argv.includes('--hooks-only'),performanceMode=process.argv.includes('--performance'),performanceCount=Number(process.env.HEADLESS_PERFORMANCE_COUNT??1000); if(performanceMode&&(!Number.isSafeInteger(performanceCount)||performanceCount<1||performanceCount>10000))throw Error('invalid performance count'); +if(process.env.HEADLESS_PERFORMANCE_EDIT_DURING_COPY==='1'&&(!performanceMode||performanceCount<1000))throw Error('concurrent input requires --performance and at least 1000 nodes'); const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline'); const source = resolve(import.meta.dirname, '../..'), directory = await mkdtemp(join(tmpdir(), 'endoleap-renderer-')), profile = await mkdtemp(join(tmpdir(), 'endoleap-renderer-browser-')); const id = n => `10000000-0000-4000-8000-${String(n).padStart(12, '0')}`;