io-v2-source-probe.mjs (9135B)
1 // Real compiled source libraries, native evaluators and the private event session. 2 export async function run(_control,backend,terminalMode=false){ 3 const base=new URL('../',document.querySelector('script[type=module][src]').src),fixture=await(await fetch('/io-v2-source.json')).json(),checks=[]; 4 const input=[...new TextEncoder().encode('\ufeffA馃榾位e\u0301\r\n\0')]; 5 async function drive(name,start,turn){ 6 if(terminalMode)return driveTerminal(name,start,turn); 7 let report=await start(),queue=[...report.events],offset=0,pendingRead=null,cancelled=false;const output=[]; 8 for(let i=0;i<2000&&!report.done;i++){ 9 if(!queue.length){report=await turn({run:report.run,kind:'poll'});queue.push(...report.events);continue;} 10 const event=queue.shift();let reply; 11 if(event.kind==='read'){ 12 if(name==='cancellation'){pendingRead=event.id;continue;} 13 if(name!=='roundTrip')throw Error('Unexpected read'); 14 const bytes=input.slice(offset,offset+Math.min(event.limit,1));offset+=bytes.length; 15 reply={...event,bytes,eof:bytes.length===0}; 16 }else if(event.kind==='write'){ 17 if(name==='cancellation'&&pendingRead===null)throw Error('Write ran without the expected pending reader'); 18 // Deliberate positive partial progress: the SOURCE writeAll must advance 19 // exactly once, including through a split multi-byte scalar. 20 output.push(event.bytes[0]);reply={...event,count:1}; 21 }else{ 22 if(event.id!==pendingRead||cancelled)throw Error('Unexpected cancellation'); 23 cancelled=true;reply={...event,kind:'cancelled'}; 24 } 25 report=await turn({run:report.run,kind:'reply',reply});if(report.accepted!==true)throw Error('Provider reply rejected');queue.push(...report.events); 26 } 27 const expected=name.startsWith('invalid')?[]:name==='roundTrip'?input:[...new TextEncoder().encode(name==='cancellation'?'ready':'位馃榾e\u0301\r\n')]; 28 if(!report.done||report.outcome.kind!=='completed'||report.outcome.value!=='42'||report.outcome.fault)throw Error(name+': '+JSON.stringify(report)); 29 if(JSON.stringify(output)!==JSON.stringify(expected))throw Error(name+' output byte mismatch: '+JSON.stringify(output)); 30 if(name==='cancellation'&&!cancelled)throw Error('Missing source cancellation'); 31 if(!Object.values(report.counts).every(group=>Object.values(group).every(v=>v===0)))throw Error('Retained run records'); 32 checks.push(name+': full bytes/result42; split UTF8 and source partial-progress handling'); 33 } 34 async function driveTerminal(name,start,turn){ 35 const {ioV2TerminalView:{terminalView}}=await import(new URL('dist/probe-api.js',base)); 36 let report=await start(),stop=false,sent=false,aborted=false;const replies=[],parent=document.createElement('div');document.body.append(parent); 37 const widget=terminalView(parent,report.run,r=>replies.push(r),()=>{stop=true;}); 38 try{ 39 for(let i=0;i<2000&&!report.done;i++){ 40 for(const event of report.events){ 41 const old=parent.querySelector('form'),oldEOF=old?.querySelector('button[type=button]'); 42 widget.handle(event); 43 if(event.kind==='cancel'){ 44 aborted=true;if(!parent.querySelector('form').hidden)throw Error('Input still visible at cancellation acknowledgement'); 45 const count=replies.length;old.dispatchEvent(new Event('submit',{cancelable:true}));oldEOF?.click(); 46 if(replies.length!==count)throw Error('Stale DOM prompt emitted reply'); 47 } 48 if(event.kind==='read'&&name==='roundTrip'){ 49 const form=parent.querySelector('form'); 50 if(!sent){form.querySelector('input').value='\ufeffA馃榾位e\u0301\0';form.dispatchEvent(new Event('submit',{cancelable:true}));sent=true;} 51 else form.querySelector('button[type=button]').click(); 52 } 53 if(event.kind==='read'&&name==='externalStop')parent.querySelector('section > button').click(); 54 } 55 const command=stop?{kind:'stop'}:replies.length?{kind:'reply',reply:replies.shift()}:{kind:'poll'};stop=false; 56 report=await turn({run:report.run,...command}); 57 if(command.kind==='reply'&&report.accepted!==true)throw Error('UI completion rejected'); 58 } 59 if(!report.done||report.outcome.kind!==(name==='externalStop'?'cancelled':'completed')||report.outcome.fault)throw Error('UI run: '+JSON.stringify(report)); 60 if(name!=='externalStop'&&report.outcome.value!=='42')throw Error('UI result mismatch'); 61 widget.finish();const snapshot=widget.snapshot(); 62 const expected=name==='binary'?[92,120,102,102,255,240,159]:name==='roundTrip'?[...new TextEncoder().encode('\ufeffA馃榾位e\u0301\0\n')]:name.startsWith('invalid')||name==='externalStop'?[]:[...new TextEncoder().encode(name==='cancellation'?'ready':'位馃榾e\u0301\r\n')]; 63 if(JSON.stringify(snapshot.output.bytes)!==JSON.stringify(expected))throw Error('UI output mismatch '+name); 64 if(parent.querySelector('[data-hex=output]').textContent!==expected.map(b=>b.toString(16).padStart(2,'0')).join(' '))throw Error('Wrong hex view'); 65 if(name==='binary'){ 66 const text=parent.querySelector('[data-stream=output]'); 67 if(text.firstChild.nodeType!==Node.TEXT_NODE||text.firstChild.textContent!=='\\xff'||text.querySelectorAll('mark').length!==2||!text.querySelector('[data-byte-reason=incomplete]'))throw Error('Byte escapes indistinguishable from literal text'); 68 } 69 if(['externalStop','cancellation'].includes(name)&&!aborted)throw Error('No UI cancellation'); 70 if(!Object.values(report.counts).every(group=>Object.values(group).every(v=>v===0)))throw Error('UI run retained records'); 71 if(replies.length||parent.querySelector('form'))throw Error('UI not retired'); 72 checks.push('terminal '+name+': exact bytes, DOM lifecycle and outcome'); 73 }finally{parent.remove();} 74 } 75 if(terminalMode){ 76 const {ioV2TerminalView:{terminalView}}=await import(new URL('dist/probe-api.js',base)); 77 const parent=document.createElement('div'),replies=[];document.body.append(parent);let stops=0; 78 const widget=terminalView(parent,'900',r=>replies.push(r),()=>{stops++;}); 79 try{ 80 widget.handle({run:'900',id:1,kind:'read',limit:4096});const stale=parent.querySelector('form'),staleEOF=stale.querySelector('button[type=button]'); 81 widget.handle({run:'900',id:1,kind:'cancel',operation:'read'});widget.handle({run:'900',id:2,kind:'read',limit:4096}); 82 stale.dispatchEvent(new Event('submit',{cancelable:true}));staleEOF.click(); 83 if(replies.length!==1||parent.querySelector('form').hidden)throw Error('Old prompt consumed successor'); 84 const form=parent.querySelector('form');form.querySelector('input').value='\ud800';form.dispatchEvent(new Event('submit',{cancelable:true})); 85 if(replies.length!==1||form.hidden)throw Error('Invalid line consumed wait'); 86 form.querySelector('input').value='';form.dispatchEvent(new Event('submit',{cancelable:true})); 87 if(replies.at(-1).eof||JSON.stringify(replies.at(-1).bytes)!=='[10]')throw Error('Empty line conflated with EOF'); 88 const text='<script>throw Error("injected")</script><img src=x onerror=alert(1)>'; 89 widget.handle({run:'900',id:3,kind:'write',stream:'error',bytes:[...new TextEncoder().encode(text)]}); 90 if(parent.querySelector('script,img')||parent.querySelector('[data-stream=error]').textContent!==text)throw Error('Output interpreted as markup'); 91 const button=parent.querySelector('section > button');button.click();button.click();if(stops!==1)throw Error('Repeated stop'); 92 widget.finish();checks.push('terminal DOM: stale prompt isolation, malformed/empty line, inert markup, once-only stop'); 93 }finally{parent.remove();} 94 } 95 const names=terminalMode?['direct','handled','roundTrip','cancellation','invalidUTF8','invalidScalar','binary','externalStop']:['direct','handled','roundTrip','cancellation','invalidUTF8','invalidScalar']; 96 if(backend==='haskell-wasm'){ 97 const {CompilerWorker}=await import(new URL('runtime/haskell/client.mjs',base)),client=new CompilerWorker(); 98 try{ 99 await client.request({type:'init',compiler:false});await client.request({type:'load',bytes:new Uint8Array(fixture.bytes).buffer,hash:fixture.hash}); 100 for(const name of names)await drive(name, 101 async()=>JSON.parse((await client.request({type:'io-v2-session-start',name:name==='externalStop'?'roundTrip':name,arguments:[]})).text), 102 async command=>JSON.parse((await client.request({type:'io-v2-session-turn',...command})).text)); 103 }finally{client.dispose();} 104 }else{ 105 const {WasmRuntime}=await import('/io-v2-runtime.mjs'),{ioV2Private,ioV2Session}=await import(new URL('dist/zig-helpers.js',base)); 106 const g=await WasmRuntime.create(new Uint8Array(await(await fetch(new URL('runtime/zig/arboricx.wasm',base))).arrayBuffer()));let run=0n; 107 try{ 108 for(const name of names)await g.scopeAsync(async()=>{ 109 const action=g.load(new Uint8Array(fixture.bytes),name==='externalStop'?'roundTrip':name),tree=ioV2Private.constructors({leaf:g.leaf,observe:g.observe.bind(g),stem:g.stem.bind(g),fork:g.fork.bind(g),app:g.app.bind(g)}); 110 const session=new ioV2Session.Session(tree,action,String(++run),roots=>g.sweepScope(roots)); 111 await drive(name,()=>session.turn({run:session.run,kind:'poll'}),command=>session.turn(command)); 112 }); 113 }finally{g.dispose();} 114 } 115 return {backend,checks}; 116 }