tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

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}`);