backend-browser.mjs (16314B)
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, diagnostics; 12 const requestTimeout=Number(process.env.TEST_CDP_TIMEOUT_MS??180000); 13 assert(Number.isSafeInteger(requestTimeout)&&requestTimeout>=1000&&requestTimeout<=900000); 14 let next = 0; const pending = new Map(); 15 try { 16 browser = spawn(process.argv[3] || 'chromium', ['--headless', '--no-sandbox', '--disable-dev-shm-usage', 17 '--js-flags=--max-old-space-size=768', '--remote-debugging-port=0', `--user-data-dir=${profile}`, 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] }); 18 if(process.env.TEST_BENCHMARK)stopMemory=sampleBrowser(browser.pid,marks); 19 const debuggerURL = await new Promise((resolve, reject) => { 20 let stderr = ''; 21 const timer = setTimeout(() => reject(new Error('Chromium startup timed out: ' + stderr)), 15_000); 22 browser.on('error', error => { clearTimeout(timer); reject(error); }); 23 browser.on('exit', code => { clearTimeout(timer); reject(new Error('Chromium exited: ' + code + '\n' + stderr)); }); 24 browser.stderr.on('data', chunk => { 25 stderr += chunk; 26 const match = stderr.match(/DevTools listening on (ws:\/\/\S+)/); 27 if (match) { clearTimeout(timer); resolve(match[1]); } 28 }); 29 }); 30 const endpoint = new URL(debuggerURL); 31 const tab = await (await fetch(`http://${endpoint.host}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' })).json(); 32 socket = new WebSocket(tab.webSocketDebuggerUrl); 33 await new Promise((resolve, reject) => { socket.onopen = resolve; socket.onerror = reject; }); 34 const exceptions = []; 35 socket.onmessage = ({ data }) => { 36 const event = JSON.parse(data); 37 if (event.id) { 38 const waiter = pending.get(event.id); pending.delete(event.id); 39 if (!waiter) return; 40 clearTimeout(waiter.timer); 41 if (event.error) waiter.reject(new Error(JSON.stringify(event.error))); 42 else waiter.resolve(event.result); 43 } else if (event.method === 'Runtime.exceptionThrown') exceptions.push(event.params); 44 }; 45 const send = (method, params = {}) => new Promise((resolve, reject) => { 46 const id = ++next; 47 const timer = setTimeout(() => { pending.delete(id); reject(new Error('CDP timed out: ' + method)); }, requestTimeout); 48 pending.set(id, { resolve, reject, timer }); 49 socket.send(JSON.stringify({ id, method, params })); 50 }); 51 await send('Runtime.enable'); await send('Page.enable'); 52 if(process.env.TEST_VIEWPORT_WIDTH){const width=Number(process.env.TEST_VIEWPORT_WIDTH);assert(Number.isInteger(width)&&width>=320);await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<741});} 53 const evaluate = async expression => { 54 let result;try{result = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });}catch(error){console.error('Last test phases:',JSON.stringify(marks.slice(-8)));throw error;} 55 if (result.exceptionDetails) throw new Error(JSON.stringify(result.exceptionDetails)); 56 return result.result.value; 57 }; 58 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));} 59 if(process.env.TEST_SCHEME){assert(['light','dark'].includes(process.env.TEST_SCHEME));await evaluate(`(()=>{if(document.documentElement.dataset.theme!==${JSON.stringify(process.env.TEST_SCHEME)})document.getElementById('theme-toggle').click();})()`);} 60 if(process.env.TEST_DIAGNOSTICS)diagnostics=setInterval(()=>{ 61 console.log('Test phases:',JSON.stringify(marks.slice(-4))); 62 void evaluate("({status:document.getElementById('status')?.textContent,activity:document.getElementById('compile-activity')?.textContent,card:document.querySelector('.definition-card [role=status]')?.textContent})").then(state=>console.log('Page:',JSON.stringify(state))).catch(()=>{}); 63 },30000); 64 const report=await evaluate(`import(${JSON.stringify(process.env.TEST_IO_PUBLIC?'/io-public-probe.mjs':process.env.TEST_IO_SOURCE?'/io-source-probe.mjs':process.env.TEST_IO_SESSION?'/io-session-probe.mjs':process.env.TEST_IO_CORE?'/io-core-probe.mjs':process.env.TEST_IO_LIFECYCLE?'/io-lifecycle-probe.mjs':process.env.TEST_IO_PROTOCOL?'/io-protocol-probe.mjs':process.env.TEST_IMPORT_FOLDS?'/import-folds-probe.mjs':process.env.TEST_IO_VERSION?'/io-version-probe.mjs':process.env.TEST_DEFINITION_NAMES?'/definition-names-probe.mjs':process.env.TEST_INLINE_IO?'/inline-io-probe.mjs':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||process.env.TEST_IO_TERMINAL)}))`); 65 if(process.env.TEST_IMPORT_FOLDS)await (await import('./import-fold-inputs.mjs')).importFoldInputs({send,evaluate,report,prefix:process.env.TEST_SCREENSHOT_PREFIX}); 66 if(process.env.TEST_DEFINITION_NAMES)await (await import('./definition-name-inputs.mjs')).definitionNameInputs({send,evaluate,report,prefix:process.env.TEST_SCREENSHOT_PREFIX}); 67 if(process.env.TEST_INLINE_IO&&process.env.TEST_SCREENSHOT_PREFIX){ 68 await evaluate("document.getElementById('run-io').click()"); 69 for(let i=0;!await evaluate("!!document.querySelector('#terminal-io form input')");i++){assert(i<3600);await new Promise(r=>setTimeout(r,25));} 70 for(const width of [390,1440]){ 71 await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:width<741}); 72 await evaluate("new Promise(r=>{document.activeElement?.blur();window.scrollTo({top:0,behavior:'instant'});requestAnimationFrame(()=>requestAnimationFrame(r));})"); 73 assert(await evaluate("(()=>{const c=document.querySelector('.definition-card').getBoundingClientRect(),s=document.getElementById('source-editor').getBoundingClientRect();return c.top>=s.top&&c.bottom<=s.bottom;})()"),'Inline IO stays within source bounds'); 74 const {cssContentSize:r}=await send('Page.getLayoutMetrics'); 75 const shot=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:r.width,height:r.height,scale:1}}); 76 await writeFile(process.env.TEST_SCREENSHOT_PREFIX+'-io-'+width+'.png',Buffer.from(shot.data,'base64')); 77 } 78 await evaluate("document.querySelector('#terminal-io form input').value='Screenshot';document.querySelector('#terminal-io form').requestSubmit()"); 79 for(let i=0;!await evaluate("document.getElementById('status').textContent==='Finished'");i++){assert(i<3600);await new Promise(r=>setTimeout(r,25));} 80 } 81 if(process.env.TEST_RELOAD){ 82 // Exercise a real navigation, not a mocked reload callback. Runtime state is 83 // deliberately non-returning when recovery is accepted. 84 const waitFor=async(expression,label)=>{ 85 for(let i=0;i<3600;i++){ 86 try{if(await evaluate(expression))return;}catch(error){if(!/context|navigat|Cannot find/i.test(error.message))throw error;} 87 await new Promise(r=>setTimeout(r,25)); 88 } 89 throw Error('Recovery timeout: '+label); 90 }; 91 const sourceFreeLocals=await evaluate("document.getElementById('source-editor').hidden&&document.getElementById('local-module-list').children.length>0"); 92 if(sourceFreeLocals){ 93 await evaluate("window.__localRecoveryPrompts=0;const savedConfirm=window.confirm;window.confirm=()=>{window.__localRecoveryPrompts++;return false;};document.getElementById('reload-playground').click();window.confirm=savedConfirm"); 94 assert.equal(await evaluate('window.__localRecoveryPrompts'),1,'Opened modules warn even without an editable draft'); 95 } 96 await evaluate(`(()=>{ 97 document.querySelector('[data-compiler-example=identity]').click(); 98 globalThis.__sourceEditor.replaceSource('answer = 42\\nloop = (x : x x) (x : x x)',true); 99 })()`); 100 await waitFor("document.getElementById('status').textContent==='Compiled'",'scratch compile'); 101 assert.equal(await evaluate("document.getElementById('reload-playground').disabled"),false,'Recovery available for local drafts'); 102 const draftBefore=await evaluate("globalThis.__sourceEditor.getSource()"); 103 const localsBefore=await evaluate("document.getElementById('local-module-list').children.length"); 104 await evaluate("window.__reloadProof=true;window.__recoveryPrompts=0;window.confirm=()=>{window.__recoveryPrompts++;return false;};document.getElementById('reload-playground').click()"); 105 await new Promise(r=>setTimeout(r,100)); 106 assert.equal(await evaluate('window.__recoveryPrompts'),1,'Draft recovery asks before clearing work'); 107 assert.equal(await evaluate("globalThis.__sourceEditor.getSource()"),draftBefore,'Cancelled recovery preserves authored source'); 108 assert.equal(await evaluate("document.getElementById('local-module-list').children.length"),localsBefore,'Cancelled recovery preserves local files'); 109 await evaluate("document.querySelector('[aria-label=\"Inspect compiled definition loop\"]').click()"); 110 await waitFor("document.getElementById('status').textContent==='Inspecting loop'",'divergent WASM observation'); 111 assert.equal(await evaluate("document.getElementById('reload-playground').disabled"),false,'Recovery remains available while WASM is stuck'); 112 await evaluate("window.confirm=()=>true;document.getElementById('reload-playground').click()"); 113 await waitFor("!window.__reloadProof&&document.getElementById('status')?.textContent==='Ready'",'new page/runtime'); 114 await evaluate("import('/source-probe-helpers.mjs').then(()=>true)"); 115 assert.equal(await evaluate("globalThis.__sourceEditor.isReadOnly||document.getElementById('source-editor').hidden"),false,'Reload restores editable demo source'); 116 assert.equal(await evaluate("document.getElementById('local-module-list').children.length"),0,'Reload clears in-memory local modules'); 117 assert.equal(await evaluate("document.querySelector('.definition-card').hidden"),true,'Reload never automatically inspects'); 118 assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,'Recovery toolbar fits viewport'); 119 await evaluate("document.getElementById('program-picker').value='demo:arithmetic';document.getElementById('program-picker').dispatchEvent(new Event('change'))"); 120 await waitFor("document.getElementById('status').textContent==='Ready'",'demo switch after recovery'); 121 await evaluate("document.querySelector('[aria-label=\"Inspect compiled definition total\"]').click()"); 122 await waitFor("document.querySelector('.definition-card pre').textContent==='12'",'fresh WASM inspection'); 123 report.checks.push('full-page toolbar recovery: confirmation/cancellation; available during divergent WASM observation; new page/runtime; cleared local state; editable demos and successful inspection'); 124 } 125 assert.deepEqual(exceptions,[]); 126 if(process.env.TEST_LIVE_UX||process.env.TEST_IDE){ 127 for(const width of [320,390,1440]){ 128 await send('Emulation.setDeviceMetricsOverride',{width,height:844,deviceScaleFactor:1,mobile:width<950}); 129 assert.equal(await evaluate('document.documentElement.scrollWidth<=innerWidth'),true,'Source workspace fits '+width); 130 if(process.env.TEST_IDE){await evaluate("import('/syntax-probe.mjs').then(m=>m.syntaxProbe())");} 131 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'); 132 assert.equal(await evaluate("parseFloat(getComputedStyle(document.getElementById('source-editor')).fontSize)>="+(width<741?16:14)),true,'Legible source text'); 133 } 134 } 135 if(process.env.TEST_IDE){ 136 await send('Emulation.setEmulatedMedia',{features:[{name:'forced-colors',value:'active'}]}); 137 assert.equal(await evaluate("getComputedStyle(globalThis.__sourceEditor.view.contentDOM).color!=='rgba(0, 0, 0, 0)'&&!document.querySelector('.syntax-viewport')"),true,'High contrast retains readable native text'); 138 await send('Emulation.setEmulatedMedia',{features:[]}); 139 const beforeEdit=await evaluate("(()=>{const e=globalThis.__sourceEditor;e.select(e.getSource().length);return e.getSource();})()"); 140 await send('Input.insertText',{text:' -- native undo'}); 141 assert.equal(await evaluate("globalThis.__sourceEditor.getSource()"),beforeEdit+' -- native undo'); 142 await send('Input.dispatchKeyEvent',{type:'keyDown',key:'z',code:'KeyZ',modifiers:2,windowsVirtualKeyCode:90,nativeVirtualKeyCode:90}); 143 await send('Input.dispatchKeyEvent',{type:'keyUp',key:'z',code:'KeyZ',modifiers:2,windowsVirtualKeyCode:90,nativeVirtualKeyCode:90}); 144 assert.equal(await evaluate("globalThis.__sourceEditor.getSource()"),beforeEdit,'Highlighting preserves native undo'); 145 await send('Emulation.setDeviceMetricsOverride',{width:390,height:844,deviceScaleFactor:1,mobile:true}); 146 await evaluate("document.getElementById('settings-toggle').click()"); 147 assert.equal(await evaluate("document.getElementById('settings-panel').getAttribute('aria-modal')==='true'&&document.querySelector('.editor-main').inert"),true,'Mobile settings is modal'); 148 await evaluate("document.getElementById('settings-close').focus()"); 149 await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Tab',code:'Tab',modifiers:8}); 150 await send('Input.dispatchKeyEvent',{type:'keyUp',key:'Tab',code:'Tab',modifiers:8}); 151 assert.equal(await evaluate("document.getElementById('settings-panel').contains(document.activeElement)"),true,'Focus remains in the sheet'); 152 await send('Input.dispatchKeyEvent',{type:'keyDown',key:'Escape',code:'Escape'}); 153 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'); 154 if(process.env.TEST_SCREENSHOT_PREFIX)for(const width of [390,1440]){ 155 await send('Emulation.setDeviceMetricsOverride',{width,height:1000,deviceScaleFactor:1,mobile:width<741}); 156 await evaluate("new Promise(r=>{window.scrollTo({top:0,behavior:'instant'});requestAnimationFrame(()=>requestAnimationFrame(r));})"); 157 const {cssContentSize:r}=await send('Page.getLayoutMetrics'); 158 const shot=await send('Page.captureScreenshot',{format:'png',captureBeyondViewport:true,clip:{x:0,y:0,width:r.width,height:r.height,scale:1}}); 159 await writeFile(process.env.TEST_SCREENSHOT_PREFIX+'-'+width+'.png',Buffer.from(shot.data,'base64')); 160 } 161 } 162 if(stopMemory){report.browserMemory=await stopMemory(marks);stopMemory=undefined;} 163 if(process.env.BENCHMARK_REPORT)await writeFile(process.env.BENCHMARK_REPORT,JSON.stringify(report,null,2)+'\n'); 164 if(report.parity)console.log(JSON.stringify(report.parity)); 165 console.log('PASS actual site Chromium: '+report.checks.join(' / ')); 166 } finally { 167 clearInterval(diagnostics); 168 if(stopMemory)await stopMemory(marks); 169 for (const waiter of pending.values()) { clearTimeout(waiter.timer); waiter.reject(new Error('Browser test closed')); } 170 pending.clear(); socket?.close(); 171 if (browser && browser.exitCode === null) { 172 const exited = new Promise(resolve => browser.once('exit', resolve)); 173 browser.kill('SIGKILL'); await exited; 174 } 175 await new Promise(resolve => server.close(resolve)); 176 await rm(profile, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); 177 }