firefox-idle.mjs (12804B)
1 // Real Firefox/WebDriver BiDi soak against the packaged page. No evaluator/UI mocks. 2 // Each published/compiled session is left untouched in a genuinely hidden tab 3 // for its entire requested interval; earlier checkpoints never rebuild later ones. 4 import assert from 'node:assert/strict'; 5 import {spawn} from 'node:child_process'; 6 import {mkdtemp,rm,writeFile,realpath} from 'node:fs/promises'; 7 import {resolve} from 'node:path'; 8 import {startSiteServer} from './backend-server.mjs'; 9 10 const minutes=(process.env.IDLE_MINUTES??'10,20,30').split(',').map(Number); 11 assert(minutes.length>0&&minutes.length<=6&&minutes.every(n=>Number.isFinite(n)&&n>0),'Positive IDLE_MINUTES, at most six checkpoints'); 12 const output=resolve(process.env.IDLE_REPORT??'/tmp/tricu-firefox-idle.json'); 13 const site=resolve(process.env.ARBORICX_SITE_ROOT??'result-site'); 14 const {server,url}=await startSiteServer(resolve(site,'runtime/haskell')); 15 const profile=await mkdtemp('/tmp/tricu-firefox-idle-'); 16 const report={schema:2,started:new Date().toISOString(),site:await realpath(site),minutes, 17 scope:'Headless Firefox on Linux; packaged page and both WASM/compiler bundles served by the existing loopback fixture server. Real inactive tabs, no page polling during idle. Each interval has independent published and compiled Haskell sessions. No OS suspend, memory-pressure injection, production network or extensions.', 18 events:[],checkpoints:[],ok:false}; 19 let browser,socket,next=0,neutral,stderr='',failure; 20 const pending=new Map(); 21 const pause=ms=>new Promise(r=>setTimeout(r,ms)); 22 const save=()=>writeFile(output,JSON.stringify(report,null,2)+'\n'); 23 function send(method,params={},timeout=90_000){ 24 const id=++next; 25 return new Promise((resolve,reject)=>{ 26 const timer=setTimeout(()=>{pending.delete(id);reject(Error('BiDi timeout: '+method));},timeout); 27 pending.set(id,{resolve,reject,timer});socket.send(JSON.stringify({id,method,params})); 28 }); 29 } 30 async function evaluate(context,expression){ 31 const reply=await send('script.evaluate',{target:{context},awaitPromise:true, 32 expression:`Promise.resolve((${expression})).then(value=>JSON.stringify(value??null))`}); 33 if(reply.type!=='success')throw Error('Page evaluation: '+JSON.stringify(reply)); 34 return JSON.parse(reply.result.value); 35 } 36 const snapshot=context=>evaluate(context,`(()=>{ 37 const get=id=>document.getElementById(id); 38 return {at:new Date().toISOString(),visibility:document.visibilityState,status:get('status')?.textContent, 39 backend:'haskell-wasm',program:get('program-picker')?.value, 40 sourceHidden:get('source-editor')?.hidden,sourceReadOnly:globalThis.__sourceEditor?.isReadOnly, 41 sourceDisabled:globalThis.__sourceEditor?.isDisabled,source:globalThis.__sourceEditor?.getSource(), 42 result:document.querySelector('.definition-card pre')?.textContent, 43 diagnostic:get('compile-diagnostic')?.textContent,inspectionDisabled:document.querySelector('.definition-card select')?.disabled, 44 errors:window.__idleErrors,visibilityChanges:window.__idleVisibility}; 45 })()`); 46 async function wait(context,condition,label){ 47 const until=Date.now()+90_000; 48 while(Date.now()<until){if(await evaluate(context,condition))return;await pause(50);} 49 throw Error('Timeout '+label+': '+JSON.stringify(await snapshot(context))); 50 } 51 const status=(context,text)=>wait(context,`document.getElementById('status')?.textContent===${JSON.stringify(text)}`,text); 52 const input=(context,id,value)=>evaluate(context,`(()=>{const e=document.getElementById(${JSON.stringify(id)});if(${JSON.stringify(id)}==='source-editor'){globalThis.__sourceEditor.replaceSource(${JSON.stringify(value)},true);}else{e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('input'));}})()`); 53 const change=(context,id,value)=>evaluate(context,`(()=>{const e=document.getElementById(${JSON.stringify(id)});e.value=${JSON.stringify(value)};e.dispatchEvent(new Event('change'));})()`); 54 const click=(context,id)=>evaluate(context,`document.getElementById(${JSON.stringify(id)}).click()`); 55 async function editable(context){ 56 const state=await snapshot(context); 57 assert.equal(state.sourceHidden,false,JSON.stringify(state)); 58 assert.equal(state.sourceReadOnly,false,JSON.stringify(state)); 59 assert.equal(state.sourceDisabled,false,JSON.stringify(state)); 60 assert.deepEqual(state.errors??[],[],JSON.stringify(state)); 61 return state; 62 } 63 async function run(context,expression,expected,codec='number'){ 64 const actual=await evaluate(context,`(async()=>{ 65 const close=document.querySelector('.definition-card [aria-label="Close definition inspection"]'); 66 if(!document.querySelector('.definition-card').hidden)close.click(); 67 const helper=await import('/source-probe-helpers.mjs');return helper.evaluateSource(${JSON.stringify(expression)},${JSON.stringify(codec)}); 68 })()`); 69 assert.equal(actual,expected,expression);await editable(context); 70 } 71 async function select(context,name){await change(context,'program-picker','demo:'+name);await status(context,'Ready');await editable(context);} 72 async function exercise(job){ 73 const {context,mode}=job; 74 job.resumedAt=new Date().toISOString();job.idleSeconds=(Date.now()-job.backgroundAt)/1000; 75 job.before=await snapshot(context); 76 assert.equal(job.before.visibility,'hidden','Tab remained inactive before resuming'); 77 assert(job.idleSeconds>=job.minutes*60,'Complete idle interval'); 78 assert.equal(job.before.visibilityChanges.filter(e=>e.visibility==='visible').length,0,'No accidental activation during idle'); 79 await send('browsingContext.activate',{context}); 80 assert.equal(await evaluate(context,'document.visibilityState'),'visible','Real foreground transition'); 81 await editable(context); 82 // First operation uses the original retained session, before any replacement. 83 await run(context,'answer',mode==='compiled'?'43':'42'); 84 for(let cycle=0;cycle<2;cycle++){ 85 await select(context,'arithmetic');await run(context,'total','12'); 86 const source=await evaluate(context,"globalThis.__sourceEditor.getSource()"); 87 assert(source.includes('Math.add 5 7'),'Known independently checked arithmetic specimen'); 88 await input(context,'source-editor',source.replace('Math.add 5 7','Math.add 8 9')); 89 await status(context,'Compiled');await run(context,'total','17'); 90 await click(context,'restore-source');await status(context,'Ready');await run(context,'total','12'); 91 await select(context,'functions');await run(context,'double 6','12'); 92 await select(context,'language-reference');await run(context,'identity 23','23'); 93 } 94 // Ordinary diagnostics must not strand the published/draft editor read-only. 95 await evaluate(context,"import('/source-probe-helpers.mjs').then(helper=>helper.inspect('probeResult','string'))"); 96 assert((await evaluate(context,"document.querySelector('.definition-card [role=status]').textContent")).includes('Cannot interpret')); 97 await editable(context); 98 await run(context,'identity 24','24'); 99 await input(context,'source-editor','!export answer\nanswer = (');await status(context,'Compilation failed');await editable(context); 100 await click(context,'restore-source');await status(context,'Ready');await run(context,'identity 25','25'); 101 job.after=await snapshot(context);job.ok=true; 102 console.log(`PASS ${job.minutes} min ${mode}: ${job.idleSeconds.toFixed(1)}s hidden; retained inspection, switching, compilation, restoration and diagnostic recovery`); 103 await send('browsingContext.activate',{context:neutral}); 104 await send('browsingContext.close',{context}); 105 await save(); 106 } 107 try{ 108 browser=spawn(process.argv[2]??'firefox',['--headless','--no-remote','--new-instance','--remote-debugging-port','0','--profile',profile,'about:blank'],{ 109 stdio:['ignore','ignore','pipe'],env:{...process.env,HOME:profile,XDG_CONFIG_HOME:resolve(profile,'config'),XDG_CACHE_HOME:resolve(profile,'cache'),XDG_DATA_HOME:resolve(profile,'data'),MOZ_CRASHREPORTER_DISABLE:'1',GSETTINGS_BACKEND:'memory'}, 110 }); 111 const endpoint=await new Promise((resolve,reject)=>{ 112 const timer=setTimeout(()=>reject(Error('Firefox BiDi startup: '+stderr)),20_000); 113 browser.on('error',error=>{clearTimeout(timer);reject(error);}); 114 browser.on('exit',code=>{clearTimeout(timer);reject(Error('Firefox exited '+code));}); 115 browser.stderr.on('data',chunk=>{stderr=(stderr+chunk).slice(-32_000);const match=stderr.match(/WebDriver BiDi listening on (ws:\/\/[^\s]+)/);if(match){clearTimeout(timer);resolve(match[1]);}}); 116 }); 117 socket=new WebSocket(endpoint+'/session');await new Promise((r,j)=>{socket.onopen=r;socket.onerror=j;}); 118 socket.onmessage=({data})=>{ 119 const message=JSON.parse(data); 120 if(message.id){const waiter=pending.get(message.id);if(!waiter)return;clearTimeout(waiter.timer);pending.delete(message.id); 121 if(message.type==='error')waiter.reject(Error(JSON.stringify(message)));else waiter.resolve(message.result); 122 }else if((message.method==='log.entryAdded'&&message.params.level==='error')||message.method==='network.fetchError'||(message.method==='network.responseCompleted'&&message.params.response.status>=400)){ 123 report.events.push({at:new Date().toISOString(),...message}); 124 } 125 }; 126 const session=await send('session.new',{capabilities:{}});report.capabilities=session.capabilities; 127 await send('session.subscribe',{events:['log.entryAdded','network.fetchError','network.responseCompleted']}); 128 neutral=(await send('browsingContext.getTree')).contexts[0].context; 129 for(const interval of minutes)for(const mode of ['published','compiled']){ 130 const context=(await send('browsingContext.create',{type:'tab',background:true})).context; 131 await send('browsingContext.activate',{context}); 132 await send('browsingContext.navigate',{context,url,wait:'complete'}); 133 await evaluate(context,`(()=>{ 134 window.__idleErrors=[];window.__idleVisibility=[]; 135 window.addEventListener('error',event=>window.__idleErrors.push({type:'error',message:event.message,filename:event.filename,line:event.lineno})); 136 window.addEventListener('unhandledrejection',event=>window.__idleErrors.push({type:'rejection',message:String(event.reason)})); 137 document.addEventListener('visibilitychange',()=>window.__idleVisibility.push({visibility:document.visibilityState,at:Date.now()})); 138 })()`); 139 await status(context,'Ready');await evaluate(context,"import('/source-probe-helpers.mjs').then(()=>true)"); 140 if(mode==='compiled'){ 141 const source=await evaluate(context,"globalThis.__sourceEditor.getSource()"); 142 assert(source.includes('answer = 42'));await input(context,'source-editor',source.replace('answer = 42','answer = 43')); 143 await status(context,'Compiled'); 144 } 145 await run(context,'answer',mode==='compiled'?'43':'42'); 146 const job={context,mode,minutes:interval};report.checkpoints.push(job); 147 await send('browsingContext.activate',{context:neutral}); 148 } 149 // Start all independent intervals only after every session has been warmed. 150 for(const job of report.checkpoints){ 151 assert.equal(await evaluate(job.context,'document.visibilityState'),'hidden'); 152 await evaluate(job.context,'window.__idleVisibility=[]');job.backgroundAt=Date.now(); 153 } 154 await save();console.log(`Warmed ${report.checkpoints.length} independent Haskell sessions in Firefox ${report.capabilities.browserVersion}. Background waits: ${minutes.join(', ')} minutes.`); 155 for(const job of [...report.checkpoints].sort((a,b)=>a.backgroundAt+a.minutes*60_000-b.backgroundAt-b.minutes*60_000)){ 156 await pause(Math.max(0,job.backgroundAt+job.minutes*60_000-Date.now())); 157 try{await exercise(job);}catch(error){job.error=String(error);try{job.failureState=await snapshot(job.context);}catch(capture){job.captureError=String(capture);} 158 try{const shot=await send('browsingContext.captureScreenshot',{context:job.context});await writeFile(output+'.failure.png',Buffer.from(shot.data,'base64'));}catch{} 159 throw error; 160 } 161 } 162 assert.deepEqual(report.events,[],'Unexpected browser console/network errors'); 163 report.ok=true; 164 }catch(error){failure=error;report.error=error.stack??String(error);} 165 finally{ 166 report.finished=new Date().toISOString();report.stderr=stderr;await save(); 167 if(socket?.readyState===WebSocket.OPEN){try{await send('browser.close',{},5000);}catch{}socket.close();} 168 if(browser?.exitCode===null&&browser.signalCode===null){const exited=new Promise(r=>browser.once('exit',r));browser.kill('SIGKILL');await exited;} 169 for(const waiter of pending.values()){clearTimeout(waiter.timer);waiter.reject(Error('Browser closed'));}pending.clear(); 170 server.closeAllConnections();await new Promise(r=>server.close(r)); 171 await rm(profile,{recursive:true,force:true,maxRetries:5,retryDelay:100}); 172 } 173 if(failure)throw failure; 174 console.log(`PASS Firefox idle soak. Report: ${output}`);