diff --git a/headless/README.md b/headless/README.md index efacff5..3c1daa0 100644 --- a/headless/README.md +++ b/headless/README.md @@ -922,3 +922,17 @@ clock; eager subscription failures also dispose their queue. This batches source notifications, not every React render: another store/prop update may read the current Root before the pending frame. Session and element subscriptions retain their existing routing and immediate read semantics. + +The actual-page performance harness defaults to development webpack. Add +`--production` together with `--performance` to build its isolated application +with `next build --webpack` and serve that completed build with `next start`. +The report identifies the mode and records build time separately from browser +copy timings; build failure aborts before measurement. Example: + +```sh +HEADLESS_PERFORMANCE_COUNT=10000 HEADLESS_PERFORMANCE_REPORT=/private/tmp/headless-production-10000.json node tests/headless/renderer.integration.mjs --performance --production +``` + +Both modes retain the same full-copy, upper-canvas edge, synchronization and +far-card viewport assertions. Production here means an optimized frontend served +against the fixture HTTP service; it does not claim production Foundry validation. diff --git a/tests/headless/renderer-performance.mjs b/tests/headless/renderer-performance.mjs index 3bcf56f..e4bb7b1 100644 --- a/tests/headless/renderer-performance.mjs +++ b/tests/headless/renderer-performance.mjs @@ -4,7 +4,7 @@ import {writeFile} from 'node:fs/promises'; import {setTimeout as pause} from 'node:timers/promises'; import {canvasTransform,toWorld} from '../../src/lib/canvas-core/coordinates.ts'; -export async function measureRenderer({call,evaluate,documents,root,partition,count,exceptions,reportPath,browserLogs=()=>'',baseline}){ +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; @@ -39,8 +39,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={baseline,heapSamples,browserLogs:browserLogs(),scenario:'actual GraphPage deep copy of expanded partition',mode:'Next development webpack; isolated fixture HTTP; real Chrome/IndexedDB/Web Locks',count,initialReadyMs,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={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}; 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,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={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;} 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 ece7ca7..d6706f4 100644 --- a/tests/headless/renderer.integration.mjs +++ b/tests/headless/renderer.integration.mjs @@ -10,6 +10,7 @@ import { tmpdir } from 'node:os'; import { spawn } from 'node:child_process'; import { setTimeout as pause } from 'node:timers/promises'; import { createDocument, applyChanges } from '../../src/lib/canvas-core/index.ts'; +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'); const baseline=process.env.HEADLESS_PERFORMANCE_BASELINE;if(baseline&&!/^[a-f0-9]{7,40}$/.test(baseline))throw Error('invalid diagnostic baseline'); @@ -132,8 +133,17 @@ try { for (const file of ['package.json', 'tsconfig.json', 'postcss.config.mjs']) await copyFile(join(source, file), join(directory, file)); await symlink(join(source, 'node_modules'), join(directory, 'node_modules')); - next = spawn(process.execPath, [join(source, 'node_modules/next/dist/bin/next'), 'dev', '--webpack', '--hostname', '127.0.0.1', '--port', String(port)], { cwd: directory, detached: true, env: { ...process.env, NEXT_TELEMETRY_DISABLED: '1', FOUNDRY_API_ORIGIN: `http://127.0.0.1:${upstream.address().port}`, NEXT_PUBLIC_FOUNDRY_PROJECT: 'endoleap' }, stdio: ['ignore', 'pipe', 'pipe'] }); - next.stdout.on('data', chunk => { logs = (logs + chunk).slice(-16000); }); next.stderr.on('data', chunk => { logs = (logs + chunk).slice(-16000); }); + const launchNext=args=>{ + const child=spawn(process.execPath,[join(source,'node_modules/next/dist/bin/next'),...args],{cwd:directory,detached:true,env:{...process.env,NEXT_TELEMETRY_DISABLED:'1',FOUNDRY_API_ORIGIN:`http://127.0.0.1:${upstream.address().port}`,NEXT_PUBLIC_FOUNDRY_PROJECT:'endoleap'},stdio:['ignore','pipe','pipe']}); + child.stdout.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});child.stderr.on('data',chunk=>{logs=(logs+chunk).slice(-16000);});return child; + }; + let buildMs; + if(productionMode){ + console.log('BUILD production fixture');const started=Date.now();next=launchNext(['build','--webpack']); + await new Promise((resolve,reject)=>{next.once('error',reject);next.once('exit',(code,signal)=>code===0?resolve():reject(Error(`production fixture build failed (${code??signal})\n${logs}`)));}); + buildMs=Date.now()-started;console.log(`BUILD complete ${buildMs} ms`); + } + next=launchNext([productionMode?'start':'dev',...productionMode?[]:['--webpack'],'--hostname','127.0.0.1','--port',String(port)]); for (let i = 0; i < 120; i++) { if (next.exitCode !== null) throw new Error(logs); try { const r = await fetch(`http://127.0.0.1:${port}/api/foundry/profile`, { signal: AbortSignal.timeout(1000) }); if (r.ok) break; } catch {} await pause(250); } let browserLogs=''; chrome = spawn(process.env.CHROME_BIN ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', ['--headless=new', '--no-first-run', '--disable-background-networking', '--disable-component-update', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore','ignore','pipe'] }); @@ -151,7 +161,7 @@ try { const route = `http://127.0.0.1:${port}/dashboard/${workspace}/${root}`; await call('Page.navigate', { url: route }); if(performanceMode){ - await measureRenderer({call,evaluate,documents,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')}); + await measureRenderer({call,evaluate,documents,root,partition,count:performanceCount,exceptions,browserLogs:()=>browserLogs,baseline,buildMode:productionMode?'production':'development',buildMs,reportPath:process.env.HEADLESS_PERFORMANCE_REPORT??join(tmpdir(),'headless-renderer-performance.json')}); }else if(hooksOnly){await until('typeof window.__runCanvasHookChecks==="function"','hook fixture did not mount');const reports=await evaluate('window.__runCanvasHookChecks()');for(const report of reports)console.log('PASS React hooks: '+report); }else{ await until('!!document.querySelector("[data-headless-canvas][data-model-loading] .react-flow__node[data-id=a]")','first editable model prefix did not open before the gated tail');