import {test} from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import vm from 'node:vm';
import ts from '../apps/web/node_modules/typescript/lib/typescript.js';
function loadCatalog(file){
const code=ts.transpileModule(fs.readFileSync(file,'utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
const sandbox={exports:{},require:()=>({})};
vm.runInNewContext(code,sandbox);
return sandbox.exports.zhTW||sandbox.exports.en;
}
const zhTW=loadCatalog('apps/web/src/locales/zh-TW.ts');
const en=loadCatalog('apps/web/src/locales/en.ts');
const catalogs={'zh-TW':zhTW,en};
const i18n={locale:'en',t:(key,params)=>{const message=catalogs[i18n.locale]?.[key]??key;return params?message.replace(/\{(\w+)\}/g,(m,name)=>Object.hasOwn(params,name)?String(params[name]):m):message},getLocale:()=>i18n.locale};
const raw=fs.readFileSync('apps/web/src/schedule.tsx','utf8');
const js=ts.transpileModule(raw,{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.React}}).outputText;
const box={exports:{},require:()=>i18n};vm.runInNewContext(js,box);const {cronFromPreset,presetFromCron,defaultCronPreset,describeCron,describeCronHuman}=box.exports;
test('fixed intervals retain exact elapsed units',()=>{
for (const unit of ['minutes','hours','days']) {const p={...defaultCronPreset(),freq:'Interval',n:45,unit};const cron=cronFromPreset(p);const restored=presetFromCron(cron);assert.equal(restored.n,45);assert.equal(restored.unit,unit);}
});
test('unknown cron is preserved verbatim instead of silently rewritten',()=>{
for(const cron of ['0 0 9 * * 1','0 25 * * *','99 9 * * *','0 0 */3 * *','*/45 * * * *']){const p=presetFromCron(cron);assert.equal(p.freq,'Advanced');assert.equal(cronFromPreset(p),cron);}
});
test('blank advanced expressions are never converted to a scheduled job',()=>assert.equal(cronFromPreset({...defaultCronPreset(),freq:'Advanced',cron:''}),''));
test('VNC paste delegates once to confirmed backend and rejects another source',async()=>{
const source=fs.readFileSync('apps/web/vnc.html','utf8').match(/'),undefined);
assert.equal(sanitizeMarkdownUrl('/relative'),undefined);
});
test('zh-TW and en catalogs share keys and placeholders',()=>{
const zhKeys=Object.keys(zhTW).sort();
const enKeys=Object.keys(en).sort();
assert.deepEqual(enKeys,zhKeys);
for(const key of zhKeys){
const slots=s=>[...String(s).matchAll(/\{(\w+)\}/g)].map(m=>m[1]).sort();
assert.deepEqual(slots(en[key]),slots(zhTW[key]),key);
assert.notEqual(String(en[key]).trim(),'');
assert.notEqual(String(zhTW[key]).trim(),'');
}
});
test('describeCron and stored Chinese human strings follow the UI locale',()=>{
i18n.locale='en';
assert.equal(describeCron('0 9 * * *'),'Daily at 9:00 AM');
assert.equal(describeCron('0 9 * * 1-5'),'Weekdays at 9:00 AM');
assert.equal(describeCronHuman('每天 09:00'),'Daily at 9:00 AM');
assert.equal(describeCronHuman('每隔 45 分鐘(固定間隔)'),'Every 45 minutes (elapsed)');
i18n.locale='zh-TW';
assert.equal(describeCron('0 9 * * *'),'每天 09:00');
assert.equal(describeCronHuman('Daily at 9:00 AM'),'每天 09:00');
});
test('hanging up before microphone permission resolves releases the late stream',async()=>{
let grant; let stopped=0;
const context={exports:{},navigator:{mediaDevices:{getUserMedia:()=>new Promise(resolve=>grant=resolve)}}};
vm.runInNewContext(audioJs,context);
const audio=new context.exports.CallAudio({onCapture(){},onError(){}});
const starting=audio.start();
audio.stop();
grant({getTracks:()=>[{stop(){stopped++;}}]});
await starting;
assert.equal(stopped,1);
});
test('odd-sized audio frames stay sample-aligned instead of turning into noise',()=>{
const rendered=[];
const audio=new audioBox.exports.CallAudio({onCapture(){},onError(){}});
audio.context=fakePlaybackContext(rendered);
const source=new Int16Array([1000,-1000,2000,-2000,3000,-3000,4000,-4000]);
const bytes=new Uint8Array(source.buffer);
let offset=0;
for(const size of [3,5,7,1]){audio.play(bytes.slice(offset,offset+size).buffer);offset+=size}
const played=rendered.flatMap(chunk=>[...chunk]);
const expected=[...pcm16ToFloat(bytes.slice().buffer)];
assert.equal(played.length,expected.length);
expected.forEach((value,index)=>assert.ok(Math.abs(played[index]-value)<1e-6,`sample ${index}`));
});
test('microphone frames are batched instead of sent one websocket message per block',()=>{
const sent=[];
const audio=new audioBox.exports.CallAudio({onCapture:pcm=>sent.push(pcm.length),onError(){}});
const block=new Float32Array(64);
const blocksPerChunk=CAPTURE_CHUNK_SAMPLES/block.length;
for(let i=0;i([\s\S]*?)<\/script>/)[1].replace(/import RFB[^;]+;/,'');
const handlers={},options={},sent=[];
const keyboard={value:'',focused:false,focus(){this.focused=true},blur(){this.focused=false},setSelectionRange(){}};
const pointerEvents=[];
const canvas={style:{},closest:selector=>selector==='#screen'?{}:null,getBoundingClientRect:()=>({left:0,top:0,width:300,height:200})};
const elements={};
const element=id=>elements[id]||(elements[id]={style:{},attributes:{},setAttribute(k,v){this.attributes[k]=v},querySelectorAll(){return []}});
let rfb;
class RFB{constructor(){rfb=this}addEventListener(){}focus(){keyboard.focused=false}
_handleMouseMove(x,y){pointerEvents.push({type:'move',x,y})}
_handleMouseButton(x,y,mask){pointerEvents.push({type:'button',x,y,mask})}
_handleWheel(event){pointerEvents.push({type:'wheel',dx:event.deltaX,dy:event.deltaY})}
}
const 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(/