tricu

An interpreted language for exploring Tree Calculus
Log | Files | Refs | README | LICENSE

backend-browser.mjs (8618B)


      1 import assert from 'node:assert/strict';
      2 import {writeFile} from 'node:fs/promises';
      3 import {sampleBrowser} from './browser-memory.mjs';
      4 import {spawn} from 'node:child_process';
      5 import {mkdtemp,rm} from 'node:fs/promises';
      6 import {tmpdir} from 'node:os';
      7 import {resolve} from 'node:path';
      8 import {startSiteServer} from './backend-server.mjs';
      9 const {server,url,control,marks}=await startSiteServer(process.argv[2]);
     10 const profile = await mkdtemp(resolve(tmpdir(), 'tricu-demo-chromium-'));
     11 let browser, socket, stopMemory;
     12 let next = 0; const pending = new Map();
     13 try {
     14   browser = spawn(process.argv[3] || 'chromium', ['--headless', '--no-sandbox', '--disable-dev-shm-usage',
     15     '--js-flags=--max-old-space-size=768', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] });
     16   if(process.env.TEST_BENCHMARK)stopMemory=sampleBrowser(browser.pid,marks);
     17   const debuggerURL = await new Promise((resolve, reject) => {
     18     let stderr = '';
     19     const timer = setTimeout(() => reject(new Error('Chromium startup timed out: ' + stderr)), 15_000);
     20     browser.on('error', error => { clearTimeout(timer); reject(error); });
     21     browser.on('exit', code => { clearTimeout(timer); reject(new Error('Chromium exited: ' + code + '\n' + stderr)); });
     22     browser.stderr.on('data', chunk => {
     23       stderr += chunk;
     24       const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/);
     25       if (match) { clearTimeout(timer); resolve(match[1]); }
     26     });
     27   });
     28   const endpoint = new URL(debuggerURL);
     29   const tab = await (await fetch(`http://${endpoint.host}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json();
     30   socket = new WebSocket(tab.webSocketDebuggerUrl);
     31   await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; });
     32   const exceptions = [];
     33   socket.onmessage = ({ data }) => {
     34     const event = JSON.parse(data);
     35     if (event.id) {
     36       const waiter = pending.get(event.id); pending.delete(event.id);
     37       if (!waiter) return;
     38       clearTimeout(waiter.timer);
     39       if (event.error) waiter.reject(new Error(JSON.stringify(event.error)));
     40       else waiter.resolve(event.result);
     41     } else if (event.method === 'Runtime.exceptionThrown') exceptions.push(event.params);
     42   };
     43   const send = (method, params = {}) => new Promise((resolve, reject) => {
     44     const id = ++next;
     45     const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, 180_000);
     46     pending.set(id, { resolve, reject, timer });
     47     socket.send(JSON.stringify({ id, method, params }));
     48   });
     49   await send('Runtime.enable'); await send('Page.enable');
     50   const evaluate = async expression => {
     51     const result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
     52     if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails));
     53     return result.result.value;
     54   };
     55   for(let i=0;!await evaluate(`location.href===${JSON.stringify(url)} && document.readyState==='complete'`);i++){if(i>400)throw Error('Page did not load');await new Promise(r=>setTimeout(r,25));}
     56   const report=await evaluate(`import(${JSON.stringify(process.env.TEST_BASIC_EXAMPLES?'/basic-examples-probe.mjs':process.env.TEST_IMPORT_PICKER?'/import-picker-probe.mjs':process.env.TEST_COLD_LOAD?'/cold-load-probe.mjs':process.env.TEST_ASSET_CACHE?'/asset-cache-probe.mjs':process.env.TEST_LOCAL_MODULES?'/local-module-probe.mjs':process.env.TEST_MODULES?'/module-probe.mjs':process.env.TEST_IDE?'/ide-probe.mjs':process.env.TEST_LIVE_UX?'/live-ux-probe.mjs':process.env.TEST_BENCHMARK?'/backend-benchmark.mjs':'/backend-probe.mjs')}).then(m=>m.run(${JSON.stringify(control)},${JSON.stringify(process.env.TEST_BACKEND||'haskell-wasm')},${!!process.env.BENCHMARK_PSS}))`);
     57   assert.deepEqual(exceptions,[]);
     58   if(process.env.TEST_LIVE_UX||process.env.TEST_IDE){
     59     for(const width of [320,390,1440]){
     60       await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<950});
     61       assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,'Expression workspace fits '+width);
     62       if(process.env.TEST_IDE){await evaluate("import('/syntax-probe.mjs').then(m=>m.syntaxProbe())");await evaluate("import('/evaluation-layout-probe.mjs').then(m=>m.evaluationLayout())");}
     63       if(width<741)assert.equal(await evaluate("(()=>{const r=document.getElementById('source-editor').getBoundingClientRect();return r.left===0&&r.width===innerWidth;})()"),true,'Mobile editor reaches both viewport edges');
     64       assert.equal(await evaluate("parseFloat(getComputedStyle(document.getElementById('expression-editor')).fontSize)>=16"),true,'Legible expression text');
     65     }
     66   }
     67   if(process.env.TEST_IDE){
     68     await send('Emulation.setEmulatedMedia',{features:[{name:'forced-colors',value:'active'}]});
     69     assert.equal(await evaluate("getComputedStyle(document.querySelector('.syntax-viewport')).display==='none'&&getComputedStyle(document.getElementById('expression-editor')).webkitTextFillColor!=='rgba(0, 0, 0, 0)'"),true,'High contrast retains readable native text');
     70     await send('Emulation.setEmulatedMedia',{features:[]});
     71     const beforeEdit=await evaluate("(()=>{const e=document.getElementById('expression-editor');e.focus();e.setSelectionRange(e.value.length,e.value.length);return e.value;})()");
     72     await send('Input.insertText',{text:' -- native undo'});
     73     assert.equal(await evaluate("document.getElementById('expression-editor').value"),beforeEdit+' -- native undo');
     74     await send('Input.dispatchKeyEvent',{type:'keyDown',key:'z',code:'KeyZ',modifiers:2,windowsVirtualKeyCode:90,nativeVirtualKeyCode:90});
     75     await send('Input.dispatchKeyEvent',{type:'keyUp',key:'z',code:'KeyZ',modifiers:2,windowsVirtualKeyCode:90,nativeVirtualKeyCode:90});
     76     assert.equal(await evaluate("document.getElementById('expression-editor').value"),beforeEdit,'Highlighting preserves native undo');
     77     await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true});
     78     await evaluate("document.getElementById('settings-toggle').click()");
     79     assert.equal(await evaluate("document.getElementById('settings-panel').getAttribute('aria-modal')==='true'&&document.querySelector('.editor-main').inert"),true,'Mobile settings is modal');
     80     await evaluate("document.getElementById('settings-close').focus()");
     81     await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Tab',code:'Tab',modifiers:8});
     82     await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Tab',code:'Tab',modifiers:8});
     83     assert.equal(await evaluate("document.getElementById('settings-panel').contains(document.activeElement)"),true,'Focus remains in the sheet');
     84     await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape'});
     85     assert.equal(await evaluate("document.getElementById('settings-panel').hidden&&!document.querySelector('.editor-main').inert&&document.activeElement.id==='settings-toggle'"),true,'Escape releases modality and returns focus');
     86     if(process.env.TEST_SCREENSHOT_PREFIX)for(const width of [390,1440]){
     87       await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:width<741});
     88       await evaluate("new Promise(r=>{window.scrollTo({top:0,behavior:'instant'});requestAnimationFrame(()=>requestAnimationFrame(r));})");
     89       const {cssContentSize:r}=await send('Page.getLayoutMetrics');
     90       const shot=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:r.width,height:r.height,scale:1}});
     91       await writeFile(process.env.TEST_SCREENSHOT_PREFIX+'-'+width+'.png',Buffer.from(shot.data,'base64'));
     92     }
     93   }
     94   if(stopMemory){report.browserMemory=await stopMemory(marks);stopMemory=undefined;}
     95   if(process.env.BENCHMARK_REPORT)await writeFile(process.env.BENCHMARK_REPORT,JSON.stringify(report,null,2)+'\n');
     96   if(report.parity)console.log(JSON.stringify(report.parity));
     97   console.log('PASS actual site Chromium: '+report.checks.join(' / '));
     98 } finally {
     99   if(stopMemory)await stopMemory(marks);
    100   for (const waiter of pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Browser test closed')); }
    101   pending.clear(); socket?.close();
    102   if (browser && browser.exitCode === null) {
    103     const exited = new Promise(resolve => browser.once('exit', resolve));
    104     browser.kill('SIGKILL'); await exited;
    105   }
    106   await new Promise(resolve => server.close(resolve));
    107   await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
    108 }