module-probe.mjs (12833B)
1 import {terminalHost} from './terminal-host.mjs'; 2 import {evaluateSource,inspect} from './source-probe-helpers.mjs'; 3 // Production adapters + real page; expected values are independent arithmetic, 4 // native header contracts, and the CLI cases in native-modules.py. 5 const assert=(ok,message)=>{if(!ok)throw Error(message);}; 6 const equal=(actual,expected,label)=>assert(JSON.stringify(actual)===JSON.stringify(expected),label+': '+JSON.stringify(actual)); 7 async function rejects(fn,pattern){try{await fn();}catch(e){assert(pattern.test(e.message),'Wrong rejection: '+e.message);return;}throw Error('Expected '+pattern);} 8 const el=id=>document.getElementById(id),delay=ms=>new Promise(r=>setTimeout(r,ms)); 9 async function status(value){for(let i=0;i<4500;i++){if(el('status').textContent===value)return;await delay(20);}throw Error('Timed out '+value+': '+el('status').textContent+' '+el('compile-diagnostic').textContent);} 10 const input=(id,value)=>{if(id==='source-editor'){globalThis.__sourceEditor.replaceSource(value,true);return;}el(id).value=value;el(id).dispatchEvent(new Event('input'));}; 11 const change=(id,value)=>{el(id).value=value;el(id).dispatchEvent(new Event('change'));}; 12 export async function run(control,backend='haskell-wasm'){ 13 const base=new URL('../',document.querySelector('script[type=module][src]').src); 14 const {createRuntimeSession}=(await import(new URL('dist/probe-api.js',base))).runtimeSession; 15 const {Terminal}=(await import(new URL('dist/probe-api.js',base))).ioTerminal; 16 const {packageIndex,packageReleases}=(await import(new URL('dist/probe-api.js',base))).packages; 17 const releases=packageIndex(await(await fetch(new URL('packages.json',base))).json()); 18 const lib=name=>packageReleases(releases,'tricu-lib-'+name)[0]; 19 const math=lib('native-math'),foundation=lib('base'),io=lib('io'),modules=lib('source-module'); 20 const host={before:async()=>{},after:()=>{},write:()=>{},read:async()=>'',running:()=>{}}; 21 const fault=async data=>{await fetch(control+'/fault',{method:'POST',body:JSON.stringify(data)});}; 22 const source=`-- native header; position masking must preserve this line\n!import ${math.manifest} as N\n!export answer\nprivate = 99\nanswer = N.add 40 2`; 23 const phase=async name=>{await fetch(control+'/phase',{method:'POST',body:JSON.stringify({name})});}; 24 const s=await createRuntimeSession(backend,true); 25 const value=async expression=>(await s.runExpression(expression,'number','pure',host)).value; 26 try { 27 const catalogue=await(await fetch(new URL('catalogue.json',base))).json(); 28 const fixtures=await(await fetch('/program-fixtures.json')).json(); 29 for(const demo of catalogue){ 30 await phase('compile/check demo '+demo.id); 31 assert(demo.environment==='empty','Every demo is editable without hidden helper injection'); 32 await s.compile(demo.source,demo.environment); 33 for(const c of fixtures.demos[demo.id].haskellCases){ 34 let terminal=''; 35 const hooks=demo.mode==='io'?terminalHost(Terminal,c.input):{...host,read:async()=>c.input,write:text=>{terminal+=text;}}; 36 const result=await s.run(c.export,c.arguments.map(value=>({type:'tree',value})),'ternary',demo.mode, 37 hooks); 38 equal(result.value,c.result,'Recompiled demo golden '+demo.id); 39 equal(demo.mode==='io'?hooks.terminalText:terminal,c.terminal,'Recompiled terminal '+demo.id); 40 } 41 } 42 await phase('retained v1 publication'); 43 const oldGreeting=releases.find(r=>r.name==='greet'&&r.version==='0.2.0'); 44 assert(oldGreeting?.hash==='0811457d501113ee11194f90e279ebb4ef9d548c70cb48d146be03eb9af7505f','Original publication pin'); 45 await s.load(new Uint8Array(await(await fetch(new URL('/_arboricx/bundle/hash/'+oldGreeting.hash,base))).arrayBuffer()),oldGreeting.hash); 46 assert(!await s.probeIO('main'),'Historical v1 is not current executable IO'); 47 let callbacks=0;const forbidden=()=>{callbacks++;throw Error('retired host callback');}; 48 await rejects(()=>s.run('main',[],'string','io',{...host,selected:forbidden,before:forbidden,write:forbidden,read:forbidden}),/Unsupported tricuIO version/); 49 equal(callbacks,0,'Retired framing rejects before host callbacks'); 50 await phase('native module contracts'); 51 equal(await s.compile(source),['answer'],'Only explicit public exports'); 52 equal(await value('answer'),'42','Qualified module application'); 53 equal(await value('N.add answer private'),'141','Imported and private local bindings survive expression runs'); 54 await s.compile(source,'practical'); 55 await rejects(()=>value('isNat? 1'),/unresolved name/); // native headers override legacy demo profiles 56 for(let i=0;i<10;i++)equal(await value('N.add answer 1'),'43','Repeated retained imported application'); 57 await rejects(()=>s.run('private',[],'number','pure',host),/Missing definition|missing export/); 58 equal(await s.compile(`!import ${foundation.manifest} *\n!export answer\nanswer = add 20 22`),['answer'],'Open full base has no implicit contract collisions'); 59 equal(await value('add answer 8'),'50','Open library helpers retained'); 60 await s.compile(`!import ${foundation.manifest} *\n!export checkedIdentity\ncheckedIdentity x =@nat? x`); 61 equal(await value('checkedIdentity 42'),'42','Native annotations resolve explicit contract imports'); 62 equal(await s.compile(`!import ${math.manifest} as A\n!import ${math.manifest} as B\n!export *\nanswer = A.add (B.add 10 11) 21`),['answer'],'Same manifest under two aliases'); 63 equal(await value('answer'),'42','Repeated reference'); 64 await s.compile(`!import ${modules.manifest} as M\n!export answer\nanswer = M.sourceModuleQualified "N" "add"`); 65 equal((await s.runExpression('answer','string','pure',host)).value,'N.add','Compiler helper library is ordinarily importable'); 66 const goldens=await(await fetch('/module-goldens.json')).json(); 67 for(const golden of goldens.trees){ 68 await phase('exact native golden '+golden.name); 69 await s.compile(golden.source); 70 const tree=(await s.run(golden.name,[],'ternary','pure',host)).value; 71 equal(tree.length,golden.bytes,'Complete native function tree length'); 72 const hash=[...new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(tree)))].map(n=>n.toString(16).padStart(2,'0')).join(''); 73 equal(hash,golden.ternaryAsciiSha256,'Exact function tree agrees with independent native compiler'); 74 } 75 const names=await s.compile(`!export * from ${io.manifest}`); 76 assert(names.includes('io')&&names.includes('openReader')&&names.length>16,'Header-only full re-export'); 77 equal((await s.run('io',[{type:'number',value:'42'}],'tree','pure',host)).type,'tree','Re-export is executable, not source expansion'); 78 await rejects(()=>value('openReader'),/unresolved name/); // reexports do not introduce lexical imports 79 await s.compile(`!import ${io.manifest} as I\n!export main\nmain = I.io (I.scope (owner : I.require (I.openReader owner "no-browser-filesystem") I.pure))`); 80 await rejects(()=>s.run('main',[],'string','io',terminalHost(Terminal,null)),/IO failed/i); 81 await s.compile(`!import ${math.manifest} as N\n!export answer\nloop x = loop x\nanswer = N.add 40 2`); 82 equal(await value('answer'),'42','Unused divergent function is never normalized'); 83 const failures=[ 84 [`!import ${math.manifest}\n!export answer\nanswer = 42`,/hash imports require an alias/], 85 [`!import ${math.manifest} as N\n!import ${math.manifest} as N\n!export answer\nanswer = 42`,/duplicate imported name/], 86 [`!import ${math.manifest} *\n!export add\nadd x y = x`,/Compiler diagnostic 46/], 87 [`!import ${math.manifest} as N\nanswer = 42`,/module requires an export/], 88 [`!export absent\nanswer = 42`,/undeclared local export/], 89 [`!export answer\n!export *\nanswer = 42`,/duplicate export/], 90 [`!export * from ${math.manifest}\n!export * from ${math.manifest}`,/duplicate export/], 91 [`!import prelude *\n!export answer\nanswer = 42`,/workspace/], 92 [`!source prelude\n!export answer\nanswer = 42`,/workspace/], 93 [`!import ${'0'.repeat(64)} as X\n!export answer\nanswer = 42`,/Unknown module manifest/], 94 [`!import ${math.manifest} as N\n!export answer\nanswer = missing`,/Compiler diagnostic 20 at 3:/], 95 ]; 96 for(const [text,pattern] of failures){ 97 await rejects(()=>s.compile(text),pattern); 98 await rejects(()=>value('answer'),/unresolved name/); 99 await s.compile(source);equal(await value('answer'),'42','Recover after failed module compilation'); 100 } 101 for(const field of ['moduleMissing','moduleCorrupt']){ 102 await fault({[field]:releases.find(r=>r.manifest===math.manifest).hash}); 103 await rejects(()=>s.compile(source),/HTTP 404|SHA-256 mismatch/); 104 await rejects(()=>value('answer'),/unresolved name/); 105 await fault({[field]:null}); 106 await s.compile(source);equal(await value('answer'),'42','Fetch retry'); 107 } 108 await fault({moduleManifest:math.manifest}); 109 await rejects(()=>s.compile(source),/Module manifest mismatch/); 110 await fault({moduleManifest:null}); 111 await s.compile(source); 112 const memory=[]; 113 for(let i=0;i<12;i++){ 114 await s.compile(source);equal(await value('N.add answer 1'),'43','Repeated module replacement'); 115 if(s.diagnostics){s.collect();memory.push(s.diagnostics());} 116 } 117 if(memory.length){ 118 equal(memory.at(-1).owned,memory[0].owned,'No retained import handle leak'); 119 assert(Math.max(...memory.slice(6).map(x=>x.linearMemoryBytes))<=Math.max(...memory.slice(0,6).map(x=>x.linearMemoryBytes))+65536,'Module memory plateaus'); 120 } 121 await s.compile(`!import ${math.manifest} as N\n!export answer\nbrowserExpression0 = 9\nanswer = 42`); 122 equal(await value('N.add answer browserExpression0'),'51','Temporary expression names avoid private locals'); 123 await s.compile('answer = 7');equal(await value('answer'),'7','Legacy scratch remains supported'); 124 await rejects(()=>value('N.add 1 2'),/unresolved name/); 125 }finally{s.dispose();await fault({moduleMissing:null,moduleCorrupt:null,moduleManifest:null});} 126 await phase('page integration'); 127 // Exercise the actual editable page, including Worker termination and rebuilding. 128 await status('Ready'); 129 document.querySelector('[data-compiler-example=module]').click();await status('Compiled'); 130 assert(globalThis.__sourceEditor.getSource().includes(math.manifest),'Module example uses an indexed manifest, not a bundle digest'); 131 equal(await inspect('answer','number'),'42','Module scratch example'); 132 input('source-editor',source);el('compile').click();await status('Compiled'); 133 assert(document.querySelector('[aria-label="Inspect compiled definition private"]'),'Private scope remains inspectable'); 134 equal(await evaluateSource('N.add answer private'),'141','Actual page imported application in source'); 135 input('source-editor',`!import ${math.manifest} as N\n!export answer\nloop x = loop x\nanswer = N.add 40 2`); 136 el('compile').click();await status('Compiled'); 137 input('source-editor',globalThis.__sourceEditor.getSource()+'\nbomb = loop t');await status('Compiled');document.querySelector('[aria-label="Inspect compiled definition bomb"]').click();await delay(100);el('stop').click();await status('Stopped'); 138 assert(!el('compile').disabled,'Stop discards imported heap'); 139 el('compile').click();await status('Compiled');equal(await evaluateSource('N.add answer 1'),'43','Stop/recompile reconstructs verified imports'); 140 // Edit the actual parser/lexer examples, not a scratch copy; verify changed 141 // behavior, then restore the authenticated original and its behavior. 142 await phase('parser/lexer edit and restore'); 143 for(const [id,before,after,expression,want,original] of [ 144 ['parser','N.inRange c 97 122','N.inRange c 65 90','parseNames "RED,GREEN"','ok','error'], 145 ['lexer','N.inRange character 97 122','N.inRange character 65 90','scanWord "HELLO,world"','HELLO',''], 146 ]){ 147 change('program-picker','demo:'+id);await status('Ready'); 148 assert(!el('source-editor').hidden&&!globalThis.__sourceEditor.isReadOnly,'Editable '+id); 149 const source=globalThis.__sourceEditor.getSource();assert(source.includes(before),'Known predicate'); 150 input('source-editor',source.replace(before,after));el('compile').click();await status('Compiled'); 151 const expressionSource=`let 152 firstValue [[_ _ value] | _] = value 153 firstValue _ = "" 154 in firstValue (${expression})`;equal(await evaluateSource(expressionSource,'string'),want,'Edited '+id+' result'); 155 el('restore-source').click();await status('Ready');equal(globalThis.__sourceEditor.getSource(),source,'Restore '+id); 156 equal(await evaluateSource(expressionSource,'string'),original,'Restored '+id+' behavior'); 157 } 158 return {userAgent:navigator.userAgent,checks:['all sixteen editable demos match native goldens; parser/lexer edit and restore; native headers; qualified/open imports; full reexports; retained private/import scope; collision/diagnostic controls; transport and manifest faults; no capabilities; repeated replacement/GC; actual page Stop/recompile']}; 159 }