68 lines
8.7 KiB
JavaScript
68 lines
8.7 KiB
JavaScript
|
|
// Optional real-browser WebGPU smoke test.
|
||
|
|
// Starts from the production index.html and injects a temporary test-only API.
|
||
|
|
// Usage: node tests/browser_smoke.mjs [port=9333]
|
||
|
|
import assert from 'node:assert/strict';
|
||
|
|
import fs from 'node:fs/promises';
|
||
|
|
import {fileURLToPath,pathToFileURL} from 'node:url';
|
||
|
|
import {setTimeout as sleep} from 'node:timers/promises';
|
||
|
|
|
||
|
|
const port=Number(process.argv[2]||9333);
|
||
|
|
const prodUrl=new URL('../index.html',import.meta.url),generatedUrl=new URL('./.browser-smoke.generated.html',import.meta.url);
|
||
|
|
const prodHtml=await fs.readFile(prodUrl,'utf8');
|
||
|
|
const marker='// ── boot / teardown';
|
||
|
|
assert.equal(prodHtml.includes(marker),true,'boot marker missing');
|
||
|
|
const hook=String.raw`
|
||
|
|
function testDecimalRequiredBits(s){s=String(s).trim().replace(/^[+-]/,'');const p=s.toLowerCase().split('e'),f=(p[0].split('.')[1]||'').length,e=p[1]?parseInt(p[1],10):0;return Math.max(64,Math.ceil(Math.max(0,f-e)*Math.log2(10))+32)}
|
||
|
|
function testFromDec(s,bits){s=String(s).trim();let neg=s.startsWith('-');if(neg)s=s.slice(1);if(s.startsWith('+'))s=s.slice(1);const p=s.toLowerCase().split('e'),mant=p[0],exp=p[1]?parseInt(p[1],10):0,a=mant.split('.'),i=a[0]||'0',f=a[1]||'';let digits=(i+f).replace(/^0+(?=\d)/,'')||'0',places=f.length-exp;if(places<0){digits+='0'.repeat(-places);places=0}const den=10n**BigInt(places),v=(BigInt(digits)*(1n<<BigInt(bits))+den/2n)/den;return neg?-v:v}
|
||
|
|
async function waitForTestState(predicate,label,timeout=120000){const start=performance.now();for(;;){if(state.drawState==='ERROR')throw new Error(state.gpuError||'render failed');if(predicate())return true;if(performance.now()-start>=timeout)throw new Error(label+' timeout');if(state.dirty&&!state.rendering)schedule();await new Promise(resolve=>setTimeout(resolve,20))}}
|
||
|
|
globalThis.__MANDEL_TEST__={
|
||
|
|
async ensureGpuReady(){const r=renderer||await initRenderer();return{ready:!!r,navigatorGpu:!!navigator.gpu,renderer:!!renderer,gpuInitFailed:state.gpuInitFailed,gpuError:state.gpuError}},
|
||
|
|
async setView({re,im,span,bits,baseIter=350,adaptive=false,continuationBudget=0,quality=null,fixed=false}){const ready=await this.ensureGpuReady();if(!ready.ready)throw new Error('WebGPU initialization failed: '+JSON.stringify(ready));cancelRender();state.bits=bits||Math.max(256,testDecimalRequiredBits(re),testDecimalRequiredBits(im),testDecimalRequiredBits(span));state.re=fixed?BigInt(re):testFromDec(re,state.bits);state.im=fixed?BigInt(im):testFromDec(im,state.bits);state.span=fixed?BigInt(span):testFromDec(span,state.bits);state.baseIter=baseIter;state.adaptive=adaptive;state.continuationBudget=Math.max(0,Math.min(150000,Math.floor(continuationBudget)));state.quality='standard';if(quality)applyQuality(quality);else if(!state.continuationBudget)applyQuality('standard');ensurePrecision();resize();markDirty(false);const start=performance.now();while((state.dirty||state.rendering)&&performance.now()-start<120000){schedule();await new Promise(r=>setTimeout(r,20))}if(state.dirty||state.rendering)throw new Error('test render timeout');return this.state()},
|
||
|
|
async waitForFinal({timeout=120000}={}){await waitForTestState(()=>!state.dirty&&!state.rendering&&state.fieldView?.complete===true&&state.renderClock?.status==='complete','final',timeout);return this.state()},
|
||
|
|
async waitForNumericComplete({timeout=120000}={}){const result=await this.waitForFinal({timeout});if(result.numericalFailures)throw new Error('numeric recovery ended with '+result.numericalFailures+' unresolved failures');return result},
|
||
|
|
async sampleMeta(points){if(!renderer?.frame)throw new Error('GPU field unavailable');const idx=points.map(([x,y])=>y*renderer.frame.w+x);return Array.from(await renderer.readMeta(idx))},
|
||
|
|
state:()=>{const reasons=state.unknownReasons||{};return{bits:state.bits,re:state.re.toString(),im:state.im.toString(),span:state.span.toString(),width:canvas.width,height:canvas.height,iter:maxIter(),effectiveIter:state.fieldView?.iter??maxIter(),continuationBudget:state.continuationBudget,quality:state.quality,rendering:state.rendering,renderClock:state.renderClock?{...state.renderClock}:null,backend:state.fieldView?.backend||null,fastExtended:!!state.fieldView?.fastExtended,referenceReused:!!state.fieldView?.referenceReused,finiteBudgetComplete:!!state.fieldView?.frontier?.finiteBudgetComplete,membershipCertified:!!state.fieldView?.membershipCertified,numericalFailures:numericalFailureCount({reasons}),unknownReasons:state.unknownReasons,kernelVersion:G.version}},
|
||
|
|
async panPixels(dx,dy){pan(dx,dy);const start=performance.now();while((state.dirty||state.rendering)&&performance.now()-start<120000){schedule();await new Promise(r=>setTimeout(r,20))}if(state.dirty||state.rendering)throw new Error('test pan timeout');return this.state()},
|
||
|
|
async smokeExportTile({w=48,h=32,ss=1}={}){if(!renderer)throw new Error('WebGPU renderer unavailable');const snap=snapshot(),iter=maxIter(),fastExtended=fastNeedsExtended(snap,w),fastContext=fastExtended?await fastRefs.request(snap,iter,w,h):null,args={snap,iter,fastContext,fullW:w,fullH:h,tileX:0,tileY:0,w,h},result=ss===2?await renderer.renderTileRGBA2x(args):await renderer.renderTileRGBA({...args,sampleX:.5,sampleY:.5,edgeAA:false}),data=result.rgba;let checksum=2166136261>>>0;for(const v of data){checksum^=v;checksum=Math.imul(checksum,16777619)>>>0}return{length:data.length,expected:w*h*4,checksum,ss,unresolved:result.unresolved||0}}
|
||
|
|
};
|
||
|
|
`;
|
||
|
|
const generatedHtml=prodHtml.replace(marker,hook+'\n'+marker);
|
||
|
|
await fs.writeFile(generatedUrl,generatedHtml);
|
||
|
|
const viewerUrl=pathToFileURL(fileURLToPath(generatedUrl)).href;
|
||
|
|
|
||
|
|
let ws=null;
|
||
|
|
try{
|
||
|
|
const response=await fetch(`http://127.0.0.1:${port}/json/list`);
|
||
|
|
assert.ok(response.ok,'Remote debugging endpoint unavailable');
|
||
|
|
const pages=(await response.json()).filter(t=>t.type==='page');
|
||
|
|
assert.equal(pages.length,1,'Use one dedicated test tab');
|
||
|
|
ws=new WebSocket(pages[0].webSocketDebuggerUrl);
|
||
|
|
await new Promise((resolve,reject)=>{ws.addEventListener('open',resolve,{once:true});ws.addEventListener('error',reject,{once:true})});
|
||
|
|
let serial=0;const pending=new Map(),exceptions=[];
|
||
|
|
ws.addEventListener('message',event=>{
|
||
|
|
const m=JSON.parse(event.data);
|
||
|
|
if(m.method==='Runtime.exceptionThrown')exceptions.push(m.params.exceptionDetails.exception?.description||m.params.exceptionDetails.text);
|
||
|
|
const p=pending.get(m.id);if(!p)return;pending.delete(m.id);clearTimeout(p.timer);
|
||
|
|
if(m.error)p.reject(new Error(JSON.stringify(m.error)));else p.resolve(m.result);
|
||
|
|
});
|
||
|
|
function command(method,params={}){const id=++serial;return new Promise((resolve,reject)=>{const timer=setTimeout(()=>{pending.delete(id);reject(new Error(method+' timeout'))},180000);pending.set(id,{resolve,reject,timer});ws.send(JSON.stringify({id,method,params}))})}
|
||
|
|
async function evaluate(expression){const r=await command('Runtime.evaluate',{expression,awaitPromise:true,returnByValue:true});if(r.exceptionDetails)throw new Error(r.exceptionDetails.exception?.description||r.exceptionDetails.text);return r.result.value}
|
||
|
|
await command('Runtime.enable');await command('Page.enable');await command('Page.navigate',{url:viewerUrl});
|
||
|
|
for(let i=0;i<200;i++){if(await evaluate('typeof globalThis.__MANDEL_TEST__ === "object"'))break;if(i===199)throw new Error('__MANDEL_TEST__ timeout');await sleep(50)}
|
||
|
|
const ready=await evaluate('__MANDEL_TEST__.ensureGpuReady()');assert.equal(ready.ready,true,JSON.stringify(ready));
|
||
|
|
const reset=await evaluate(`__MANDEL_TEST__.setView({re:'-0.5',im:'0',span:'3.4',bits:256,baseIter:350,adaptive:true,quality:'fast'})`);
|
||
|
|
assert.equal(reset.continuationBudget,4096);assert.equal(reset.numericalFailures,0);
|
||
|
|
const finalReset=await evaluate('__MANDEL_TEST__.waitForNumericComplete()');assert.equal(finalReset.finiteBudgetComplete,true);
|
||
|
|
const deep=await evaluate(`__MANDEL_TEST__.setView({re:'-0.743643887037151',im:'0.13182590420533',span:'0.00000000000034',bits:256,baseIter:350,adaptive:true,quality:'standard'})`);
|
||
|
|
assert.equal(deep.continuationBudget,16384);assert.equal(deep.fastExtended,true);
|
||
|
|
const finalDeep=await evaluate('__MANDEL_TEST__.waitForNumericComplete()');assert.equal(finalDeep.numericalFailures,0);
|
||
|
|
const samples=await evaluate(`__MANDEL_TEST__.sampleMeta([[0,0],[Math.floor(innerWidth/2),Math.floor(innerHeight/2)]])`);assert.equal(samples.length,2);
|
||
|
|
const ss1=await evaluate('__MANDEL_TEST__.smokeExportTile({w:24,h:16,ss:1})');assert.equal(ss1.length,ss1.expected);assert.ok(ss1.checksum>0);
|
||
|
|
const ss2=await evaluate('__MANDEL_TEST__.smokeExportTile({w:16,h:12,ss:2})');assert.equal(ss2.length,ss2.expected);assert.ok(ss2.checksum>0);
|
||
|
|
const panned=await evaluate('__MANDEL_TEST__.panPixels(24,0)');assert.equal(panned.numericalFailures,0);
|
||
|
|
assert.deepEqual(exceptions,[]);
|
||
|
|
console.log('PASS: optional Chromium WebGPU smoke (generated from production source)');
|
||
|
|
}finally{
|
||
|
|
ws?.close();
|
||
|
|
await fs.rm(generatedUrl,{force:true});
|
||
|
|
}
|