lazyBoy/tests/frontend.test.mjs

715 lines
48 KiB
JavaScript

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 source=fs.readFileSync(file,'utf8');
// transpileModule prints output even for a catalog that does not parse, so a
// missing comma silently loads a shorter catalog and every key check below
// still passes. Ask the parser, which actually keeps its errors.
const parsed=ts.createSourceFile(file,source,ts.ScriptTarget.ESNext,true,ts.ScriptKind.TS);
assert.deepEqual((parsed.parseDiagnostics||[]).map(d=>ts.flattenDiagnosticMessageText(d.messageText,' ')),[],`${file} must parse`);
const code=ts.transpileModule(source,{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(/<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');
});
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;
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);
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(){}}),
};
}
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);
});
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);
});
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<blocksPerChunk-1;i++)audio.queueCapture(block);
assert.deepEqual(sent,[]);
audio.queueCapture(block);
assert.deepEqual(sent,[CAPTURE_CHUNK_SAMPLES*2]);
});
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})}
}
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};
return {handlers,options,sent,keyboard,canvas,rfb,window,pointerEvents,elements};
}
test('direct mobile taps position without unexpectedly opening the keyboard',()=>{
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,false);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:{closest:selector=>selector==='#trackpad'?{}:null},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');
});
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 sits outside the remote pixels; screenshots stay opt-in',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.doesNotMatch(app,/RunStatus/);
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}><Avatar/);
assert.match(app,/--visible-height/);
assert.match(app,/<div className=\{`composer-dock \$\{statusMembers\.length\?"has-status":""\}`\}>\s*\{error&&<div className="error-banner"/);
const css=fs.readFileSync('apps/web/src/computer.css','utf8');
assert.doesNotMatch(css,/\.run-status/);
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/);
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: 登入/);
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\}`/);
assert.doesNotMatch(probe,/export function RunStatus/);
assert.match(fs.readFileSync('apps/web/src/main.tsx','utf8'),/import "\.\/monitor\.css";/);
});
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,event=>\{[^}]*settle\.kick\(\)\}/,'every non-streaming event kicks the settle coalescer');
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');
});
// The screen veil and the frame that survives it: both used to be driven by a
// timer plus "null the url", which is what made booting and handing over feel
// like a stall instead of a gesture.
const handoffJs=ts.transpileModule(fs.readFileSync('apps/web/src/handoff.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const handoffBox={exports:{},require:()=>({})};vm.runInNewContext(handoffJs,handoffBox);
const {viewerPath,keepScreenUrl,handoffRemaining,nextVeil,viewOnlyFor,HANDOFF_MS,HANDOFF_MIN_MS,VEIL_FADE_MS}=handoffBox.exports;
test('the viewer path is stable, so a frame can mount before the desktop answers',()=>{
assert.equal(viewerPath('bot 7'),'/view/bot%207/vnc.html');
assert.equal(viewerPath('a/b'),'/view/a%2Fb/vnc.html');
assert.equal(viewerPath('bot'),viewerPath('bot'),'no nonce, or the frame would remount every poll');
});
test('the path the frame mounts on is the byte the api hands back',()=>{
// The viewer mounts on the client's own path while the desktop is coming up,
// then adopts the url from the status round trip. If the two ever differ the
// iframe remounts mid-boot, which is the black flash this whole path exists
// to avoid, so the invariant is pinned to the server's own format string.
const routes=fs.readFileSync('crates/api/src/routes.rs','utf8');
const served=(routes.match(/"url": format!\("([^"]+)"\)/)||[])[1];
assert.equal(served,'/view/{id}/vnc.html');
const id='6f1d3a0e-2b7c-4d5e-8f90-1a2b3c4d5e6f';
assert.equal(viewerPath(id),served.replace('{id}',id));
});
test('pixels are dropped only for a computer that is really gone',()=>{
const url='/view/bot/vnc.html';
for(const state of ['booting','suspended','running'])assert.equal(keepScreenUrl(url,null,state),url,state+' must keep its VNC session');
for(const state of ['stopped','error'])assert.equal(keepScreenUrl(url,null,state),null,state+' has no desktop to keep');
assert.equal(keepScreenUrl(null,null,'running'),null,'nothing was ever mounted');
assert.equal(keepScreenUrl(url,'/other','booting'),'/other','a fresh url still wins');
assert.equal(keepScreenUrl(null,url,'booting'),url,'the first url arrives while booting');
});
test('a handoff holds one visible beat, never the whole round trip',()=>{
assert.ok(HANDOFF_MIN_MS<HANDOFF_MS,'the beat needs a floor and a ceiling');
assert.ok(VEIL_FADE_MS<=HANDOFF_MIN_MS,'the fade has to finish inside the beat');
assert.ok(HANDOFF_MS<=1_000,'a reply that never lands cannot strand the mascot');
assert.equal(handoffRemaining(1_000,1_000),HANDOFF_MIN_MS,'a reply this instant still owes the beat');
assert.equal(handoffRemaining(1_000,1_000+HANDOFF_MIN_MS),0,'served');
assert.equal(handoffRemaining(1_000,1_000+(HANDOFF_MIN_MS+HANDOFF_MS)/2),0,'past the floor is never a wait');
assert.equal(handoffRemaining(1_000,1_000+HANDOFF_MS),0);
const midway=handoffRemaining(1_000,1_000+HANDOFF_MIN_MS-10);
assert.equal(midway,10,'the remaining beat shrinks with the clock');
});
test('the veil fades in with its label and back out through the same mascot',()=>{
const empty={label:null,leaving:false};
assert.equal(nextVeil(null,empty),empty,'with nothing to say there is nothing to show');
const shown=nextVeil('換手中',empty);
assert.equal(shown.label,'換手中');assert.equal(shown.leaving,false);
assert.equal(nextVeil('換手中',shown),shown,'a repeated status must not restart the animation');
const leaving=nextVeil(null,shown);
assert.equal(leaving.label,'換手中','the label is held while it fades');assert.equal(leaving.leaving,true);
assert.equal(nextVeil(null,leaving),leaving,'it leaves exactly once');
const replaced=nextVeil('喚醒中',shown);
assert.equal(replaced.label,'喚醒中');assert.equal(replaced.leaving,false,'a new status replaces the label');
const revived=nextVeil('喚醒中',leaving);
assert.equal(revived.label,'喚醒中');assert.equal(revived.leaving,false,'and pulls it back out of the fade');
});
test('only a human moves the mouse, and the veil never holds it back',()=>{
assert.equal(viewOnlyFor('user'),false);
for(const holder of ['none','bot'])assert.equal(viewOnlyFor(holder),true,holder);
});
test('shared input stays interactive across agent ownership and pause states',()=>{
for(const holder of ['none','bot','user'])assert.equal(viewOnlyFor(holder,true),false,holder);
});
test('taking the screen flips the mouse on the click, not on the reply',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
const body=app.slice(app.indexOf('async function setControl('));
const flip=body.indexOf('controlHolder:holder');
const gate=body.indexOf('pushViewOnly(viewOnlyFor(holder,computer.sharedInput))');
const post=body.indexOf('await api(`/api/computer/${botId}/${holder==="user"?"takeover":"release"}`');
const readBack=body.indexOf('finally{');
assert.ok(flip>0&&gate>0&&post>0&&readBack>0,'optimistic flip, gate, request, and read back all present');
assert.ok(flip<post&&gate<post,'badge and input move before the server answers');
assert.ok(readBack>post,'the truth is read back after the request');
assert.match(body.slice(readBack),/await refresh\(\)/);
assert.match(body,/if\(!botId\|\|controlBusyRef\.current\)return/,'a double click cannot fight itself');
assert.match(app,/if\(computer\.sharedInput\)\{pushViewOnly\(!desktopInteractive\);return\}void setControl\("user"\)/);
assert.match(app,/if\(expectedHolderRef\.current&&status\.controlHolder===expectedHolderRef\.current\)/,'agreement, not a timer, ends the handoff');
assert.match(app,/onClick=\{onTakeOver\}/);
assert.match(app,/onClick=\{onRelease\}/);
assert.doesNotMatch(app,/action\(\(\)=>api\(`\/api\/computer\/[^`]*takeover/,'takeover no longer rides the global busy path');
assert.equal((app.match(/\/api\/computer\/\$\{[^}]*\}\/(takeover|release)/g)||[]).length,0,'every handoff goes through setControl');
assert.equal((app.match(/holder==="user"\?"takeover":"release"/g)||[]).length,1,'one request path, one owner of it');
});
test('shared CUA desktops hide takeover and keep the mouse live',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/sharedInput:true/,'the first paint already assumes shared input');
assert.match(app,/if\(!computer\.sharedInput\)await setControl\("user",id\)/,'opening the login screen must not pause a shared run');
assert.match(app,/if\(active&&!computer\.sharedInput\)void setControl\("user",active\.id\)/,'a call only takes exclusive control on older exclusive desktops');
assert.match(app,/computer\.sharedInput\?\(computer\.takeoverRequested\|\|computer\.controlHolder==="user"\?<button className="primary" disabled=\{busy\|\|pending\} onClick=\{onRelease\}>\{t\("doneContinue"\)\}<\/button>:null\)/);
assert.match(app,/computer\.sharedInput\?t\("sharedDesktop"\)/);
assert.match(app,/computer\.sharedInput\?t\("sharedNeedsUser"/);
assert.match(app,/takeover=\{computer\.takeoverRequested\|\|\(!computer\.sharedInput&&computer\.controlHolder==="user"\)\}/);
const vnc=fs.readFileSync('apps/web/vnc.html','utf8');
assert.match(vnc,/rfb\.viewOnly = true;/,'a dropped RFB is muted locally');
assert.doesNotMatch(vnc,/disconnect[\s\S]{0,400}applyViewOnly\(true\)/,'disconnect must not overwrite host intent');
});
test('the waiting veil covers the desktop the way it always did',()=>{
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
assert.match(app,/<div className="preview">\{computerOpen\?<EmptyComputer state=\{computer\.state\}\/>:frame\}\{!computerOpen&&hud\}<\/div>/);
assert.match(app,/<div className="overlay-desktop">\{frame\}\{hud\}<\/div>/);
assert.match(app,/const hud=paneBot&&veil\.label\?<ComputerHud bot=\{paneBot\} label=\{veil\.label\} leaving=\{veil\.leaving\}\/>:null/);
const css=fs.readFileSync('apps/web/src/computer.css','utf8');
// Both parents are what make inset:0 cover the desktop rather than the page.
assert.match(css,/\.computer-part \.preview\{position:relative/);
assert.match(css,/\.overlay-screen \.overlay-desktop\{position:relative/);
// These values were compared against main in a real browser: rendered pixel
// for pixel identical, so drift here means the veil visibly changed.
const hud=css.match(/^\.computer-hud\{([^}]*)\}/m)[1];
for(const rule of ['position:absolute','inset:0','z-index:3','display:grid','align-content:center','justify-items:center','gap:14px','padding:10px 12px 8px','border-radius:inherit','background:radial-gradient(ellipse at center,#142722e8,#101012ed)','backdrop-filter:blur(8px)','pointer-events:none'])assert.ok(hud.includes(rule),`the veil needs ${rule}`);
assert.match(css,/\.computer-hud\.is-leaving\{opacity:0\}/);
assert.match(css,/\.computer-hud\{[^}]*transition:opacity \.22s ease\}/,'it fades instead of blinking off');
// main drew the label as static mint text; the shimmer belongs to the chat
// working label, and the veil keeps the mascot as the only moving part.
const label=css.match(/^\.computer-hud-label\{([^}]*)\}/m)[1];
assert.ok(!label.includes('animation'),'the veil label does not animate');
assert.ok(label.includes('color:#c9ddd5')&&label.includes('font-size:12px')&&label.includes('text-align:center')&&label.includes('max-width:90%'));
assert.ok(!/\.computer-hud[^{]*\{[^}]*working-shimmer/.test(css),'nothing shimmers inside the veil');
assert.match(css,/\.computer-signal\{[^}]*width:64px[^}]*animation:monitor-breathe/);
assert.match(css,/\.computer-signal::before\{[^}]*monitor-orbit/);
const responsive=fs.readFileSync('apps/web/src/responsive.css','utf8');
assert.match(responsive,/prefers-reduced-motion:reduce\)\{\.computer-signal,\.computer-signal::before,\.computer-signal-face i[^}]*animation:none/,'reduced motion still has to still the mascot');
});
test('a desktop that is still starting is dialled again fast, a dropped session calmly',()=>{
const source=fs.readFileSync('apps/web/vnc.html','utf8').match(/<script type="module">([\s\S]*?)<\/script>/)[1].replace(/import RFB[^;]+;/,'');
// Any element the viewer touches answers, so the disconnect path runs the
// same way it does in a browser without a DOM to stand in.
const element=()=>new Proxy({style:{},classList:{toggle(){}},dataset:{},hidden:false,textContent:''},{get:(node,key)=>key in node?node[key]:()=>undefined,set:(node,key,value)=>{node[key]=value;return true}});
const instances=[],scheduled=[];
class RFB{constructor(){this._handlers={};instances.push(this._handlers);}
addEventListener(name,handler){(this._handlers[name]||(this._handlers[name]=[])).push(handler);} focus(){} blur(){} sendKey(){}}
const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent:{postMessage(){}},addEventListener(){}};
vm.runInNewContext(source,{window,document:{location:{href:'http://localhost/vnc.html?view_only=false'},getElementById:()=>element(),querySelector:()=>null},navigator:{clipboard:{}},RFB,setTimeout:(fn,ms)=>{scheduled.push(ms);return scheduled.length},clearTimeout(){}});
const fire=(name,event)=>{for(const handlers of instances)(handlers[name]||[]).forEach(handler=>handler(event||{}));};
assert.equal(instances.length,1,'the viewer dials once on load');
fire('disconnect',{clean:false});
assert.equal(scheduled.at(-1),350,'a desktop that has never answered is retried eagerly');
fire('connect');
fire('disconnect',{clean:true});
assert.equal(scheduled.at(-1),1500,'a session that was really there is retried calmly');
});
for(const hostUpdatesDuringReconnect of [false,true])test(`reconnect preserves shared input (host update during reconnect: ${hostUpdatesDuringReconnect})`,()=>{
const vnc=fs.readFileSync('apps/web/vnc.html','utf8');
// Run the viewer rather than read it. The host hands the mouse over while the
// viewer sits between two sessions: exactly the moment a fresh RFB used to
// fall back to its view-only default and leave a taken-over desktop deaf with
// the veil already lifted. Only the gate message arriving is real here.
const body=vnc.match(/<script type="module">([\s\S]*?)<\/script>/)[1].replace(/import RFB[^;]+;/,'');
const element=()=>new Proxy({style:{},classList:{toggle(){}},dataset:{},hidden:false,textContent:''},{get:(node,key)=>key in node?node[key]:()=>undefined,set:(node,key,value)=>{node[key]=value;return true}});
const instances=[],scheduled=[],messages=[];
class RFB{constructor(){this._handlers={};instances.push(this);}addEventListener(name,handler){(this._handlers[name]||(this._handlers[name]=[])).push(handler);}focus(){this.focused=true;}blur(){}}
const parent={postMessage(){}};
const window={location:{pathname:'/vnc.html',protocol:'http:',host:'localhost',origin:'http://localhost',hash:''},parent,addEventListener(name,handler){if(name==='message')messages.push(handler);}};
vm.runInNewContext(body,{window,document:{location:{href:'http://localhost/vnc.html'},getElementById:()=>element(),querySelector:()=>null},navigator:{clipboard:{}},RFB,setTimeout:(fn)=>{scheduled.push(fn);return scheduled.length},clearTimeout(){}});
const fire=(name,event)=>instances.forEach(rfb=>(rfb._handlers[name]||[]).forEach(handler=>handler(event||{})));
assert.ok(messages.length,'the viewer listens for the host gate');
const allowInput=()=>messages.forEach(handler=>handler({origin:'http://localhost',source:parent,data:{type:'lazyboy-view-only',viewOnly:false}}));
allowInput();
fire('disconnect',{clean:false});
if(hostUpdatesDuringReconnect)allowInput();
scheduled.at(-1)();
fire('connect');
assert.equal(instances.length,2,'the retry dialed a new session');
assert.equal(instances.at(-1).viewOnly,false,'a handoff made mid-reconnect still owns the new session');
assert.equal(instances.at(-1).focused,true,'taking over lands the first keystroke');
});
// Who speaks in a room is decided by three small pure functions, so the `@`
// list is run rather than read: which token the caret is writing, who that token
// could mean, and what the composer holds after a pick.
const mentionsJs=ts.transpileModule(fs.readFileSync('apps/web/src/mentions.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const mentionsBox={exports:{},require:()=>{throw new Error('unexpected import')}};
vm.runInNewContext(mentionsJs,mentionsBox);
const {mentionToken,mentionChoices,acceptMention}=mentionsBox.exports;
const EVERYONE='所有人';
const roster=[{id:'b1',name:'小美'},{id:'b2',name:'Alex'},{id:'b3',name:'Alexa'},{id:'b4',name:'工程小助手'}];
// The list is built inside a vm, so its arrays are not this realm's arrays:
// compare what a person sees (names, joined) rather than the objects themselves.
const names=(token,source=roster)=>mentionChoices(token,source,EVERYONE).map(choice=>choice.name).join(',');
test('a mention list opens only on the token under the caret',()=>{
assert.equal(mentionToken('@',1),'','a bare @ is still a list, not a word');
assert.equal(mentionToken('@小美',3),'小美');
assert.equal(mentionToken('嗨 @小美 @',7),'','the last word wins, the earlier mention is spent');
assert.equal(mentionToken('@小美 幫我看',4),null,'a finished name closes the list');
assert.equal(mentionToken('幫我改一下佈景',6),null,'plain text offers nobody');
assert.equal(mentionToken('mail@example.com',16),null,'an email address is not a mention');
assert.equal(mentionToken('@@',2),null,'a doubled @ is a typo, not a name');
});
test('the whole-room escape hatch is the first name you are offered',()=>{
const open=mentionChoices('',roster,EVERYONE);
assert.equal(open[0].name,EVERYONE,'@ comes with the escape hatch on top');
assert.equal(open[0].member,null,'it reaches the room, so it is nobody in particular');
assert.equal(names('').split(',').length,roster.length+1,'then the whole room, in order');
assert.equal(names('').split(',')[1],roster[0].name);
assert.equal(names('所'),EVERYONE,'a token that cannot grow into 所有人 drops it');
assert.equal(names(EVERYONE).split(',')[0],EVERYONE,'typing it out keeps it offered');
});
test('mention choices match loosely and stay one glance long',()=>{
assert.equal(names('alex'),'Alex,Alexa','case does not matter, room order does');
assert.equal(names('小'),'小美,工程小助手','a name anywhere in the word hits');
assert.equal(names(' nobody '),'','a token nobody can answer to offers nobody');
const crowd=Array.from({length:9},(_,index)=>({id:`b${index}`,name:`bot${index}`}));
assert.equal(names('',crowd).split(',').length,6,'six rows is the cap');
assert.equal(names('',crowd).split(',')[0],EVERYONE,'and the escape hatch still leads');
});
test('accepting a mention leaves the sentence readable and the list closed',()=>{
const fresh=acceptMention('@',1,'小美');
assert.equal(fresh.text,'@小美 ');
assert.equal(fresh.caret,fresh.text.length,'the caret sits past the space, so typing continues the sentence');
assert.equal(mentionToken(fresh.text,fresh.caret),null,'and the list does not reopen behind it');
const picked=acceptMention('幫我 @小 部署',5,'小美');
assert.ok(picked.text.startsWith('幫我 @小美 '),'only the typed token is replaced');
assert.ok(picked.text.includes('部署'),'the rest of the message survives');
assert.equal(acceptMention('@小',2,'小美').text,'@小美 ','a half-typed name is replaced whole');
});
// When a message was said is split the way chat apps split it: the day once,
// as a divider, and the clock time on each message. The words for today and
// yesterday come from the catalog; everything else is the locale's own spelling.
const chatTimeJs=ts.transpileModule(fs.readFileSync('apps/web/src/chat-time.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const chatTimeBox={exports:{},require:()=>{throw new Error('unexpected import')}};
vm.runInNewContext(chatTimeJs,chatTimeBox);
const {dayLabel,sameDay,clockTime}=chatTimeBox.exports;
const words={today:zhTW.today,yesterday:zhTW.yesterday};
// A Wednesday afternoon, built in local time so the calendar day is unambiguous.
const now=new Date(2026,9,7,15,30);
const daysAgo=(count,hour=9)=>new Date(2026,9,7-count,hour);
test('a day is named the way people say it',()=>{
assert.equal(dayLabel(daysAgo(0,0),now,'zh-TW',words),'今天','midnight still counts as today');
assert.equal(dayLabel(daysAgo(0,23),now,'zh-TW',words),'今天','a message later today is still today, not the future');
assert.equal(dayLabel(daysAgo(1,23),now,'zh-TW',words),'昨天','yesterday means the calendar day, not 24 hours');
assert.equal(dayLabel(daysAgo(1),now,'en',{today:en.today,yesterday:en.yesterday}),'Yesterday');
assert.equal(dayLabel(daysAgo(2),now,'zh-TW',words),'星期一','the past week goes by weekday');
assert.equal(dayLabel(daysAgo(6),now,'zh-TW',words),'星期四','six days back is the last weekday');
const week=dayLabel(daysAgo(7),now,'zh-TW',words);
assert.match(week,/9\/30|9月30日/,'a week or more ago is written as a date');
assert.match(week,/週三|星期三/,'with its weekday, so it reads like a chat and not a form');
assert.ok(!/2026/.test(week),'this year is not spelled out');
assert.match(dayLabel(new Date(2025,9,1),now,'zh-TW',words),/2025/,'another year is spelled out');
assert.match(dayLabel(new Date(2026,8,15),now,'en',{today:en.today,yesterday:en.yesterday}),/Tue, 9\/15/,'English keeps the same short numeric date');
assert.equal(dayLabel('not a date',now,'zh-TW',words),'','garbage shows nothing rather than Invalid Date');
});
test('messages split by calendar day and show only their clock time',()=>{
assert.ok(sameDay(new Date(2026,9,7,0,1),new Date(2026,9,7,23,59)),'first and last minute share a day');
assert.ok(!sameDay(new Date(2026,9,7,23,59),new Date(2026,9,8,0,0)),'a minute past midnight is a new day');
assert.ok(!sameDay('garbage','garbage'),'two unreadable dates are not the same day');
assert.equal(clockTime(new Date(2026,9,7,9,5),'zh-TW'),'09:05','24-hour clock, zero padded');
assert.equal(clockTime(new Date(2026,9,7,15,30),'en'),'15:30');
assert.equal(clockTime('garbage','en'),'');
});