766 lines
51 KiB
JavaScript
766 lines
51 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,()=>{}],useMemo:fn=>fn(),memo:fn=>fn,Children:{map:(n,fn)=>n},cloneElement:el=>el,isValidElement:()=>false};
|
||
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==='./mentions')return{mentionSpans:()=>[]};
|
||
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('chat messages use a copy-or-reply menu instead of inline run details',()=>{
|
||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||
assert.match(app,/function MessageContextMenu/);
|
||
assert.match(app,/t\("replyToThis"\)/);
|
||
assert.match(app,/t\("copyMessage"\)/);
|
||
assert.match(app,/\{\.\.\.messagePress\(message\)\}/);
|
||
assert.match(app,/if\(message\.role==="user"\)return/);
|
||
assert.match(app,/canReply=context\.message\.role!=="user"/);
|
||
assert.match(app,/kind:"reply"/);
|
||
assert.doesNotMatch(app,/CopyMessageButton/);
|
||
assert.doesNotMatch(app,/remember-msg/);
|
||
assert.doesNotMatch(app,/className="route-note"/);
|
||
});
|
||
|
||
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=\{computer\.busyRunId\|\|computer\.waitingRunId\}>/);
|
||
assert.doesNotMatch(app,/message-run-details/);
|
||
assert.match(app,/className="thinking-row"/);
|
||
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,ensureMention,replySnippet,mentionSpans,nameHit}=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');
|
||
});
|
||
|
||
test('replying to a room message names the speaker once',()=>{
|
||
assert.equal(ensureMention('','小美'),'@小美 ');
|
||
assert.equal(ensureMention('看一下這個','小美'),'@小美 看一下這個');
|
||
assert.equal(ensureMention('@小美 已經點名','小美'),'@小美 已經點名','an existing mention is left alone');
|
||
assert.equal(ensureMention('請 @小美 幫忙','小美'),'請 @小美 幫忙','a mention later in the sentence also counts');
|
||
assert.equal(ensureMention(' 前面有空白','Alex'),'@Alex 前面有空白');
|
||
assert.equal(ensureMention('',''),'');
|
||
assert.equal(replySnippet('沒拚嘛,在這等你聊天~有事直接說就好。'),'沒拚嘛,在這等你聊天~有事直接說就好。');
|
||
assert.equal(replySnippet('one\n\ntwo three'),'one two three');
|
||
const long='字'.repeat(90);
|
||
assert.equal(replySnippet(long).length,80);
|
||
assert.ok(replySnippet(long).endsWith('…'));
|
||
});
|
||
|
||
test('group @mentions become agent spans and ignore emails',()=>{
|
||
const room=[{id:'b',name:'小美'},{id:'a',name:'阿明'},{id:'c',name:'Dev'}];
|
||
const kinds=(text)=>mentionSpans(text,room).map(span=>span.kind==='agent'?`${span.kind}:${span.id}`:`${span.kind}:${span.text}`).join('|');
|
||
assert.equal(kinds('@小美 幫我看這份報表'),'agent:b|text: 幫我看這份報表');
|
||
assert.equal(kinds('@阿明哥 來一下'),'agent:a|text:哥 來一下','a CJK name has no word boundary, so 阿明 matches inside 阿明哥 when that longer member is absent');
|
||
const overlap=[{id:'a',name:'阿明'},{id:'b',name:'阿明哥'}];
|
||
assert.equal(mentionSpans('@阿明哥 來一下',overlap)[0].id,'b');
|
||
assert.equal(mentionSpans('@阿明 來一下',overlap)[0].id,'a');
|
||
assert.equal(kinds('@dev 幫我看'),'agent:c|text: 幫我看');
|
||
assert.equal(kinds('@Dev, help'),'agent:c|text:, help');
|
||
assert.equal(kinds('@Developer 幫我看'),'text:@Developer 幫我看');
|
||
assert.equal(kinds('寄到 mail@小美 或 report@dev.example 就好'),'text:寄到 mail@小美 或 report@dev.example 就好');
|
||
assert.equal(kinds('(@小美)'),'text:(|agent:b|text:)');
|
||
assert.equal(mentionSpans('@所有人 結論定了',room)[0].kind,'everyone');
|
||
assert.equal(mentionSpans('@everyone look',room)[0].kind,'everyone');
|
||
assert.ok(nameHit('小美 幫我','小美'));
|
||
assert.equal(mentionSpans('@allison 來了',room)[0].kind,'text');
|
||
assert.equal(mentionSpans('@小美 幫我看',[])[0].kind,'text','one-to-one chats do not link mentions');
|
||
});
|
||
|
||
// 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'),'');
|
||
});
|