import {test} from 'node:test'; import assert from 'node:assert/strict'; import fs from 'node:fs'; import vm from 'node:vm'; import ts from '../apps/web/node_modules/typescript/lib/typescript.js'; function loadCatalog(file){ const code=ts.transpileModule(fs.readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText; const sandbox={exports:{},require:()=>({})}; vm.runInNewContext(code,sandbox); return sandbox.exports.zhTW||sandbox.exports.en; } const zhTW=loadCatalog('apps/web/src/locales/zh-TW.ts'); const en=loadCatalog('apps/web/src/locales/en.ts'); const catalogs={'zh-TW':zhTW,en}; const i18n={locale:'en',t:(key,params)=>{const message=catalogs[i18n.locale]?.[key]??key;return params?message.replace(/\{(\w+)\}/g,(m,name)=>Object.hasOwn(params,name)?String(params[name]):m):message},getLocale:()=>i18n.locale}; const raw=fs.readFileSync('apps/web/src/schedule.tsx','utf8'); const js=ts.transpileModule(raw,{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.React}}).outputText; const box={exports:{},require:()=>i18n};vm.runInNewContext(js,box);const {cronFromPreset,presetFromCron,defaultCronPreset,describeCron,describeCronHuman}=box.exports; test('fixed intervals retain exact elapsed units',()=>{ for (const unit of ['minutes','hours','days']) {const p={...defaultCronPreset(),freq:'Interval',n:45,unit};const cron=cronFromPreset(p);const restored=presetFromCron(cron);assert.equal(restored.n,45);assert.equal(restored.unit,unit);} }); test('unknown cron is preserved verbatim instead of silently rewritten',()=>{ for(const cron of ['0 0 9 * * 1','0 25 * * *','99 9 * * *','0 0 */3 * *','*/45 * * * *']){const p=presetFromCron(cron);assert.equal(p.freq,'Advanced');assert.equal(cronFromPreset(p),cron);} }); test('blank advanced expressions are never converted to a scheduled job',()=>assert.equal(cronFromPreset({...defaultCronPreset(),freq:'Advanced',cron:''}),'')); test('VNC paste delegates once to confirmed backend and rejects another source',async()=>{ const source=fs.readFileSync('apps/web/vnc.html','utf8').match(/'),undefined); assert.equal(sanitizeMarkdownUrl('/relative'),undefined); }); test('zh-TW and en catalogs share keys and placeholders',()=>{ const zhKeys=Object.keys(zhTW).sort(); const enKeys=Object.keys(en).sort(); assert.deepEqual(enKeys,zhKeys); for(const key of zhKeys){ const slots=s=>[...String(s).matchAll(/\{(\w+)\}/g)].map(m=>m[1]).sort(); assert.deepEqual(slots(en[key]),slots(zhTW[key]),key); assert.notEqual(String(en[key]).trim(),''); assert.notEqual(String(zhTW[key]).trim(),''); } }); test('describeCron and stored Chinese human strings follow the UI locale',()=>{ i18n.locale='en'; assert.equal(describeCron('0 9 * * *'),'Daily at 9:00 AM'); assert.equal(describeCron('0 9 * * 1-5'),'Weekdays at 9:00 AM'); assert.equal(describeCronHuman('每天 09:00'),'Daily at 9:00 AM'); assert.equal(describeCronHuman('每隔 45 分鐘(固定間隔)'),'Every 45 minutes (elapsed)'); i18n.locale='zh-TW'; assert.equal(describeCron('0 9 * * *'),'每天 09:00'); assert.equal(describeCronHuman('Daily at 9:00 AM'),'每天 09:00'); }); test('hanging up before microphone permission resolves releases the late stream',async()=>{ let grant; let stopped=0; const context={exports:{},navigator:{mediaDevices:{getUserMedia:()=>new Promise(resolve=>grant=resolve)}}}; vm.runInNewContext(audioJs,context); const audio=new context.exports.CallAudio({onCapture(){},onError(){}}); const starting=audio.start(); audio.stop(); grant({getTracks:()=>[{stop(){stopped++;}}]}); await starting; assert.equal(stopped,1); }); test('odd-sized audio frames stay sample-aligned instead of turning into noise',()=>{ const rendered=[]; const audio=new audioBox.exports.CallAudio({onCapture(){},onError(){}}); audio.context=fakePlaybackContext(rendered); const source=new Int16Array([1000,-1000,2000,-2000,3000,-3000,4000,-4000]); const bytes=new Uint8Array(source.buffer); let offset=0; for(const size of [3,5,7,1]){audio.play(bytes.slice(offset,offset+size).buffer);offset+=size} const played=rendered.flatMap(chunk=>[...chunk]); const expected=[...pcm16ToFloat(bytes.slice().buffer)]; assert.equal(played.length,expected.length); expected.forEach((value,index)=>assert.ok(Math.abs(played[index]-value)<1e-6,`sample ${index}`)); }); test('microphone frames are batched instead of sent one websocket message per block',()=>{ const sent=[]; const audio=new audioBox.exports.CallAudio({onCapture:pcm=>sent.push(pcm.length),onError(){}}); const block=new Float32Array(64); const blocksPerChunk=CAPTURE_CHUNK_SAMPLES/block.length; for(let i=0;i([\s\S]*?)<\/script>/)[1].replace(/import RFB[^;]+;/,''); const handlers={},options={},sent=[]; const keyboard={value:'',focused:false,focus(){this.focused=true},blur(){this.focused=false},setSelectionRange(){}}; const pointerEvents=[]; const canvas={style:{},closest:selector=>selector==='#screen'?{}:null,getBoundingClientRect:()=>({left:0,top:0,width:300,height:200})}; const elements={}; const element=id=>elements[id]||(elements[id]={style:{},attributes:{},setAttribute(k,v){this.attributes[k]=v},querySelectorAll(){return []}}); let rfb; class RFB{constructor(){rfb=this}addEventListener(){}focus(){keyboard.focused=false} _handleMouseMove(x,y){pointerEvents.push({type:'move',x,y})} _handleMouseButton(x,y,mask){pointerEvents.push({type:'button',x,y,mask})} _handleWheel(event){pointerEvents.push({type:'wheel',dx:event.deltaX,dy:event.deltaY})} } const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent:{postMessage:x=>sent.push(x)},addEventListener:(n,f,o)=>{handlers[n]=f;options[n]=o}}; vm.runInNewContext(source,{window,document:{location:{href:'http://localhost/vnc.html?view_only=false'},getElementById:id=>id==='mobile-keyboard'?keyboard:element(id),querySelector:selector=>selector==='#screen canvas'?canvas:null},navigator:{},RFB,setTimeout(){},clearTimeout(){}}); return {handlers,options,sent,keyboard,canvas,rfb,window,pointerEvents,elements}; } test('mobile tap focuses editable input before noVNC stops touch propagation; drag and multi-touch do not',()=>{ const {handlers:h,options,keyboard,canvas,rfb}=mobileViewer(); for(const name of ['touchstart','touchmove','touchend'])assert.equal(options[name].capture,true); const point={clientX:20,clientY:30}; h.touchstart({target:canvas,touches:[point]});h.touchend({touches:[]}); assert.equal(keyboard.focused,true);assert.equal(rfb.focusOnClick,false); keyboard.blur();h.touchstart({target:canvas,touches:[point]});h.touchmove({touches:[{clientX:50,clientY:30}]});h.touchend({touches:[]});assert.equal(keyboard.focused,false); h.touchstart({target:canvas,touches:[point,point]});h.touchend({touches:[]});assert.equal(keyboard.focused,false); rfb.viewOnly=true;h.touchstart({target:canvas,touches:[point]});h.touchend({touches:[]});assert.equal(keyboard.focused,false); }); test('mobile IME sends committed Chinese once and supports input-only backspace and Enter',()=>{ const {handlers:h,keyboard,sent}=mobileViewer(); sent.length=0; h.compositionstart({target:keyboard});keyboard.value+='中文'; h.input({target:keyboard,isComposing:true});assert.equal(sent.length,0); h.compositionend({target:keyboard});h.input({target:keyboard,isComposing:false}); assert.equal(sent.length,1);assert.equal(sent[0].text,'中文'); keyboard.value='';h.input({target:keyboard,inputType:'deleteContentBackward'}); let prevented=false;h.beforeinput({target:keyboard,inputType:'insertLineBreak',preventDefault(){prevented=true}}); assert.equal(prevented,true); assert.deepEqual(sent.map(x=>x.text||x.key),['中文','BackSpace','Return']); }); test('mobile keyboard is cleared and blurred when control is released; foreign messages cannot change control',()=>{ const {handlers:h,keyboard,sent,window}=mobileViewer(); keyboard.focus();keyboard.value+='secret'; h.message({origin:window.location.origin,source:{},data:{type:'lazyboy-view-only',viewOnly:true}}); assert.equal(keyboard.focused,true); h.message({origin:window.location.origin,source:window.parent,data:{type:'lazyboy-view-only',viewOnly:true}}); assert.equal(keyboard.focused,false);assert.equal(keyboard.value,'\u200b'); sent.length=0;keyboard.value+='blocked';h.input({target:keyboard});assert.equal(sent.length,0); }); function pointerButton(viewer,action){ viewer.handlers.click({target:{closest:()=>({dataset:{action}})}}); } function pointerTouch(viewer,name,points){ let prevented=false,stopped=false; viewer.handlers[name]({target:viewer.canvas,touches:points.map(([clientX,clientY])=>({clientX,clientY})),preventDefault(){prevented=true},stopImmediatePropagation(){stopped=true}}); return prevented&&stopped; } test('trackpad moves relative to cursor, clamps edges and taps without opening keyboard',()=>{ const v=mobileViewer();pointerButton(v,'mode'); assert.equal(pointerTouch(v,'touchstart',[[30,30]]),true); pointerTouch(v,'touchmove',[[60,40]]);pointerTouch(v,'touchend',[]); assert.equal(v.pointerEvents.length,1);assert.equal(v.pointerEvents[0].x,180);assert.ok(Math.abs(v.pointerEvents[0].y-110)<.001); assert.equal(v.keyboard.focused,false); pointerTouch(v,'touchstart',[[10,10]]);pointerTouch(v,'touchend',[]); assert.deepEqual(v.pointerEvents.slice(-2).map(e=>e.mask),[1,0]); pointerTouch(v,'touchstart',[[10,10]]);pointerTouch(v,'touchmove',[[1000,-1000]]);pointerTouch(v,'touchend',[]); assert.equal(v.pointerEvents.at(-1).x,299);assert.equal(v.pointerEvents.at(-1).y,0); pointerButton(v,'keyboard');assert.equal(v.keyboard.focused,true); }); test('two-finger trackpad scroll and right tap do not also left click',()=>{ const v=mobileViewer();pointerButton(v,'mode'); pointerTouch(v,'touchstart',[[20,20],[50,20]]);pointerTouch(v,'touchmove',[[20,80],[50,80]]);pointerTouch(v,'touchend',[]); assert.deepEqual(v.pointerEvents,[{type:'wheel',dx:-0,dy:-60}]); pointerTouch(v,'touchstart',[[20,20],[50,20]]);pointerTouch(v,'touchend',[[20,20]]);pointerTouch(v,'touchend',[]); assert.deepEqual(v.pointerEvents.slice(-2).map(e=>e.mask),[4,0]); }); test('drag is released on cancellation, mode switch, blur and control handoff',()=>{ for(const release of ['cancel','mode','blur','handoff']){ const v=mobileViewer();pointerButton(v,'drag'); assert.equal(v.pointerEvents.at(-1).mask,1); if(release==='cancel')v.handlers.touchcancel(); if(release==='mode')pointerButton(v,'mode'); if(release==='blur')v.handlers.blur(); if(release==='handoff')v.handlers.message({origin:v.window.location.origin,source:v.window.parent,data:{type:'lazyboy-view-only',viewOnly:true}}); assert.equal(v.pointerEvents.at(-1).mask,0,release); assert.equal(v.elements['pointer-drag'].attributes['aria-pressed'],'false'); } }); test('view-only pointer controls never send mouse input and trackpad asks for control',()=>{ const v=mobileViewer();pointerButton(v,'mode');v.rfb.viewOnly=true; for(const action of ['left','right','drag','up','down'])pointerButton(v,action); pointerTouch(v,'touchstart',[[20,20]]);pointerTouch(v,'touchmove',[[50,50]]);pointerTouch(v,'touchend',[]); assert.equal(v.pointerEvents.length,0); assert.equal(v.sent.at(-1).type,'lazyboy-request-control'); }); const monitorJs=ts.transpileModule(fs.readFileSync('apps/web/src/run-monitor.tsx','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText; const monitorBox={exports:{},require:name=>{ if(name==='react')return{useCallback:fn=>fn,useEffect:()=>{},useRef:()=>({current:null}),useState:()=>[null,()=>{}]}; if(name==='react/jsx-runtime')return{jsx:()=>null,jsxs:()=>null,Fragment:'Fragment'}; if(name==='./api')return{api:async()=>({activity:[]})}; if(name==='./i18n')return{t:i18n.t,getLocale:()=>i18n.locale}; throw new Error('unexpected import '+name); }}; vm.runInNewContext(monitorJs,monitorBox); const {formatElapsed,shortDuration,errorActions,errorTitle,trailText}=monitorBox.exports; const FAILURE_CODES=['interrupted','tool_timeout','model_key','model_quota','model_unknown','model_timeout','network','computer_gone','lease_lost','unknown']; test('run timings stay on one glanceable line',()=>{ assert.equal(formatElapsed(0),'0:00'); assert.equal(formatElapsed(9_500),'0:09'); assert.equal(formatElapsed(65_000),'1:05'); assert.equal(formatElapsed(3_725_000),'1:02:05'); assert.equal(formatElapsed(-5),'0:00'); assert.equal(formatElapsed(NaN),'0:00'); assert.equal(shortDuration(340),'340ms'); assert.equal(shortDuration(6_400),'6.4s'); assert.equal(shortDuration(65_000),'1:05'); assert.equal(shortDuration(null),''); assert.equal(shortDuration(-1),''); }); test('every failure code offers at least one action, in the right order',()=>{ const buttons=code=>errorActions(code).join(','); assert.equal(buttons('model_key'),'settings,retry'); assert.equal(buttons('model_unknown'),'settings,retry'); assert.equal(buttons('model_quota'),'retry,settings'); assert.equal(buttons('model_timeout'),'retry,settings'); assert.equal(buttons('network'),'retry,settings'); assert.equal(buttons('computer_gone'),'screen,retry'); assert.equal(buttons('tool_timeout'),'screen,retry'); assert.equal(buttons('interrupted'),'screen,retry'); assert.equal(buttons('lease_lost'),'retry'); for(const code of [...FAILURE_CODES,'made_up',undefined,null]){ const actions=errorActions(code); assert.ok(actions.length>0,String(code)); assert.ok(actions.every(action=>['retry','screen','settings'].includes(action)),code); assert.equal(new Set(actions).size,actions.length,'no duplicate buttons for '+code); } }); test('failure titles resolve in both catalogs and never repeat themselves',()=>{ for(const locale of Object.keys(catalogs)){ i18n.locale=locale; const titles=FAILURE_CODES.map(code=>errorTitle(code)); for(const [index,title] of titles.entries()){ assert.notEqual(title,title.startsWith('errorTitle')?title:`${title}-missing`,FAILURE_CODES[index]); assert.ok(!title.startsWith('errorTitle'),`${locale} ${FAILURE_CODES[index]} has no copy`); assert.ok(title.trim().length>0); } assert.equal(new Set(titles).size,titles.length,locale); assert.ok(errorTitle('made_up').length>0); } i18n.locale='en'; }); test('trail lines read as sentences with the detail a stuck run needs',()=>{ assert.equal(trailText({id:1,kind:'model',createdAt:'',turn:3,elapsedMs:6400,text:'先打開網頁'}),'turn 3, thought for 6.4s — 先打開網頁'); assert.match(trailText({id:2,kind:'tool',createdAt:'',step:'browser: click #12',status:'ok',elapsedMs:400}),/browser: click #12 · ok · 400ms/); assert.match(trailText({id:3,kind:'tool',createdAt:'',step:'shell: npm test',status:'timed_out',snippet:'killed after 150s'}),/timed out — killed after 150s/); assert.match(trailText({id:4,kind:'run',createdAt:'',event:'started',task:'整理下載資料'}),/整理下載資料/); assert.match(trailText({id:5,kind:'run',createdAt:'',event:'completed',turns:9}),/Done in 9 turns/); assert.match(trailText({id:6,kind:'run',createdAt:'',event:'waiting_input',reason:'登入'}),/Waiting for you: 登入/); assert.match(trailText({id:7,kind:'run',createdAt:'',event:'retry'}),/Re-queued/); assert.match(trailText({id:8,kind:'retry',createdAt:'',attempt:2,gaveUp:true,error:'429 rate limit'}),/attempt 2 failed, retrying · gave up — 429 rate limit/); assert.match(trailText({id:9,kind:'notice',createdAt:'',text:'這輪不需要電腦'}),/這輪不需要電腦/); }); test('the bubble reads the run activity endpoint the API actually mounts',()=>{ const monitor=fs.readFileSync('crates/api/src/monitor.rs','utf8'); assert.match(monitor,/\.route\("\/api\/runs\/\{id\}\/activity", get\(activity\)\)/); assert.match(monitor,/\.route\("\/api\/runs\/\{id\}\/retry", post\(retry\)\)/); const app=fs.readFileSync('apps/web/src/App.tsx','utf8'); assert.match(app,/chip\.kind==="error"\?/); assert.match(app,/errorActions\(chip\.code\)/); assert.match(app,/`\/api\/runs\/\$\{runId\}\/retry`,\{method:"POST",body:"\{\}"\}/); assert.match(app,/