Verify native input and snapshot isolation during progressive copy

This commit is contained in:
2026-11-11 01:33:54 +08:00
parent 4946e8025b
commit 26d1688f2f
4 changed files with 78 additions and 3 deletions
+13
View File
@@ -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.
+52
View File
@@ -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<Math.min(innerHeight,rect.bottom);y+=6)
for(let x=Math.max(0,rect.left)+3;x<Math.min(innerWidth,rect.right);x+=6)
if(label.contains(document.elementFromPoint(x,y)))return{x,y};
throw Error('original text has no exposed native input point');
})()`);
await call('Input.dispatchMouseEvent',{type:'mousePressed',...point,button:'left',buttons:1,clickCount:2});
await call('Input.dispatchMouseEvent',{type:'mouseReleased',...point,button:'left',buttons:0,clickCount:2});
await wait(`document.activeElement===document.querySelector('.react-flow__node[data-id="perf-0"] textarea')&&!document.activeElement.readOnly`,'original text did not enter editing during copy');
await evaluate(`(()=>{
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;
}
+12 -3
View File
@@ -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()<deadline){if(exceptions.length)throw Error(exceptions.join('\n'));if(copying&&process.env.HEADLESS_PERFORMANCE_HEAP==='1'&&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.nodeCount<target,'offscreen DOM was not culled');assert.ok(await evaluate(`document.querySelector('[aria-label="画布导入"]').textContent.includes('${count+3} 项')`),'copy progress did not include all source records');
if(concurrentInput){
const copiedFirst=[...document.elements.values()].find(element=>element.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);
}
+1
View File
@@ -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')}`;