import test from 'node:test'; import assert from 'node:assert/strict'; import {build} from 'esbuild'; const result=await build({entryPoints:[new URL('../src/core/fleet/useRoverControl.ts',import.meta.url).pathname],bundle:true,write:false,platform:'node',format:'esm',plugins:[{name:'hook-fixture',setup(b){ b.onResolve({filter:/^react$/},()=>({path:'react',namespace:'fixture'})); b.onLoad({filter:/.*/,namespace:'fixture'},()=>({contents:`export const useRef=v=>({current:v});export const useState=v=>[v,()=>{}];export const useCallback=f=>f;export const useEffect=f=>globalThis.roverEffects.push(f);`})); }}]}); const {useRoverControl}=await import('data:text/javascript;base64,'+Buffer.from(result.outputFiles[0].contents).toString('base64')); const flush=()=>new Promise(resolve=>setImmediate(resolve)); const deferred=()=>{let resolve;const promise=new Promise(r=>resolve=r);return {promise,resolve};}; const response=value=>({ok:true,json:async()=>value}); const idle={fresh:true,controlling:false,snapshot:{supported:true,state:'observing'}}; async function fixture(run){ const originals=Object.fromEntries(['window','document','fetch','roverEffects','setInterval','clearInterval'].map(k=>[k,globalThis[k]])); const calls=[],timers=[];globalThis.roverEffects=[]; globalThis.window={addEventListener(){},removeEventListener(){}};globalThis.document={hidden:false,hasFocus:()=>true,addEventListener(){},removeEventListener(){}}; globalThis.setInterval=(f,ms)=>{timers.push({f,ms});return timers.length;};globalThis.clearInterval=()=>{}; let armResponse=null,nextPoll=null; globalThis.fetch=async(url,init)=>{const body=init.body?JSON.parse(init.body):null;calls.push({url,body}); if(url.endsWith('/arm'))return armResponse?armResponse.promise:response({session_id:'new-session'}); if(!body)return nextPoll?nextPoll.promise:response(idle); return response({accepted_sequence:body.sequence}); }; const c=useRoverControl('fixture',true),cleanup=globalThis.roverEffects.map(f=>f()); try{await flush();await run({c,calls,poll:()=>timers.find(t=>t.ms===200).f(),heartbeat:()=>timers.find(t=>t.ms===100).f(),delayArm:()=>armResponse=deferred(),delayPoll:()=>nextPoll=deferred()});} finally{cleanup.forEach(f=>f?.());await flush();for(const [key,value]of Object.entries(originals)){if(value===undefined)delete globalThis[key];else globalThis[key]=value;}} } test('idle poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{ const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll(); a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush(); p.resolve(response(idle));await flush();f.heartbeat();await flush(); assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old idle poll revoked the new session'); assert.equal(f.calls.at(-1).body.session_id,'new-session'); })); test('failed poll begun during pending arm cannot revoke newly acknowledged session',async()=>fixture(async f=>{ const a=f.delayArm();const arming=f.c.arm(30,2000);const p=f.delayPoll();f.poll(); a.resolve(response({session_id:'new-session'}));assert.equal(await arming,true);await flush(); p.resolve({ok:false,json:async()=>({detail:'stale read failure'})});await flush(); assert.equal(f.calls.filter(x=>x.body?.stop===true).length,0,'old failed poll revoked the new session'); })); test('a current poll still revokes control when server reports lease lost',async()=>fixture(async f=>{ assert.equal(await f.c.arm(30,2000),true);await flush();f.poll();await flush(); assert.equal(f.calls.at(-1).body.stop,true); })); test('duplicate arm clicks while request pending create exactly one lease request',async()=>fixture(async f=>{ const a=f.delayArm();const first=f.c.arm(30,2000),second=f.c.arm(30,2000); a.resolve(response({session_id:'new-session'}));await Promise.all([first,second]);await flush(); assert.equal(f.calls.filter(x=>x.url.endsWith('/arm')).length,1); }));