lazyBoy/tests/frontend.test.mjs

450 lines
29 KiB
JavaScript
Raw Normal View History

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';
2026-09-06 05:39:06 +00:00
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;
2026-09-06 05:39:06 +00:00
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(/<script type="module">([\s\S]*?)<\/script>/)[1].replace(/import RFB[^;]+;/,'');
const handlers={},sent=[];class RFB{addEventListener(){} focus(){} sendKey(){}}
const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent:{postMessage:x=>sent.push(x)},addEventListener:(n,f)=>handlers[n]=f};
vm.runInNewContext(source,{window,document:{location:{href:'http://localhost/vnc.html?view_only=false'},getElementById:()=>({}),querySelector:()=>null},navigator:{clipboard:{readText:async()=>'中文\nhello'}},RFB,setTimeout(){},clearTimeout(){}});
await handlers.keydown({ctrlKey:true,code:'KeyV',preventDefault(){},stopImmediatePropagation(){}});
assert.equal(sent.filter(x=>x.type==='lazyboy-paste-text').length,1);
assert.equal(sent.at(-1).text,'中文\nhello');
handlers.message({origin:'http://localhost',source:{},data:{type:'lazyboy-host-clipboard',text:'bad'}});assert.equal(sent.at(-1).text,'中文\nhello');
});
test('saved login rejects HTTP, lookalike hosts, and missing host before touching fields',()=>{
const py=fs.readFileSync('crates/control/src/cdp.py','utf8');const expression=py.match(/FILL_LOGIN_JS = r"""([\s\S]*?)"""/)[1];
for(const [protocol,hostname,expectedHost] of [['https:','evil.example','bank.example'],['http:','bank.example','bank.example'],['https:','bank.example.evil','bank.example'],['https:','bank.example','']]) {
const evaluate=vm.runInNewContext(`(${expression})`,{location:{protocol,hostname},document:{querySelectorAll(){throw Error('must not touch fields')}}});
assert.equal(evaluate({username:'u',password:'secret',expectedHost}).ok,false);
}
});
2026-09-05 14:59:07 +00:00
const mdJs=ts.transpileModule(fs.readFileSync('apps/web/src/markdown.tsx','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
const mdBox={exports:{},require:(name)=>{
if(name==='react')return{useCallback:fn=>fn,useRef:()=>({current:null}),useState:()=>[false,()=>{}],memo:fn=>fn};
if(name==='react/jsx-runtime')return{jsx:()=>null,jsxs:()=>null,Fragment:'Fragment'};
if(name==='react-markdown'||name==='remark-gfm'||name==='remark-breaks')return{default:()=>null};
if(name==='./i18n')return{t:key=>key};
if(name.endsWith('.css'))return{};
throw new Error('unexpected import '+name);
}};
vm.runInNewContext(mdJs,mdBox);
const {sanitizeMarkdownUrl}=mdBox.exports;
2026-09-06 03:43:38 +00:00
const audioJs=ts.transpileModule(fs.readFileSync('apps/web/src/call-audio.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const audioBox={exports:{},require:()=>{throw new Error('unexpected import')}};
vm.runInNewContext(audioJs,audioBox);
2026-09-06 05:39:06 +00:00
const {floatToPcm16,pcm16ToFloat,resample,VOICE_SAMPLE_RATE,CAPTURE_CHUNK_SAMPLES}=audioBox.exports;
function fakePlaybackContext(rendered){
return {
sampleRate:VOICE_SAMPLE_RATE,currentTime:0,
createBuffer:(_channels,length,rate)=>{const data=new Float32Array(length);rendered.push(data);return {length,duration:length/rate,getChannelData:()=>data}},
createBufferSource:()=>({buffer:null,connect(){},start(){},stop(){}}),
};
}
2026-09-06 03:43:38 +00:00
test('pcm16 roundtrip keeps amplitude sign and resample identity at 24 kHz',()=>{
const source=new Float32Array([0,0.5,-0.5,1,-1]);
const bytes=floatToPcm16(source);
const back=pcm16ToFloat(bytes.buffer);
assert.equal(back.length,source.length);
assert.ok(back[1]>0.49&&back[1]<0.51);
assert.ok(back[2]< -0.49&&back[2]> -0.51);
const same=resample(source,VOICE_SAMPLE_RATE,VOICE_SAMPLE_RATE);
assert.equal(same,source);
const down=resample(new Float32Array([0,1,0,1]),48000,24000);
assert.equal(down.length,2);
});
2026-09-05 14:59:07 +00:00
test('markdown links only keep http(s), mailto, tel, and in-page hashes',()=>{
assert.equal(sanitizeMarkdownUrl('https://example.com/docs'),'https://example.com/docs');
assert.equal(sanitizeMarkdownUrl('mailto:hi@example.com'),'mailto:hi@example.com');
assert.equal(sanitizeMarkdownUrl('#section'),'#section');
assert.equal(sanitizeMarkdownUrl('javascript:alert(1)'),undefined);
assert.equal(sanitizeMarkdownUrl('data:text/html,<script>alert(1)</script>'),undefined);
assert.equal(sanitizeMarkdownUrl('/relative'),undefined);
});
2026-09-06 03:43:38 +00:00
2026-09-06 05:39:06 +00:00
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');
});
2026-09-06 03:43:38 +00: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);
});
2026-09-06 05:39:06 +00:00
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<blocksPerChunk-1;i++)audio.queueCapture(block);
assert.deepEqual(sent,[]);
audio.queueCapture(block);
assert.deepEqual(sent,[CAPTURE_CHUNK_SAMPLES*2]);
});
2026-09-06 14:09:17 +00:00
function mobileViewer(){
const source=fs.readFileSync('apps/web/vnc.html','utf8').match(/<script type="module">([\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})}
}
2026-09-07 09:05:14 +00:00
const body={className:'',classList:{tokens:new Set(),toggle(name,force){if(force===undefined)force=!this.tokens.has(name);if(force)this.tokens.add(name);else this.tokens.delete(name);body.className=[...this.tokens].join(' ')}}};
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},matchMedia:()=>({matches:false,addEventListener(){}})};
vm.runInNewContext(source,{window,document:{body,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,body};
2026-09-06 14:09:17 +00:00
return {handlers,options,sent,keyboard,canvas,rfb,window,pointerEvents,elements};
}
2026-09-07 09:05:14 +00:00
test('direct mobile taps position without unexpectedly opening the keyboard',()=>{
2026-09-06 14:09:17 +00:00
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:[]});
2026-09-07 09:05:14 +00:00
assert.equal(keyboard.focused,false);assert.equal(rfb.focusOnClick,false);
2026-09-06 14:09:17 +00:00
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;
2026-09-07 09:05:14 +00:00
viewer.handlers[name]({target:{closest:selector=>selector==='#trackpad'?{}:null},touches:points.map(([clientX,clientY])=>({clientX,clientY})),preventDefault(){prevented=true},stopImmediatePropagation(){stopped=true}});
2026-09-06 14:09:17 +00:00
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');
});
2026-09-07 09:05:14 +00:00
test('trackpad mode swallows screen taps so fingers on the picture do not click',()=>{
const v=mobileViewer();pointerButton(v,'mode');
let prevented=false,stopped=false;
v.handlers.touchstart({target:v.canvas,touches:[{clientX:20,clientY:30}],preventDefault(){prevented=true},stopImmediatePropagation(){stopped=true}});
assert.equal(prevented&&stopped,true);
assert.equal(v.pointerEvents.length,0);
assert.equal(v.keyboard.focused,false);
});
test('coarse pointers start in trackpad mode with a body class for independent controls',()=>{
const source=fs.readFileSync('apps/web/vnc.html','utf8').match(/<script type="module">([\s\S]*?)<\/script>/)[1].replace(/import RFB[^;]+;/,'');
const body={className:'',classList:{tokens:new Set(),toggle(name,force){if(force)this.tokens.add(name);else this.tokens.delete(name);body.className=[...this.tokens].join(' ')}}};
const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent:{postMessage(){}},addEventListener(){},matchMedia:()=>({matches:true,addEventListener(){}})};
const elements={}; const element=id=>elements[id]||(elements[id]={style:{},attributes:{},hidden:true,setAttribute(k,v){this.attributes[k]=v},querySelectorAll(){return []}});
class RFB{constructor(){} addEventListener(){} focus(){}}
vm.runInNewContext(source,{window,document:{body,location:{href:'http://localhost/vnc.html?view_only=false'},getElementById:id=>element(id),querySelector:()=>null},navigator:{},RFB,setTimeout(){},clearTimeout(){}});
assert.equal(body.className,'touch-ui');
assert.equal(elements.trackpad.hidden,false);
});
test('mobile shortcut row sends modifiers and hides with the keyboard',()=>{
const v=mobileViewer();
pointerButton(v,'keyboard');
assert.equal(v.keyboard.focused,true);
assert.equal(v.elements['keyboard-shortcuts'].hidden,false);
v.handlers.click({target:{closest:()=>({dataset:{action:'modifier',key:'ctrl'}})}});
v.handlers.click({target:{closest:()=>({dataset:{action:'key',key:'c'}})}});
assert.equal(v.sent.at(-1).type,'lazyboy-mobile-key');
assert.equal(v.sent.at(-1).key,'ctrl+c');
v.handlers.click({target:{closest:()=>({dataset:{action:'hide-keyboard'}})}});
assert.equal(v.keyboard.focused,false);
assert.equal(v.elements['keyboard-shortcuts'].hidden,true);
});
test('status and HUD sit outside the remote pixels; screenshots stay opt-in',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
2026-09-07 13:08:10 +00:00
assert.doesNotMatch(app,/RunStatus/);
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}><Avatar/);
2026-09-07 09:05:14 +00:00
assert.match(app,/\{!computerOpen&&hud\}<div className="preview">/);
assert.match(app,/--visible-height/);
2026-09-07 13:08:10 +00:00
assert.match(app,/<div className=\{`composer-dock \$\{statusMembers\.length\?"has-status":""\}`\}>\s*\{error&&<div className="error-banner"/);
2026-09-07 09:05:14 +00:00
const css=fs.readFileSync('apps/web/src/computer.css','utf8');
2026-09-07 13:08:10 +00:00
assert.doesNotMatch(css,/\.run-status/);
2026-09-07 09:05:14 +00:00
assert.match(css,/\.computer-hud\{position:static/);
2026-09-07 13:08:10 +00:00
const chat=fs.readFileSync('apps/web/src/chat.css','utf8');
assert.match(chat,/\.composer-dock\{position:relative/);
assert.doesNotMatch(chat,/\.composer-dock\{position:absolute/);
assert.match(chat,/\.messages\{min-width:0;padding-bottom:24px/);
2026-09-07 09:05:14 +00:00
const tools=fs.readFileSync('crates/api/src/tools.rs','utf8');
assert.match(tools,/screenshots are opt-in with observe:true/);
assert.match(tools,/if args\.get\("observe"\)\.and_then\(Value::as_bool\) != Some\(true\)/);
const runs=fs.readFileSync('crates/api/src/runs.rs','utf8');
assert.match(runs,/Never silently stop/);
assert.match(runs,/let goal_mode = goal_mode \|\| !chat_only/);
});
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: 登入/);
2026-09-07 09:05:14 +00:00
assert.match(trailText({id:10,kind:'run',createdAt:'',event:'paused',reason:'已達本輪執行上限'}),/Paused: 已達本輪執行上限/);
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,/<RunProbe runId=\{chip\.runId\|\|null\} align="end" label=\{t\("errorDetails"\)\}/);
assert.match(app,/<RunProbe runId=\{member\.id===computer\.botId\?computer\.busyRunId:null\}><Avatar/);
const probe=fs.readFileSync('apps/web/src/run-monitor.tsx','utf8');
assert.match(probe,/`\/api\/runs\/\$\{runId\}\/activity\$\{after\}`/);
2026-09-07 13:08:10 +00:00
assert.doesNotMatch(probe,/export function RunStatus/);
assert.match(fs.readFileSync('apps/web/src/main.tsx','utf8'),/import "\.\/monitor\.css";/);
});
2026-09-07 13:08:10 +00:00
const liveJs=ts.transpileModule(fs.readFileSync('apps/web/src/live.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const liveBox={exports:{}};vm.runInNewContext(liveJs,liveBox);
const {sessionEventsUrl,subscribeToSession,SESSION_EVENT_TYPES,createCoalescer}=liveBox.exports;
test('the browser listens for every event kind the api can append',()=>{
const rust=['crates/api/src/runs.rs','crates/api/src/sessions.rs','crates/api/src/schedules.rs','crates/api/src/voice_call.rs'].map(file=>fs.readFileSync(file,'utf8')).join('\n');
const emitted=[...rust.matchAll(/["']((?:message|run|session)\.[a-z_]+)["']/g)].map(match=>match[1]);
assert.ok(emitted.length>=4,'the api should emit session events');
for(const kind of new Set(emitted))assert.ok(SESSION_EVENT_TYPES.includes(kind),`${kind} is emitted but never listened for`);
});
test('the live feed reads its own session, drops replays, and survives a bad frame',()=>{
const listeners={};let closed=0,url='';
const source={addEventListener:(type,listener)=>{listeners[type]=listener},close:()=>{closed+=1}};
const seen=[],statuses=[];
const feed=subscribeToSession('s/1',event=>seen.push(event),{source:candidate=>{url=candidate;return source},onStatus:connected=>statuses.push(connected)});
assert.equal(url,'/api/sessions/s%2F1/events');
assert.ok(listeners['message.created']&&listeners['run.completed']&&listeners['session.cleared'],'every kind gets a listener');
listeners['message.created']({lastEventId:'5',data:'{"seq":5,"role":"assistant"}'});
// The event object was built inside a vm realm, so compare fields, not prototypes.
assert.equal(seen.length,1);
assert.equal(seen[0].kind,'message.created');assert.equal(seen[0].id,5);
assert.equal(seen[0].payload.seq,5);assert.equal(seen[0].payload.role,'assistant');
listeners['message.created']({lastEventId:'5',data:'{}'});
listeners['message.created']({lastEventId:'4',data:'{}'});
assert.equal(seen.length,1,'a reconnect replay must not be applied twice');
listeners['run.started']({lastEventId:'6',data:'not json'});
assert.equal(seen.length,2);assert.equal(seen[1].kind,'run.started');assert.equal(seen[1].id,6);
assert.equal(Object.keys(seen[1].payload).length,0,'a malformed frame still reports the event');
listeners['open']();listeners['error']();
assert.deepEqual(statuses,[true,false]);
feed.close();feed.close();
assert.equal(closed,1,'close is idempotent');
assert.equal(sessionEventsUrl('abc'),'/api/sessions/abc/events');
});
test('a burst of session events settles into one refresh',()=>{
const timers=[];let runs=0;
const schedule=(callback,ms)=>{timers.push({callback,ms});return timers.length-1};
const dismiss=handle=>{if(timers[handle])timers[handle].cancelled=true};
const coalescer=createCoalescer(()=>{runs+=1},120,schedule,dismiss);
coalescer.kick();coalescer.kick();coalescer.kick();
assert.equal(runs,0);
assert.equal(timers.filter(entry=>!entry.cancelled).length,1,'one pending run');
assert.equal(timers[0].ms,120);
timers[0].callback();
assert.equal(runs,1);
coalescer.kick();coalescer.cancel();coalescer.cancel();
assert.equal(timers[1].cancelled,true);
assert.equal(runs,1,'a cancelled run never fires');
coalescer.kick();timers[2].callback();
assert.equal(runs,2,'the coalescer is reusable after a cancel');
});
test('chat follows the event stream instead of a fixed two second poll',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/subscribeToSession\(activeSessionId,\(\)=>settle\.kick\(\)/);
assert.match(app,/createCoalescer\(\(\)=>\{if\(!document\.hidden\)refresh\(\)\.catch\(\(\)=>\{\}\)\},EVENT_SETTLE_MS\)/);
assert.match(app,/document\.addEventListener\("visibilitychange",resume\)/);
assert.doesNotMatch(app,/const timer=setInterval\(\(\)=>\{refresh\(\)/,'the 2s transcript poll should be gone');
assert.match(app,/const heartbeat=window\.setInterval\(\(\)=>\{const beat=roomsRef\.current[\s\S]*\},HEARTBEAT_MS\)/,'the heartbeat keeps its own minute cadence');
});