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 }