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(/