import test from 'node:test'; import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; import { PortalInputBackend } from '../computer-use/portal-input.js'; import { ComputerUseSession } from '../computer-use/session.js'; import { ComputerActuator } from '../computer-use/actuator.js'; import { DesktopObserver } from '../computer-use/observer.js'; function setup(options = {}) { const child = new EventEmitter(); child.stdout = new EventEmitter(); child.stderr = new EventEmitter(); child.stdin = new EventEmitter(); child.stdin.writable = true; const sent = []; child.stdin.write = s => sent.push(JSON.parse(s)); child.kill = () => {}; const input = new PortalInputBackend({ spawnImpl: () => child, ...options }); const grant = input.grant(); child.stdout.emit('data', '{"type":"ready","screen":true,"backend":"portal-ei"}\n'); return { child, input, sent, grant, event: value => child.stdout.emit('data', JSON.stringify(value) + '\n') }; } test('input awaits a matching ack, propagates errors, and rejects pending work on revoke', async () => { const {input,grant,event,sent} = setup(); await grant; const first = input.send({type:'keyboard',action:'key',combo:'home'}); event({type:'ack',id:sent[0].id+100}); assert.equal(input._pending.size,1); event({type:'error',id:sent[0].id,reason:'device paused'}); await assert.rejects(first,/device paused/); const second=input.send({type:'pointer',action:'move',x:1,y:1}); event({type:'ack',id:sent[1].id,ok:true}); assert.equal((await second).ok,true); const third=input.send({type:'keyboard',action:'key',combo:'end'}); input.revoke(); await assert.rejects(third,/revoked/); }); test('missing input acknowledgment revokes the helper instead of claiming success', async () => { const {input,grant}=setup({actionTimeoutMs:20});await grant; await assert.rejects(input.send({}),/timed out/);assert.equal(input.available,false); }); test('old helper exit cannot cancel a newer grant or its commands', async () => { const children=[]; const spawnImpl=()=>{const c=new EventEmitter();c.stdout=new EventEmitter();c.stderr=new EventEmitter();c.stdin=new EventEmitter();c.stdin.writable=true;c.stdin.write=()=>{};c.kill=()=>{};children.push(c);return c;}; const input=new PortalInputBackend({spawnImpl}); let g=input.grant();children[0].stdout.emit('data','{"type":"ready"}\n');await g;input.revoke(); g=input.grant();children[1].stdout.emit('data','{"type":"ready"}\n');await g; children[0].emit('close');assert.equal(input.available,true);input.revoke(); }); test('actuator waits for actual input completion and refuses a revoke during readiness', async () => { const session=new ComputerUseSession();session.grant();let sent=false; const actuator=new ComputerActuator({session,input:{ready:async()=>session.revoke(),send:()=>{sent=true;}},sleep:async()=>{}}); await assert.rejects(actuator.click({x:1,y:1}),/inactive/);assert.equal(sent,false); session.grant();actuator.input={send:async()=>{throw Error('injection failed');}}; await assert.rejects(actuator.click({x:1,y:1}),/injection failed/); await assert.rejects(actuator.click({x:NaN,y:1}),/finite/); }); test('observer maps Wayland surface bounds to desktop and never reuses old refs',async()=>{ const observer=new DesktopObserver({atspi:{tree:async()=>[{name:'Target',role:'button',pid:2,rect:[46,113,640,34],window_rect:[0,0,732,649]}]},shell:{windows:async()=>({windows:[{pid:2,focused:true,rect:[2540,242,680,597],buffer_rect:[2514,219,732,649]}]})}}); const first=await observer.tree();assert.deepEqual(first[0].rect,[2560,332,640,34]); const second=await observer.tree();assert.notEqual(second[0].ref,first[0].ref); }); test('native fallback preserves shortcuts, Unicode composition, stream ids and both scroll axes', async () => { const {spawnSync}=await import('node:child_process'); const run=spawnSync('python3',['-c',` import sys sys.path.insert(0, 'computer-use/py') import portal_remote_desktop as p calls=[] p.notify=lambda method, signature, values: calls.append((method,values)) n=p.PortalNotify('/session',94,(1920,0)) n.handle({'type':'pointer','action':'click','x':2000,'y':300}) assert calls[0] == ('NotifyPointerMotionAbsolute',('/session',{},94,80.0,300.0)) calls.clear() n.handle({'type':'keyboard','action':'key','combo':'ctrl+l'}) assert [x[1][-2:] for x in calls] == [(29,1),(38,1),(38,0),(29,0)] calls.clear() n.handle({'type':'keyboard','action':'type','text':'✓'}) assert calls[0][0] == 'NotifyKeyboardKeycode' and calls[0][1][-2:] == (29,1) assert any(c[0] == 'NotifyKeyboardKeysym' and c[1][-2] == ord('7') for c in calls) calls.clear() n.handle({'type':'pointer','action':'scroll','x':2000,'y':300,'dx':1,'dy':2}) assert calls[-2][1][-2:] == (0,2) and calls[-1][1][-2:] == (1,1) from libei_sender import LibeiSender, _lib sender=object.__new__(LibeiSender) taps=[] sender._tap=lambda key,mods=(): taps.append((key,mods)) sender.key_combo('ctrl+left') assert taps == [(105,(29,))] try: sender.key_combo('invalid') except ValueError: pass else: raise AssertionError('invalid key silently accepted') assert _lib().ei_seat_bind_capabilities.argtypes `],{encoding:'utf8'}); assert.equal(run.status,0,run.stderr); }); test('frame normalization reports native and scaled dimensions', async () => { const {spawnSync}=await import('node:child_process'); const {mkdtemp,rm}=await import('node:fs/promises'); const {FrameNormalizer}=await import('../computer-use/frame.js'); const dir=await mkdtemp('/tmp/jarvis-frame-test-'); try { const made=spawnSync('python3',['-c','from PIL import Image; import sys; Image.new("RGB",(1600,900),"red").save(sys.argv[1])',dir+'/input.png'],{encoding:'utf8'}); assert.equal(made.status,0,made.error?.message || made.stderr); const frame=await new FrameNormalizer().normalize(dir+'/input.png',dir+'/output.webp'); assert.equal(frame.source_width,1600);assert.equal(frame.width,1280);assert.equal(frame.height,720); assert.equal(frame.mime,'image/webp'); const jpeg=await new FrameNormalizer().normalize(dir+'/input.png',dir+'/output.jpg'); assert.equal(jpeg.mime,'image/jpeg');assert.equal(jpeg.width,1280); } finally {await rm(dir,{recursive:true,force:true});} });