tricu

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

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 }