| 1 | import { test } from 'node:test'; |
| 2 | import assert from 'node:assert/strict'; |
| 3 | import fs from 'node:fs'; |
| 4 | import os from 'node:os'; |
| 5 | import path from 'node:path'; |
| 6 | import { spawn, spawnSync } from 'node:child_process'; |
| 7 | import zlib from 'node:zlib'; |
| 8 | import crypto from 'node:crypto'; |
| 9 | import { create, leaseVerdict, leaseAccounting } from '../src/backends/darwin.mjs'; |
| 10 | import { withSignal, currentSignal, runInputLease } from '../src/exec.mjs'; |
| 11 | |
| 12 | // Simulate the native lease process: it owns cleanup itself, keeping the |
| 13 | // original app identity even if the JS backend is rebound before release. |
| 14 | function leaseExecutor(run) { |
| 15 | return { async run(cmd,args,opts) { |
| 16 | const result=await run(cmd,args,opts); |
| 17 | if(args[0]?.startsWith('{') && JSON.parse(args[0]).tool==='input_capabilities' && result.code===0 && JSON.parse(result.stdout).input_lease===undefined) return {code:0,stderr:'',stdout:JSON.stringify({input_lease:1,background_focus_guard:1})}; |
| 18 | return result; |
| 19 | }, async runInputLease(cmd, argv) { |
| 20 | const request = JSON.parse(argv[0]); |
| 21 | const release = async ({point} = {}) => { |
| 22 | const args = request.args; |
| 23 | const native = request.tool === 'key_event' |
| 24 | ? {tool:'key_event',args:{...args,down:false,owned_release:true}} |
| 25 | : {tool:'release_input',args:{...args,point:point??args.steps.at(-1),button:0}}; |
| 26 | await withSignal(null,()=>run(cmd,[JSON.stringify(native)],{timeoutMs:20000,ownerPipe:true})); |
| 27 | }; |
| 28 | const result = await run(cmd,argv,{timeoutMs:20000,ownerPipe:true}); |
| 29 | if (result.code !== 0 || result.aborted || result.timedOut) { |
| 30 | if(result.spawned && (result.aborted || result.timedOut)) await release(); |
| 31 | throw Object.assign(new Error(result.aborted?'computer request cancelled':result.timedOut?'native accessibility helper timed out':result.stderr),{code:result.aborted?'cancelled':'native_error'}); |
| 32 | } |
| 33 | return {receipt:JSON.parse(result.stdout),release,async send({point}) { |
| 34 | const result=await run(cmd,[JSON.stringify({tool:'pointer_sequence',args:{...request.args,steps:[{type:6,...point,button:0}]}})],{timeoutMs:20000,ownerPipe:true}); |
| 35 | return JSON.parse(result.stdout); |
| 36 | }}; |
| 37 | }}; |
| 38 | } |
| 39 | |
| 40 | test('native summary keeps text and top-level menus without spending the UI budget on hidden menu trees', {skip:process.platform!=='darwin'}, t=>{ |
| 41 | const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-native-observation-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); |
| 42 | const binary=path.join(dir,'native'); |
| 43 | const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'}); |
| 44 | assert.equal(build.status,0,build.stderr); |
| 45 | |
| 46 | for (const tool of ['bg_key','bg_pointer','pointer_sequence','inspect_focus_control']) { |
| 47 | const r=spawnSync(binary,[JSON.stringify({tool,args:{app_scoped:true,foreground_input:false}})],{encoding:'utf8'}); |
| 48 | assert.equal(r.status,1); |
| 49 | assert.match(r.stderr,/background_focus_required/); |
| 50 | } |
| 51 | const consented=spawnSync(binary,[JSON.stringify({tool:'inspect_focus_control',args:{foreground_input:true}})],{encoding:'utf8'}); |
| 52 | assert.equal(consented.status,0,consented.stderr); |
| 53 | |
| 54 | for(const [element,context,action] of [ |
| 55 | [{AXRole:'AXTextField',actions:['AXPress'],settable:['AXFocused']},false,'AXFocused'], |
| 56 | [{AXRole:'AXRow',settable:['AXSelected']},false,'AXSelected'], |
| 57 | [{AXRole:'AXMenuItem',actions:['AXPick']},false,'AXPick'], |
| 58 | [{AXRole:'AXButton',actions:['AXShowMenu']},true,'AXShowMenu'], |
| 59 | [{AXRole:'AXButton',AXEnabled:false,actions:['AXPress']},false,null], |
| 60 | // Focusing anything but a text-entry role is not a click; the caller |
| 61 | // falls back to real delivery. |
| 62 | [{AXRole:'AXGroup',settable:['AXFocused']},false,null], |
| 63 | [{AXRole:'AXSearchField',settable:['AXFocused']},false,'AXFocused'], |
| 64 | [{AXRole:'AXGroup',settable:['AXFocused','AXSelectedText']},false,null], |
| 65 | ]) { |
| 66 | const r=spawnSync(binary,[JSON.stringify({tool:'inspect_click_action',args:{element,context}})],{encoding:'utf8'}); |
| 67 | assert.equal(r.status,0,r.stderr); |
| 68 | assert.equal(JSON.parse(r.stdout).action,action); |
| 69 | } |
| 70 | const node=(role,label,children=[])=>({AXRole:role,AXTitle:label,AXChildren:children,actions:['AXPress']}); |
| 71 | const app={AXMenuBar:node('AXMenuBar','Menu bar',[node('AXMenuBarItem','File',[node('AXMenu','File menu',Array.from({length:150},(_,i)=>node('AXMenuItem',`Command ${i}`)))])]),AXChildren:[node('AXMenu','Popup',[node('AXMenuItem','Choose')])]}; |
| 72 | const windows=[node('AXWindow','Fixture',Array.from({length:350},(_,i)=>({...node('AXTextField',`Field ${i}`),AXValue:`Value ${i}`,AXFocused:i===349})))]; |
| 73 | const observe=(detail)=>{ |
| 74 | const r=spawnSync(binary,[JSON.stringify({tool:'inspect_observation',args:{app,windows,detail}})],{encoding:'utf8'}); |
| 75 | assert.equal(r.status,0,r.stderr);return JSON.parse(r.stdout); |
| 76 | }; |
| 77 | for(const detail of [undefined,'summary','compact']) { |
| 78 | const state=observe(detail); |
| 79 | assert.equal(state.truncated,false,'intentional menu summarization is not a truncated observation'); |
| 80 | assert.equal(state.elements.length,355); |
| 81 | assert.ok(state.elements.some(e=>e.label==='File')); |
| 82 | assert.ok(state.elements.some(e=>e.label==='Choose'),'open popup actions remain observable'); |
| 83 | assert.ok(!state.elements.some(e=>e.label==='Command 0')); |
| 84 | const field=state.elements.find(e=>e.label==='Field 349'); |
| 85 | assert.equal(field.value,'Value 349');assert.equal(field.focused,true); |
| 86 | assert.deepEqual(field.path,[349]);assert.equal(field.windowIndex,0); |
| 87 | } |
| 88 | const full=observe('full'); |
| 89 | assert.equal(full.truncated,false); |
| 90 | assert.equal(full.elements.find(e=>e.label==='Command 149').windowIndex,-1); |
| 91 | assert.deepEqual(full.elements.find(e=>e.label==='Command 149').path,[0,0,149]); |
| 92 | assert.ok(full.elements.some(e=>e.label==='Field 349')); |
| 93 | const identity=(element,target)=>spawnSync(binary,[JSON.stringify({tool:'inspect_element_identity',args:{element,target}})],{encoding:'utf8'}); |
| 94 | const target={role:'AXMenuItem',label:'Files'}; |
| 95 | assert.equal(identity(node('AXMenuItem','Files'),target).status,0); |
| 96 | for(const element of [node('AXMenuItem','Delete'),node('AXButton','Files'),node('AXMenuItem','')]) { |
| 97 | const refused=identity(element,target); |
| 98 | assert.equal(refused.status,1);assert.match(refused.stderr,/element changed (role|label)/); |
| 99 | } |
| 100 | assert.equal(identity(node('AXMenuItem','Files'),{role:'AXMenuItem'}).status,1,'an unlabeled element replaced by a labeled one is stale too'); |
| 101 | }); |
| 102 | |
| 103 | test('native Unicode encoding round-trips through the actual CoreGraphics event', {skip:process.platform!=='darwin'}, t=>{ |
| 104 | const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-native-test-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); |
| 105 | const binary=path.join(dir,'native'); |
| 106 | const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'}); |
| 107 | assert.equal(build.status,0,build.stderr); |
| 108 | for(const text of ['Hello 世界 🐋','quote " slash \\ newline\n','e\u0301 👨👩👧👦']){ |
| 109 | const r=spawnSync(binary,[JSON.stringify({tool:'inspect_text_event',args:{text}})],{encoding:'utf8'}); |
| 110 | assert.equal(r.status,0,r.stderr);assert.equal(JSON.parse(r.stdout).text,text); |
| 111 | assert.equal(JSON.parse(r.stdout).flags,0,'literal text carries no physical modifiers'); |
| 112 | for (const inherited_flags of [1<<17,1<<18,1<<19,1<<20,(1<<17)|(1<<20)]) { |
| 113 | const inherited=spawnSync(binary,[JSON.stringify({tool:'inspect_text_event',args:{text,inherited_flags}})],{encoding:'utf8'}); |
| 114 | assert.equal(inherited.status,0,inherited.stderr); |
| 115 | assert.deepEqual(JSON.parse(inherited.stdout),{text,flags:0}); |
| 116 | } |
| 117 | } |
| 118 | const pointer=spawnSync(binary,[JSON.stringify({tool:'pointer_sequence',args:{foreground_input:false,steps:[]}})],{encoding:'utf8'}); |
| 119 | assert.equal(pointer.status,1); |
| 120 | assert.match(pointer.stderr,/shared macOS pointer input is unavailable in background mode/); |
| 121 | }); |
| 122 | |
| 123 | test('native window matching refuses another process, mismatched geometry and ambiguous captures', {skip:process.platform!=='darwin'}, t=>{ |
| 124 | const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-native-window-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); |
| 125 | const binary=path.join(dir,'native'); |
| 126 | const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'}); |
| 127 | assert.equal(build.status,0,build.stderr); |
| 128 | const selected={kCGWindowOwnerPID:123,kCGWindowLayer:0,kCGWindowNumber:42,kCGWindowBounds:{X:10,Y:20,Width:400,Height:300}}; |
| 129 | const query=windows=>spawnSync(binary,[JSON.stringify({tool:'inspect_window_match',args:{pid:123,bounds:{x:10,y:20,w:400,h:300},windows}})],{encoding:'utf8'}); |
| 130 | const wrongProcess={...selected,kCGWindowOwnerPID:999,kCGWindowNumber:43}; |
| 131 | const wrongSize={...selected,kCGWindowNumber:44,kCGWindowBounds:{...selected.kCGWindowBounds,Width:800}}; |
| 132 | const found=query([wrongProcess,wrongSize,selected]); |
| 133 | assert.equal(found.status,0,found.stderr);assert.equal(JSON.parse(found.stdout).window_id,42); |
| 134 | const missing=query([wrongProcess,wrongSize]); |
| 135 | assert.equal(missing.status,1);assert.match(missing.stderr,/selected app window is not capturable/); |
| 136 | const ambiguous=query([selected,{...selected,kCGWindowNumber:45}]); |
| 137 | assert.equal(ambiguous.status,1);assert.match(ambiguous.stderr,/selected window is ambiguous/); |
| 138 | const owner=windows=>{ |
| 139 | const result=spawnSync(binary,[JSON.stringify({tool:'inspect_window_at_point',args:{x:50,y:50,input_app_ref:{pid:123},windows}})],{encoding:'utf8'}); |
| 140 | assert.equal(result.status,0,result.stderr);return JSON.parse(result.stdout); |
| 141 | }; |
| 142 | for(const layer of [0,3,8,25,1000]) { |
| 143 | const floating={...wrongProcess,kCGWindowLayer:layer,kCGWindowAlpha:1}; |
| 144 | assert.equal(owner([floating,selected]).owner_pid,999,'visible foreign windows own the point at every layer'); |
| 145 | assert.equal(owner([{...floating,kCGWindowAlpha:0.001},selected]).owner_pid,999,'faint foreign windows remain occluders'); |
| 146 | assert.equal(owner([{...floating,kCGWindowAlpha:0},selected]).owner_pid,123,'transparent overlays do not intercept'); |
| 147 | } |
| 148 | assert.equal(owner([{...selected,kCGWindowLayer:8}]).owner_pid,123,'the bound app can target its own floating panels'); |
| 149 | |
| 150 | }); |
| 151 | |
| 152 | test('native owner pipe survives forced MCP exit, releases promptly and excludes competing input', {skip:process.platform!=='darwin'}, async t=>{ |
| 153 | const dir=fs.mkdtempSync(path.join(os.tmpdir(),'cu-native-owner-')); t.after(()=>fs.rmSync(dir,{recursive:true,force:true})); |
| 154 | const binary=path.join(dir,'native'); |
| 155 | const build=spawnSync('clang',['-DCU_TEST=1','-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia','-framework','Vision','src/backends/darwin-accessibility.m','-o',binary],{encoding:'utf8'}); |
| 156 | assert.equal(build.status,0,build.stderr); |
| 157 | for(const workMs of [0,5000]) { |
| 158 | const releaseFile=path.join(dir,`released-${workMs}`); |
| 159 | const args={owner_pipe:true,input_lease:true,lock_dir:dir,release_file:releaseFile,work_ms:workMs}; |
| 160 | const request=JSON.stringify({tool:'test_input_lease',args}); |
| 161 | const parent=spawn(process.execPath,['--input-type=module','-e',` |
| 162 | import {runInputLease} from ${JSON.stringify(new URL('../src/exec.mjs',import.meta.url).href)}; |
| 163 | await runInputLease(process.argv[1],[process.argv[2]]); |
| 164 | console.log('ready'); setInterval(()=>{},1000); |
| 165 | `,binary,request],{stdio:['ignore','pipe','pipe']}); |
| 166 | t.after(()=>{if(parent.exitCode===null)parent.kill('SIGKILL');}); |
| 167 | await new Promise((resolve,reject)=>{parent.stdout.once('data',resolve);parent.once('exit',code=>reject(new Error(`parent exited before ready: ${code}`)));}); |
| 168 | const competing=spawnSync(binary,[JSON.stringify({tool:'test_input_lease',args:{lock_dir:dir}})],{encoding:'utf8'}); |
| 169 | assert.equal(competing.status,1); |
| 170 | assert.match(competing.stderr,/another Computer Use session owns held input/); |
| 171 | assert.ok(!fs.existsSync(releaseFile)); |
| 172 | const exited=new Promise(resolve=>parent.once('exit',resolve));parent.kill('SIGKILL');await exited; |
| 173 | const deadline=Date.now()+2000; |
| 174 | while((!fs.existsSync(releaseFile)||fs.readFileSync(releaseFile,'utf8')!=='released')&&Date.now()<deadline)await new Promise(resolve=>setTimeout(resolve,20)); |
| 175 | assert.equal(fs.readFileSync(releaseFile,'utf8'),'released','native cleanup ran on owner pipe EOF'); |
| 176 | const successor=await runInputLease(binary,[JSON.stringify({tool:'test_input_lease',args:{...args,work_ms:0}})]);await successor.release(); |
| 177 | } |
| 178 | }); |
| 179 | test('macOS backend binds native input to the opened process and reports denied permissions honestly', async t=>{ |
| 180 | const bundle=fs.mkdtempSync(path.join(os.tmpdir(),'cu-bundle-test-'));const old=process.env.CODEWHALE_CU_APP_BUNDLE; |
| 181 | t.after(()=>{if(old===undefined)delete process.env.CODEWHALE_CU_APP_BUNDLE;else process.env.CODEWHALE_CU_APP_BUNDLE=old;fs.rmSync(bundle,{recursive:true,force:true});}); |
| 182 | fs.mkdirSync(path.join(bundle,'Contents','MacOS'),{recursive:true});fs.writeFileSync(path.join(bundle,'Contents','MacOS','accessibility'),'');process.env.CODEWHALE_CU_APP_BUNDLE=bundle; |
| 183 | const calls=[]; |
| 184 | const backend=create({exec:leaseExecutor(async (cmd,args,opts)=>{ |
| 185 | assert.ok(Number.isFinite(opts.timeoutMs)); |
| 186 | if(cmd==='open')return {code:0,stdout:'',stderr:''}; |
| 187 | if(cmd==='screencapture')return {code:1,stdout:'',stderr:'denied'}; |
| 188 | const request=JSON.parse(args[0]);calls.push(request); |
| 189 | return {code:0,stderr:'',stdout:JSON.stringify(request.tool==='app_info'?{found:true,pid:123,bundle_id:'test.app'}:request.tool==='permissions'?{trusted:false}:{action_sent:true})}; |
| 190 | })}); |
| 191 | await backend.open_application({name:'TextEdit',activate:false});await backend.key({text:'return'});await backend.type({text:'Hello 世界 🐋'}); |
| 192 | const events=calls.filter(c=>c.tool==='key_event');assert.equal(events.length,2);assert.equal(events[0].args.code,36);assert.equal(events[0].args.flags,0);assert.equal(events[0].args.input_app_ref.pid,123);assert.equal(events[1].args.down,false); |
| 193 | assert.equal(calls.find(c=>c.tool==='type').args.text,'Hello 世界 🐋'); |
| 194 | const probe=await backend.probe();assert.equal(probe.permissions.accessibility,'denied');assert.equal(probe.capabilities.raw_input,false);assert.equal(probe.capabilities.screenshot,false); |
| 195 | }); |
| 196 | |
| 197 | test('macOS background binding avoids reopen and releases at the agent pointer, not the user pointer', async t=>{ |
| 198 | const bundle=fs.mkdtempSync(path.join(os.tmpdir(),'cu-quiet-test-'));const old=process.env.CODEWHALE_CU_APP_BUNDLE; |
| 199 | t.after(()=>{if(old===undefined)delete process.env.CODEWHALE_CU_APP_BUNDLE;else process.env.CODEWHALE_CU_APP_BUNDLE=old;fs.rmSync(bundle,{recursive:true,force:true});}); |
| 200 | fs.mkdirSync(path.join(bundle,'Contents','MacOS'),{recursive:true});fs.writeFileSync(path.join(bundle,'Contents','MacOS','accessibility'),'');process.env.CODEWHALE_CU_APP_BUNDLE=bundle; |
| 201 | const calls=[]; |
| 202 | const backend=create({exec:leaseExecutor(async (cmd,args)=>{ |
| 203 | assert.notEqual(cmd,'open','binding a running app must not reopen its windows'); |
| 204 | const request=JSON.parse(args[0]);calls.push(request); |
| 205 | assert.notEqual(request.tool,'cursor_position','release must not sample the physical pointer'); |
| 206 | const body=request.tool==='app_info'?{found:true,pid:123,bundle_id:'test.app'} |
| 207 | :request.tool==='window_at_point'?{found:true,owner_pid:123,owner_name:'TextEdit',window_id:9,layer:0} |
| 208 | :{action_sent:true}; |
| 209 | return {code:0,stderr:'',stdout:JSON.stringify(body)}; |
| 210 | })}); |
| 211 | await backend.open_application({name:'TextEdit'}); |
| 212 | assert.equal(calls[0].args.activate,false); |
| 213 | await assert.rejects(backend.left_mouse_up({}),/no agent pointer/); |
| 214 | await backend.open_application({name:'TextEdit',activate:true}); |
| 215 | await backend.left_mouse_down({target:{x:100,y:200}}); |
| 216 | await backend.mouse_move({target:{x:140,y:250}}); |
| 217 | await backend.left_mouse_up({}); |
| 218 | const release=calls.at(-1); |
| 219 | assert.equal(release.tool,'release_input'); |
| 220 | assert.deepEqual(release.args.point,{x:140,y:250},'release lands at the agent pointer'); |
| 221 | assert.equal(release.args.restore,false,'a held button is not put back'); |
| 222 | assert.equal(release.args.input_app_ref.pid,123); |
| 223 | assert.ok(!calls.some(c=>c.tool==='preview_notify'),'background actions do not open preview'); |
| 224 | }); |
| 225 | |
| 226 | /** Backend wired to a scripted native helper; returns the requests it made. */ |
| 227 | function stubBackend(t, reply) { |
| 228 | const bundle = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-hit-test-')); |
| 229 | const old = process.env.CODEWHALE_CU_APP_BUNDLE; |
| 230 | t.after(() => { if (old === undefined) delete process.env.CODEWHALE_CU_APP_BUNDLE; else process.env.CODEWHALE_CU_APP_BUNDLE = old; fs.rmSync(bundle, { recursive: true, force: true }); }); |
| 231 | fs.mkdirSync(path.join(bundle, 'Contents', 'MacOS'), { recursive: true }); |
| 232 | fs.writeFileSync(path.join(bundle, 'Contents', 'MacOS', 'accessibility'), ''); |
| 233 | process.env.CODEWHALE_CU_APP_BUNDLE = bundle; |
| 234 | const calls = []; |
| 235 | const backend = create({ exec: leaseExecutor(async (cmd, args) => { |
| 236 | const request = JSON.parse(args[0]); |
| 237 | calls.push(request); |
| 238 | const custom = reply(request); |
| 239 | if (custom?.nativeResult) return custom.nativeResult; |
| 240 | const body = request.tool === 'app_info' ? custom ?? { found: true, pid: 321, bundle_id: 'test.app' } |
| 241 | : custom |
| 242 | ?? (request.tool === 'window_at_point' ? { found: true, owner_pid: 321, owner_name: 'TextEdit', window_id: 9, layer: 0 } |
| 243 | : { action_sent: true, restored: true }); |
| 244 | return { code: 0, stderr: '', stdout: JSON.stringify(body) }; |
| 245 | }) }); |
| 246 | return { backend, calls }; |
| 247 | } |
| 248 | |
| 249 | const PRESSABLE = { found: true, element: { role: 'AXButton', label: 'Tab B', actions: ['AXPress'] }, action: 'AXPress', action_sent: true }; |
| 250 | |
| 251 | test('busy native input keeps its typed refusal through ordinary and held-input routes', async t => { |
| 252 | const { backend } = stubBackend(t, r => { |
| 253 | if (r.tool === 'input_capabilities') return { input_lease: 1, background_focus_guard: 1 }; |
| 254 | if (r.tool === 'type' || r.tool === 'key_event') return { nativeResult: { |
| 255 | code: 1, stdout: '', stderr: 'user_busy: no quiet input window became available; no input was sent.', |
| 256 | } }; |
| 257 | return null; |
| 258 | }); |
| 259 | await backend.open_application({ name: 'Fixture' }); |
| 260 | await assert.rejects(backend.type({ text: 'hello' }), { code: 'user_busy' }); |
| 261 | await assert.rejects(backend.key({ text: 'a' }), { code: 'user_busy' }); |
| 262 | }); |
| 263 | const NOT_PRESSABLE = { found: false, reason: 'no_pressable_element' }; |
| 264 | const FILES_TARGET = { type:'element', app_ref:{pid:321,bundle_id:'test.app'}, windowIndex:0, path:[0,4,2], |
| 265 | role:'AXMenuItem', label:'Files', x:1607, y:692 }; |
| 266 | |
| 267 | test('macOS background scroll and context menus keep the selected element without a global gesture', async t => { |
| 268 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,background_actions:1}:null); |
| 269 | await backend.open_application({name:'Fixture'}); |
| 270 | const area={...FILES_TARGET,role:'AXScrollArea'}; |
| 271 | await backend.scroll({target:area,direction:'down',amount:4}); |
| 272 | assert.deepEqual(calls.find(r=>r.tool==='scroll_element').args.target,area); |
| 273 | await backend.right_click({target:FILES_TARGET}); |
| 274 | assert.equal(calls.find(r=>r.tool==='click_element').args.context,true); |
| 275 | assert.ok(!calls.some(r=>['pointer_sequence','window_at_point','mouse_event'].includes(r.tool))); |
| 276 | }); |
| 277 | |
| 278 | test('macOS scroll cannot retry an ambiguous semantic dispatch or downgrade an old helper', async t => { |
| 279 | let supported=false; |
| 280 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,background_actions:supported?1:0} |
| 281 | :r.tool==='scroll_element'?{nativeResult:{code:null,spawned:true,timedOut:true,stdout:'',stderr:''}}:null); |
| 282 | await backend.open_application({name:'Fixture'}); |
| 283 | await assert.rejects(backend.scroll({target:FILES_TARGET}),e=>e.code==='app_upgrade_required'); |
| 284 | assert.equal(calls.filter(r=>r.tool==='scroll_element').length,0); |
| 285 | supported=true; |
| 286 | await assert.rejects(backend.scroll({target:FILES_TARGET}),e=>e.inputMayHaveBeenSent===true); |
| 287 | assert.equal(calls.filter(r=>r.tool==='scroll_element').length,1); |
| 288 | assert.ok(!calls.some(r=>r.tool==='pointer_sequence')); |
| 289 | }); |
| 290 | |
| 291 | test('macOS unqualified observations stay bound; malformed explicit targets never select the foreground', async t => { |
| 292 | const {backend,calls}=stubBackend(t, r=>r.tool==='get_app_state'?{found:true,pid:321,elements:[]}:null); |
| 293 | await backend.open_application({name:'Fixture'}); |
| 294 | for(const tool of ['get_app_state','list_windows']) { |
| 295 | await backend[tool]({}); |
| 296 | assert.equal(calls.at(-1).args.app_ref.pid,321); |
| 297 | await backend[tool]({app_ref:null}); |
| 298 | assert.equal(calls.at(-1).args.app_ref,null); |
| 299 | } |
| 300 | }); |
| 301 | |
| 302 | test('macOS failed activation cannot leave a previous shared-desktop binding armed', async t => { |
| 303 | let frontmost=true; |
| 304 | const {backend,calls}=stubBackend(t,r=>r.tool==='app_info'?{found:true,pid:321,bundle_id:'test.app',frontmost}:null); |
| 305 | await backend.open_application({name:'Fixture',activate:true}); |
| 306 | frontmost=false; |
| 307 | await assert.rejects(backend.open_application({name:'Fixture',activate:true}),e=>e.code==='activation_not_confirmed'); |
| 308 | await assert.rejects(backend.mouse_move({target:{x:10,y:10}}),e=>e.code==='shared_pointer_required'); |
| 309 | assert.ok(!calls.some(r=>r.tool==='pointer_sequence')); |
| 310 | }); |
| 311 | |
| 312 | test('macOS lease verdict flags hardware input inside the borrow window only', () => { |
| 313 | const base = { front_lease: true, lease_ms: 120, idle_before_s: 5.0 }; |
| 314 | assert.equal(leaseVerdict({ ...base, idle_after_s: 5.12 }), false); |
| 315 | assert.equal(leaseVerdict({ ...base, idle_after_s: 0.05 }), true); |
| 316 | assert.equal(leaseVerdict({ ...base, idle_after_s: 5.12 - 0.24 }), false); |
| 317 | assert.equal(leaseVerdict({ front_lease: false, lease_ms: 120, idle_before_s: 5, idle_after_s: 0.01 }), null); |
| 318 | assert.equal(leaseVerdict({ front_lease: true, lease_ms: 120 }), null); |
| 319 | assert.equal(leaseVerdict(null), null); |
| 320 | const threaded = leaseAccounting({ ...base, idle_after_s: 0.05, user_input_during_lease: true }); |
| 321 | assert.deepEqual(threaded, { lease_ms: 120, idle_before_s: 5.0, idle_after_s: 0.05, user_input_during_lease: true }); |
| 322 | assert.deepEqual(leaseAccounting({ front_lease: true }), {}); |
| 323 | assert.deepEqual(leaseAccounting({ front_lease: false }), {}); |
| 324 | }); |
| 325 | |
| 326 | test('background key focus is refused even when an older helper advertises window records', async t => { |
| 327 | const { backend, calls } = stubBackend(t, r => r.tool === 'input_capabilities' ? { input_lease: 1, window_record: 1 } : null); |
| 328 | for (const args of [{text:'cmd+w'}, {text:'return',target:{type:'element',index:1}}]) { |
| 329 | await assert.rejects(backend.key(args), {code:'background_focus_required'}); |
| 330 | } |
| 331 | assert.ok(!calls.some(r => ['bg_key','key_event'].includes(r.tool))); |
| 332 | }); |
| 333 | |
| 334 | test('macOS open_application reports launched only when it actually launched the app', async t => { |
| 335 | const bundle=fs.mkdtempSync(path.join(os.tmpdir(),'cu-launch-flag-')); |
| 336 | const old=process.env.CODEWHALE_CU_APP_BUNDLE; |
| 337 | t.after(()=>{ if(old===undefined) delete process.env.CODEWHALE_CU_APP_BUNDLE; else process.env.CODEWHALE_CU_APP_BUNDLE=old; fs.rmSync(bundle,{recursive:true,force:true}); }); |
| 338 | fs.mkdirSync(path.join(bundle,'Contents','MacOS'),{recursive:true}); |
| 339 | fs.writeFileSync(path.join(bundle,'Contents','MacOS','accessibility'),''); |
| 340 | process.env.CODEWHALE_CU_APP_BUNDLE=bundle; |
| 341 | let running=true; |
| 342 | const opens=[]; |
| 343 | const backend=create({exec:{run:async(cmd,args)=>{ |
| 344 | if(cmd==='open'){ opens.push(args); running=true; return {code:0,stderr:'',stdout:''}; } |
| 345 | const request=JSON.parse(args[0]); |
| 346 | if(request.tool==='app_info'){ |
| 347 | if(!running) return {code:1,stderr:'application not found',stdout:''}; |
| 348 | return {code:0,stderr:'',stdout:JSON.stringify({found:true,pid:321,name:'Fixture',bundle_id:'test.app',frontmost:false})}; |
| 349 | } |
| 350 | return {code:0,stderr:'',stdout:JSON.stringify({action_sent:true})}; |
| 351 | }}}); |
| 352 | const rebound=await backend.open_application({name:'Fixture'}); |
| 353 | assert.equal(rebound.launched,false); |
| 354 | assert.equal(opens.length,0); |
| 355 | running=false; |
| 356 | const fresh=await backend.open_application({name:'Fixture'}); |
| 357 | assert.equal(fresh.launched,true); |
| 358 | assert.equal(opens.length,1); |
| 359 | assert.ok(opens[0].includes('-g'),'background launch stays in the background'); |
| 360 | }); |
| 361 | |
| 362 | test('macOS app-scoped fallback also refuses with an older helper', async t => { |
| 363 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1}:r.tool==='hit_test'?NOT_PRESSABLE:null); |
| 364 | await backend.open_application({name:'Fixture'}); |
| 365 | await assert.rejects(backend.left_click({target:{x:70,y:80},strategy:'app'}),{code:'background_focus_required'}); |
| 366 | assert.ok(!calls.some(r=>r.tool==='pointer_sequence')); |
| 367 | }); |
| 368 | |
| 369 | test('macOS background control never escalates an unavailable semantic action to shared pointer input', async t => { |
| 370 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1,background_actions:1,background_focus_guard:1}:r.tool==='hit_test'?NOT_PRESSABLE:null); |
| 371 | const binding=await backend.open_application({name:'Fixture'}); |
| 372 | assert.equal(binding.input_scope,'application'); |
| 373 | assert.equal(binding.shared_pointer,false); |
| 374 | assert.equal(binding.isolated_desktop,false); |
| 375 | const target={x:70,y:80}; |
| 376 | for(const [tool,args] of [ |
| 377 | ['left_click',{target}], ['left_click',{target,strategy:'event'}], |
| 378 | ['double_click',{target}], ['triple_click',{target}], ['right_click',{target}], |
| 379 | ['middle_click',{target}], ['mouse_move',{target}], ['left_mouse_down',{target}], |
| 380 | ['left_click_drag',{from_target:target,to:{x:90,y:100}}], ['scroll',{target}], |
| 381 | ]) await assert.rejects(backend[tool](args),error=>error.code===(tool==='scroll'?'background_scroll_unavailable':'shared_pointer_required')); |
| 382 | assert.ok(!calls.some(r=>['pointer_sequence','release_input','window_at_point'].includes(r.tool))); |
| 383 | await backend.type({text:'Background typing'}); |
| 384 | const typed=calls.filter(r=>r.tool==='type'); |
| 385 | assert.equal(typed.length,1); |
| 386 | assert.equal(typed[0].args.foreground_input,false); |
| 387 | }); |
| 388 | |
| 389 | test('macOS returning to background stops held-pointer movement while preserving its release', async t => { |
| 390 | const {backend,calls}=stubBackend(t,()=>null); |
| 391 | const binding=await backend.open_application({name:'Fixture',activate:true}); |
| 392 | assert.equal(binding.input_scope,'shared-desktop'); |
| 393 | assert.equal(binding.shared_pointer,true); |
| 394 | assert.equal(binding.isolated_desktop,false); |
| 395 | await backend.left_mouse_down({target:{x:70,y:80}}); |
| 396 | await backend.open_application({name:'Fixture',activate:false}); |
| 397 | const before=calls.length; |
| 398 | await assert.rejects(backend.mouse_move({target:{x:90,y:100}}),error=>error.code==='shared_pointer_required'); |
| 399 | assert.equal(calls.length,before); |
| 400 | await backend.releaseInput(); |
| 401 | assert.equal(calls.at(-1).tool,'release_input'); |
| 402 | assert.equal(calls.at(-1).args.foreground_input,true); |
| 403 | }); |
| 404 | |
| 405 | test('macOS element click preserves the observed path despite an oversized frame center', async t => { |
| 406 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1,background_actions:1,background_focus_guard:1}:r.tool==='hit_test'?PRESSABLE:null); |
| 407 | await backend.open_application({name:'Fixture'}); |
| 408 | const receipt=await backend.left_click({target:FILES_TARGET}); |
| 409 | assert.equal(receipt.action_sent,true); |
| 410 | assert.equal(receipt.element.label,'Files'); |
| 411 | assert.equal(receipt.verified,false); |
| 412 | assert.equal(receipt.verification_required,'observation'); |
| 413 | const presses=calls.filter(r=>r.tool==='click_element'); |
| 414 | assert.equal(presses.length,1,'one dispatch, even when the app does not report a changed state'); |
| 415 | assert.deepEqual(presses[0].args.target,FILES_TARGET); |
| 416 | assert.equal(presses[0].args.action,'AXPress'); |
| 417 | assert.ok(!calls.some(r=>['hit_test','pointer_sequence','window_at_point'].includes(r.tool)),'the center never selects another control'); |
| 418 | }); |
| 419 | |
| 420 | for(const reason of ['action is not advertised by this element','element changed label; observe again','window blocked by modal sheet']) |
| 421 | test(`macOS element click does not fall back after ${reason}`, async t => { |
| 422 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1,background_actions:1,background_focus_guard:1} |
| 423 | :r.tool==='click_element'?{nativeResult:{code:1,stdout:'',stderr:reason}}:null); |
| 424 | await backend.open_application({name:'Fixture'}); |
| 425 | await assert.rejects(backend.left_click({target:FILES_TARGET,strategy:'a11y'}),error=>error.message.includes(reason)&&/fresh screenshot or OCR/.test(error.message)); |
| 426 | assert.equal(calls.filter(r=>r.tool==='click_element').length,1); |
| 427 | assert.ok(!calls.some(r=>['hit_test','pointer_sequence'].includes(r.tool))); |
| 428 | }); |
| 429 | |
| 430 | test('macOS ambiguous element press is never retried or converted to pointer input', async t => { |
| 431 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,element_identity:1,background_actions:1,background_focus_guard:1} |
| 432 | :r.tool==='click_element'?{nativeResult:{code:null,spawned:true,timedOut:true,stdout:'',stderr:''}}:null); |
| 433 | await backend.open_application({name:'Fixture'}); |
| 434 | await assert.rejects(backend.left_click({target:FILES_TARGET}),error=>error.inputMayHaveBeenSent===true&&/timed out/.test(error.message)); |
| 435 | assert.equal(calls.filter(r=>r.tool==='click_element').length,1); |
| 436 | assert.ok(!calls.some(r=>['hit_test','pointer_sequence'].includes(r.tool))); |
| 437 | }); |
| 438 | |
| 439 | test('macOS element clicks refuse missing identity, another bound app and an old helper before dispatch', async t => { |
| 440 | const {backend,calls}=stubBackend(t,()=>null); |
| 441 | await backend.open_application({name:'Fixture'}); |
| 442 | await assert.rejects(backend.left_click({target:{...FILES_TARGET,path:undefined}}),/no resolved accessibility identity/); |
| 443 | await assert.rejects(backend.left_click({target:{...FILES_TARGET,app_ref:{pid:999}}}),/bound application/); |
| 444 | await assert.rejects(backend.left_click({target:FILES_TARGET}),/helper needs an update/); |
| 445 | assert.ok(!calls.some(r=>['perform_action','hit_test','pointer_sequence'].includes(r.tool))); |
| 446 | }); |
| 447 | |
| 448 | test('macOS explicit event selection remains usable and retains the app ownership guard', async t => { |
| 449 | let covered=false; |
| 450 | const {backend,calls}=stubBackend(t,r=>r.tool==='window_at_point'&&covered?{found:true,owner_pid:999,owner_name:'Mail'}:null); |
| 451 | await backend.open_application({name:'Fixture',activate:true}); |
| 452 | assert.equal((await backend.left_click({target:FILES_TARGET,strategy:'event'})).strategy,'event'); |
| 453 | const seq=calls.find(r=>r.tool==='pointer_sequence'); |
| 454 | assert.deepEqual([seq.args.steps[1].x,seq.args.steps[1].y],[1607,692]); |
| 455 | covered=true; |
| 456 | await assert.rejects(backend.left_click({target:FILES_TARGET,strategy:'event'}),/covered by a window owned by Mail/); |
| 457 | assert.equal(calls.filter(r=>r.tool==='pointer_sequence').length,1); |
| 458 | assert.ok(!calls.some(r=>['perform_action','hit_test'].includes(r.tool))); |
| 459 | }); |
| 460 | |
| 461 | test('macOS refuses an old native helper before any held input is dispatched', async t => { |
| 462 | const {backend,calls}=stubBackend(t,request=>request.tool==='input_capabilities'?{input_lease:0}:null); |
| 463 | await backend.open_application({name:'Fixture',activate:true}); |
| 464 | await assert.rejects(backend.key({text:'cmd+n'}),/helper needs an update/); |
| 465 | await assert.rejects(backend.left_mouse_down({target:{x:70,y:80}}),/helper needs an update/); |
| 466 | assert.ok(!calls.some(request=>['key_event','pointer_sequence','release_input'].includes(request.tool))); |
| 467 | }); |
| 468 | |
| 469 | test('macOS pointer cleanup keeps its original app after a background rebind', async t => { |
| 470 | const {backend,calls}=stubBackend(t,request=>request.tool==='app_info'?{found:true,pid:request.args.app_ref.name==='First'?321:654,bundle_id:'test.app'}:null); |
| 471 | await backend.open_application({name:'First',activate:true}); |
| 472 | await backend.left_mouse_down({target:{x:70,y:80}}); |
| 473 | await backend.open_application({name:'Second',activate:false}); |
| 474 | await backend.releaseInput(); |
| 475 | const release=calls.find(request=>request.tool==='release_input'); |
| 476 | assert.equal(release.args.foreground_input,true,'release belongs to the native owner from the original binding'); |
| 477 | assert.equal(release.args.input_app_ref.pid,321); |
| 478 | }); |
| 479 | |
| 480 | test('macOS cancellation releases a held key without replaying it', async t => { |
| 481 | const controller = new AbortController(); |
| 482 | const { backend, calls } = stubBackend(t, request => { |
| 483 | if (request.tool === 'key_event' && request.args.down) controller.abort(); |
| 484 | if (request.tool === 'key_event' && !request.args.down) assert.equal(currentSignal(), null); |
| 485 | }); |
| 486 | await backend.open_application({name:'Fixture',activate:true}); |
| 487 | await assert.rejects(withSignal(controller.signal, () => backend.hold_key({text:'shift+a', duration:30})), /cancelled/); |
| 488 | assert.deepEqual(calls.filter(r=>r.tool==='key_event').map(r=>r.args.down), [true,false]); |
| 489 | }); |
| 490 | |
| 491 | test('macOS session cleanup releases only its owned mouse press once', async t => { |
| 492 | const { backend, calls } = stubBackend(t, () => null); |
| 493 | await backend.open_application({name:'Fixture',activate:true}); |
| 494 | await backend.releaseInput(); |
| 495 | assert.ok(!calls.some(r=>r.tool==='release_input')); |
| 496 | await backend.left_mouse_down({target:{x:70,y:80}}); |
| 497 | await withSignal(AbortSignal.abort(), () => backend.releaseInput()); |
| 498 | await backend.releaseInput(); |
| 499 | const releases=calls.filter(r=>r.tool==='release_input'); |
| 500 | assert.equal(releases.length,1); |
| 501 | assert.deepEqual(releases[0].args.point,{x:70,y:80}); |
| 502 | }); |
| 503 | |
| 504 | test('macOS foreground delivery requires explicit activation and resets on background binding', async t => { |
| 505 | const { backend, calls } = stubBackend(t, () => null); |
| 506 | await backend.open_application({name:'Fixture',activate:true}); |
| 507 | assert.equal((await backend.key({text:'return'})).keyboard_delivery,'foreground-guarded'); |
| 508 | assert.ok(calls.filter(r=>r.tool==='key_event').every(r=>r.args.foreground_input)); |
| 509 | await backend.open_application({name:'Fixture',activate:false}); |
| 510 | assert.equal((await backend.key({text:'return'})).keyboard_delivery,'process'); |
| 511 | assert.equal(calls.at(-1).args.foreground_input,false); |
| 512 | }); |
| 513 | |
| 514 | test('background modifier holds refuse while plain process keys remain available', async t => { |
| 515 | const {backend,calls}=stubBackend(t,()=>null); |
| 516 | await backend.open_application({name:'Fixture'}); |
| 517 | await assert.rejects(backend.hold_key({text:'cmd+a',duration:0.05}),{code:'background_focus_required'}); |
| 518 | assert.ok(!calls.some(c=>['bg_key','key_event'].includes(c.tool))); |
| 519 | assert.equal((await backend.key({text:'tab'})).keyboard_delivery,'process'); |
| 520 | assert.ok(calls.filter(c=>c.tool==='key_event').every(c=>c.args.foreground_input===false)); |
| 521 | }); |
| 522 | |
| 523 | test('background pointer fallbacks refuse without dispatch or moving focus', async t => { |
| 524 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities' |
| 525 | ?{input_lease:1,window_record:1,background_actions:1}:r.tool==='hit_test'?NOT_PRESSABLE:null); |
| 526 | await backend.open_application({name:'Fixture'}); |
| 527 | for (const call of [ |
| 528 | ()=>backend.left_click({target:{x:20,y:20}}), |
| 529 | ()=>backend.left_click({target:{x:20,y:20},strategy:'app'}), |
| 530 | ()=>backend.left_click_drag({from_target:{x:20,y:20},to:{x:30,y:30}}), |
| 531 | ()=>backend.scroll({target:{x:20,y:20},direction:'down'}), |
| 532 | ]) await assert.rejects(call(),{code:'background_focus_required'}); |
| 533 | assert.ok(!calls.some(c=>['bg_pointer','pointer_sequence'].includes(c.tool))); |
| 534 | }); |
| 535 | |
| 536 | test('background web value replacement refuses before focusing or selecting text', async t => { |
| 537 | const {backend,calls}=stubBackend(t,r=>r.tool==='set_value'?{nativeResult:{code:1,stderr:'web area refuses direct AXValue'}}:null); |
| 538 | await assert.rejects(backend.set_value({target:{type:'element',index:1},value:'replacement'}),{code:'background_focus_required'}); |
| 539 | assert.ok(!calls.some(c=>['focus_element','bg_key','type'].includes(c.tool))); |
| 540 | }); |
| 541 | |
| 542 | test('background typing refuses a helper without the native focus guard', async t => { |
| 543 | const {backend,calls}=stubBackend(t,r=>r.tool==='input_capabilities'?{input_lease:1,window_record:1}:null); |
| 544 | await assert.rejects(backend.type({text:'Hello 🐋'}),{code:'app_upgrade_required'}); |
| 545 | assert.ok(!calls.some(c=>c.tool==='type')); |
| 546 | }); |
| 547 | |
| 548 | test('macOS input handlers refuse a missing target without dereferencing it', async t => { |
| 549 | const {backend,calls}=stubBackend(t,()=>null); |
| 550 | await backend.open_application({name:'Fixture'}); |
| 551 | for (const call of [ |
| 552 | ()=>backend.left_mouse_down({}), |
| 553 | ()=>backend.left_mouse_down(), |
| 554 | ()=>backend.mouse_move({}), |
| 555 | ()=>backend.left_click({}), |
| 556 | ()=>backend.scroll({}), |
| 557 | ()=>backend.select_text({}), |
| 558 | ()=>backend.set_value({value:'x'}), |
| 559 | ()=>backend.perform_action({action:'AXPress'}), |
| 560 | ]) { |
| 561 | await assert.rejects(call, error=>!(error instanceof TypeError)); |
| 562 | } |
| 563 | assert.ok(!calls.some(c=>['pointer_sequence','bg_pointer','select_text','set_value','perform_action'].includes(c.tool))); |
| 564 | }); |
| 565 | |
| 566 | test('macOS foreground refusal never sends an unowned global key-up', async t => { |
| 567 | const { backend, calls } = stubBackend(t, request => request.tool === 'key_event' && request.args.down |
| 568 | ? { nativeResult: { code: 1, spawned: true, stdout: '', stderr: 'foreground changed to Mail (pid 999); expected Fixture (pid 321)' } } : null); |
| 569 | await backend.open_application({name:'Fixture',activate:true}); |
| 570 | await assert.rejects(backend.key({text:'cmd+n'}), /foreground changed to Mail \(pid 999\)/); |
| 571 | await assert.rejects(backend.hold_key({text:'shift+a',duration:30}), /expected Fixture/); |
| 572 | assert.deepEqual(calls.filter(r=>r.tool==='key_event').map(r=>r.args.down), [true,true]); |
| 573 | }); |
| 574 | |
| 575 | test('macOS cancellation before child spawn does not release keys it never pressed', async t => { |
| 576 | const { backend, calls } = stubBackend(t, request => request.tool === 'key_event' && request.args.down |
| 577 | ? { nativeResult: { code: -1, spawned: false, aborted: true, stdout: '', stderr: '' } } : null); |
| 578 | await backend.open_application({name:'Fixture',activate:true}); |
| 579 | await assert.rejects(backend.hold_key({text:'shift+a',duration:30}), /cancelled/); |
| 580 | assert.deepEqual(calls.filter(r=>r.tool==='key_event').map(r=>r.args.down), [true]); |
| 581 | }); |
| 582 | |
| 583 | for (const failure of ['aborted','timedOut']) test(`macOS ${failure} after child spawn releases an ambiguous key press`, async t => { |
| 584 | const { backend, calls } = stubBackend(t, request => { |
| 585 | if(request.tool==='key_event' && request.args.down) return { nativeResult: { code: null, spawned: true, [failure]: true, stdout: '', stderr: '' } }; |
| 586 | if(request.tool==='key_event' && !request.args.down) assert.equal(currentSignal(),null); |
| 587 | }); |
| 588 | await backend.open_application({name:'Fixture',activate:true}); |
| 589 | await assert.rejects(backend.key({text:'cmd+n'}), /cancelled|timed out/); |
| 590 | const events=calls.filter(r=>r.tool==='key_event'); |
| 591 | assert.deepEqual(events.map(r=>r.args.down), [true,false]); |
| 592 | assert.equal(events[1].args.owned_release,true); |
| 593 | }); |
| 594 | |
| 595 | test('macOS refused mouse-down cannot acquire release ownership', async t => { |
| 596 | const { backend, calls } = stubBackend(t, request => request.tool==='window_at_point' |
| 597 | ? {found:true,owner_pid:999,owner_name:'Mail'} : null); |
| 598 | await backend.open_application({name:'Fixture',activate:true}); |
| 599 | await assert.rejects(backend.left_mouse_down({target:{x:70,y:80}}), /owned by Mail/); |
| 600 | await backend.releaseInput(); |
| 601 | assert.ok(!calls.some(r=>['pointer_sequence','release_input'].includes(r.tool))); |
| 602 | }); |
| 603 | |
| 604 | test('macOS cancellation during the ownership probe cannot acquire release ownership', async t => { |
| 605 | const { backend, calls } = stubBackend(t, request => request.tool==='window_at_point' |
| 606 | ? {nativeResult:{code:null,spawned:true,aborted:true,stdout:'',stderr:''}} : null); |
| 607 | await backend.open_application({name:'Fixture',activate:true}); |
| 608 | await assert.rejects(backend.left_mouse_down({target:{x:70,y:80}}), /cancelled/); |
| 609 | await backend.releaseInput(); |
| 610 | assert.ok(!calls.some(r=>['pointer_sequence','release_input'].includes(r.tool))); |
| 611 | }); |
| 612 | |
| 613 | test('macOS a single mouse-down ownership guard precedes the dispatch and ambiguous cleanup', async t => { |
| 614 | const { backend, calls } = stubBackend(t, request => request.tool==='pointer_sequence' |
| 615 | ? {nativeResult:{code:null,spawned:true,aborted:true,stdout:'',stderr:''}} : null); |
| 616 | await backend.open_application({name:'Fixture',activate:true}); |
| 617 | await assert.rejects(backend.left_mouse_down({target:{x:70,y:80}}), /cancelled/); |
| 618 | assert.equal(calls.filter(r=>r.tool==='window_at_point').length,1); |
| 619 | await backend.releaseInput(); |
| 620 | assert.equal(calls.at(-1).args.point.x,70); |
| 621 | assert.equal(calls.at(-1).args.point.y,80); |
| 622 | assert.equal(calls.at(-1).tool,'release_input'); |
| 623 | }); |
| 624 | |
| 625 | test('macOS coordinate left_click prefers the accessibility element under the point', async (t) => { |
| 626 | const { backend, calls } = stubBackend(t, (r) => (r.tool === 'hit_test' ? PRESSABLE : null)); |
| 627 | await backend.open_application({ name: 'TextEdit' }); |
| 628 | const receipt = await backend.left_click({ target: { x: 220, y: 180 } }); |
| 629 | assert.equal(receipt.strategy, 'a11y'); |
| 630 | assert.equal(receipt.action, 'AXPress'); |
| 631 | assert.equal(receipt.element.label, 'Tab B'); |
| 632 | const hit = calls.find((c) => c.tool === 'hit_test'); |
| 633 | assert.deepEqual([hit.args.x, hit.args.y, hit.args.perform], [220, 180, true]); |
| 634 | assert.equal(hit.args.input_app_ref.pid, 321); |
| 635 | assert.ok(!calls.some((c) => c.tool === 'mouse_event'), 'a semantic press must not also post raw pointer events'); |
| 636 | }); |
| 637 | |
| 638 | test('macOS coordinate left_click falls back to a guarded global gesture when no element is pressable', async (t) => { |
| 639 | const { backend, calls } = stubBackend(t, (r) => (r.tool === 'input_capabilities' ? {input_lease:1,background_actions:1} : r.tool === 'hit_test' ? NOT_PRESSABLE : null)); |
| 640 | await backend.open_application({ name: 'TextEdit', activate:true }); |
| 641 | const receipt = await backend.left_click({ target: { x: 40, y: 90 } }); |
| 642 | assert.equal(receipt.strategy, 'event'); |
| 643 | assert.equal(receipt.pointer_moved, true, 'the receipt admits the real cursor moved'); |
| 644 | assert.equal(receipt.a11y_reason, 'no_pressable_element'); |
| 645 | |
| 646 | const guard = calls.find((c) => c.tool === 'window_at_point'); |
| 647 | assert.deepEqual([guard.args.x, guard.args.y], [40, 90], 'ownership of the landing point is checked first'); |
| 648 | const seq = calls.find((c) => c.tool === 'pointer_sequence'); |
| 649 | assert.deepEqual(seq.args.steps.map((s) => s.type), [5, 1, 2], 'move, down, up in one gesture'); |
| 650 | assert.deepEqual([seq.args.steps[1].x, seq.args.steps[1].y, seq.args.steps[1].clickState], [40, 90, 1]); |
| 651 | assert.equal(seq.args.restore, true, 'the user gets their pointer back'); |
| 652 | }); |
| 653 | |
| 654 | test('macOS refuses a global gesture whose landing point belongs to another application', async (t) => { |
| 655 | const { backend, calls } = stubBackend(t, (r) => (r.tool === 'hit_test' ? NOT_PRESSABLE |
| 656 | : r.tool === 'window_at_point' ? { found: true, owner_pid: 999, owner_name: 'Mail', window_id: 4, layer: 0 } : null)); |
| 657 | await backend.open_application({ name: 'TextEdit', activate:true }); |
| 658 | await assert.rejects(backend.left_click({ target: { x: 40, y: 90 } }), /covered by a window owned by Mail/); |
| 659 | assert.ok(!calls.some((c) => c.tool === 'pointer_sequence'), 'nothing is posted into the other application'); |
| 660 | }); |
| 661 | |
| 662 | test('macOS click strategies: event skips the tree and a11y fails closed', async (t) => { |
| 663 | const { backend, calls } = stubBackend(t, (r) => (r.tool === 'input_capabilities' ? {input_lease:1,background_actions:1} : r.tool === 'hit_test' ? NOT_PRESSABLE : null)); |
| 664 | await backend.open_application({ name: 'TextEdit', activate:true }); |
| 665 | |
| 666 | const forced = await backend.left_click({ target: { x: 10, y: 20 }, strategy: 'event' }); |
| 667 | assert.equal(forced.strategy, 'event'); |
| 668 | assert.ok(!calls.some((c) => c.tool === 'hit_test'), 'strategy=event never hit-tests'); |
| 669 | |
| 670 | await assert.rejects(backend.left_click({ target: { x: 10, y: 20 }, strategy: 'a11y' }), /no supported accessibility click/); |
| 671 | await assert.rejects(backend.left_click({ target: { x: 10, y: 20 }, strategy: 'sideways' }), /strategy must be auto, a11y, app or event/); |
| 672 | |
| 673 | calls.length = 0; |
| 674 | const dbl = await backend.double_click({ target: { x: 10, y: 20 } }); |
| 675 | assert.equal(dbl.strategy, 'event'); |
| 676 | assert.deepEqual(calls.find((c) => c.tool === 'pointer_sequence').args.steps.map((s) => s.clickState), [0, 1, 1, 2, 2]); |
| 677 | assert.equal((await backend.right_click({ target: { x: 10, y: 20 } })).strategy, 'event'); |
| 678 | assert.equal(calls.find(c => c.tool === 'hit_test').args.operation, 'context'); |
| 679 | }); |
| 680 | |
| 681 | test('macOS drag and scroll travel as one gesture that puts the pointer back', async (t) => { |
| 682 | const { backend, calls } = stubBackend(t, () => null); |
| 683 | await backend.open_application({ name: 'TextEdit', activate:true }); |
| 684 | |
| 685 | const drag = await backend.left_click_drag({ from_target: { x: 10, y: 10 }, to: { x: 110, y: 10 } }); |
| 686 | assert.equal(drag.pointer_moved, true); |
| 687 | const dragSeq = calls.find((c) => c.tool === 'pointer_sequence'); |
| 688 | assert.equal(dragSeq.args.restore, true); |
| 689 | assert.equal(dragSeq.args.steps.at(-1).type, 2, 'released at the destination'); |
| 690 | assert.deepEqual([dragSeq.args.steps.at(-1).x, dragSeq.args.steps.at(-1).y], [110, 10]); |
| 691 | |
| 692 | calls.length = 0; |
| 693 | await backend.scroll({ target: { x: 10, y: 10 }, direction: 'down', amount: 3 }); |
| 694 | const scrollSeq = calls.find((c) => c.tool === 'pointer_sequence'); |
| 695 | const notches = scrollSeq.args.steps.filter((s) => s.scroll); |
| 696 | assert.equal(notches.length, 3, 'one notch per unit of amount, like a real wheel'); |
| 697 | assert.deepEqual(notches[0].scroll, [0, -1]); |
| 698 | assert.equal(scrollSeq.args.restore, true); |
| 699 | }); |
| 700 | |
| 701 | test('native hit_test fails closed without a bound application', { skip: process.platform !== 'darwin' }, (t) => { |
| 702 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-hit-native-')); |
| 703 | t.after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 704 | const binary = path.join(dir, 'native'); |
| 705 | const build = spawnSync('clang', ['-DCU_TEST=1', '-fobjc-arc', '-Os', '-framework', 'Cocoa', '-framework', 'ApplicationServices', '-framework', 'ScreenCaptureKit', '-framework', 'AVFoundation', '-framework', 'CoreMedia', '-framework', 'Vision', 'src/backends/darwin-accessibility.m', '-o', binary], { encoding: 'utf8' }); |
| 706 | assert.equal(build.status, 0, build.stderr); |
| 707 | const r = spawnSync(binary, [JSON.stringify({ tool: 'hit_test', args: { x: 1, y: 1, perform: true } })], { encoding: 'utf8' }); |
| 708 | assert.equal(r.status, 1); |
| 709 | assert.match(r.stderr, /open_application first/); |
| 710 | }); |
| 711 | |
| 712 | test('native type verifies delivery against the focused control and fails closed on a non-text focus', { skip: process.platform !== 'darwin' }, (t) => { |
| 713 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-type-native-')); |
| 714 | t.after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 715 | const binary = path.join(dir, 'native'); |
| 716 | const build = spawnSync('clang', ['-DCU_TEST=1', '-ftrivial-auto-var-init=pattern', '-fobjc-arc', '-Os', '-framework', 'Cocoa', '-framework', 'ApplicationServices', '-framework', 'ScreenCaptureKit', '-framework', 'AVFoundation', '-framework', 'CoreMedia', '-framework', 'Vision', 'src/backends/darwin-accessibility.m', '-o', binary], { encoding: 'utf8' }); |
| 717 | assert.equal(build.status, 0, build.stderr); |
| 718 | const type = (args) => spawnSync(binary, [JSON.stringify({ tool: 'inspect_type', args })], { encoding: 'utf8' }); |
| 719 | |
| 720 | let r = type({ text: 'world', focused: { AXRole: 'AXTextField', AXValue: 'Hello ', after: 'Hello world' } }); |
| 721 | assert.equal(r.status, 0, r.stderr); |
| 722 | let receipt = JSON.parse(r.stdout); |
| 723 | assert.equal(receipt.action_sent, true); |
| 724 | assert.equal(receipt.chars, 5); |
| 725 | assert.equal(receipt.strategy, 'unicode-events'); |
| 726 | assert.equal(receipt.verified, true, 'suffix match confirms delivery'); |
| 727 | assert.equal(receipt.focused_role, 'AXTextField'); |
| 728 | assert.ok(!('verification_required' in receipt)); |
| 729 | |
| 730 | r = type({ text: 'their', focused: { AXRole: 'AXTextArea', AXValue: 'I love ', after: 'I love thier' } }); |
| 731 | assert.equal(JSON.parse(r.stdout).verified, false, 'matching length does not establish that the requested text landed'); |
| 732 | r = type({ text: 'world', focused: { AXRole: 'AXTextField', AXValue: 'Hello world', after: 'Hello world' } }); |
| 733 | assert.equal(JSON.parse(r.stdout).verified, false, 'an existing suffix must not verify a dropped insertion'); |
| 734 | |
| 735 | r = type({ text: 'x', focused: { AXRole: 'AXButton', AXTitle: 'Save' } }); |
| 736 | assert.equal(r.status, 1, 'a clearly non-text focus refuses before any event is posted'); |
| 737 | assert.match(r.stderr, /focused element is a AXButton, not a text control/); |
| 738 | |
| 739 | for (const focused of [null, { AXRole: 'AXWebArea' }, { AXRole: 'AXSecureTextField' }, { AXRole: 'AXTextField', AXValue: 'abc' }]) { |
| 740 | r = type({ text: 'hi', focused }); |
| 741 | assert.equal(r.status, 0, r.stderr); |
| 742 | receipt = JSON.parse(r.stdout); |
| 743 | assert.equal(receipt.action_sent, true, 'unverifiable readbacks still report dispatch'); |
| 744 | assert.equal(receipt.verified, false); |
| 745 | assert.equal(receipt.verification_required, 'screenshot'); |
| 746 | } |
| 747 | const unleased = JSON.parse(type({ text: 'hi', focused: null }).stdout); |
| 748 | assert.equal(unleased.focused_role, null); |
| 749 | for (const field of ['front_restored', 'lease_ms', 'idle_before_s', 'idle_after_s']) { |
| 750 | assert.ok(!(field in unleased), `${field} must not be invented for typing without a lease`); |
| 751 | } |
| 752 | |
| 753 | // Exercise the real wait with a deterministic HID clock, without posting |
| 754 | // input. Reuse this native build; cancellation and a busy deadline refuse. |
| 755 | const yieldToUser = (args) => spawnSync(binary, [JSON.stringify({ tool: 'inspect_user_yield', |
| 756 | args: { yield_gap_ms: 450, yield_wait_ms: 60, ...args } })], { encoding: 'utf8' }); |
| 757 | const idle = yieldToUser({ idle_seconds: 1 }); |
| 758 | assert.equal(idle.status, 0, idle.stderr); |
| 759 | assert.ok(Number.isFinite(JSON.parse(idle.stdout).yield_ms)); |
| 760 | for (const idle_seconds of [0, -1]) { |
| 761 | const busy = yieldToUser({ idle_seconds }); |
| 762 | assert.equal(busy.status, 1, 'busy or unavailable HID clock must refuse'); |
| 763 | assert.match(busy.stderr, /^user_busy:.*no input was sent/); |
| 764 | assert.equal(busy.stdout, ''); |
| 765 | } |
| 766 | const cancelled = yieldToUser({ idle_seconds: 0, cancelled: true }); |
| 767 | assert.equal(cancelled.status, 1); |
| 768 | assert.match(cancelled.stderr, /computer request cancelled/); |
| 769 | assert.equal(JSON.parse(yieldToUser({ idle_seconds: 0, yield_gap_ms: 0 }).stdout).yield_ms, 0); |
| 770 | |
| 771 | }); |
| 772 | |
| 773 | test('macOS type passes the native verification receipt through untouched', async (t) => { |
| 774 | const nativeReceipt = { action_sent: true, chars: 5, strategy: 'unicode-events', keyboard_delivery: 'process', verified: false, focused_role: null, verification_required: 'screenshot' }; |
| 775 | const { backend } = stubBackend(t, (r) => (r.tool === 'type' ? nativeReceipt : null)); |
| 776 | await backend.open_application({ name: 'TextEdit' }); |
| 777 | const { preview_error, ...receipt } = await backend.type({ text: 'hello' }); |
| 778 | assert.deepEqual(receipt, nativeReceipt); |
| 779 | assert.ok(preview_error, 'preview refresh failure is reported, not swallowed'); |
| 780 | }); |
| 781 | |
| 782 | // ---------- dogfood 2026-09-17: app_not_found, live preview, sessions, kill ---------- |
| 783 | |
| 784 | const nap = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); |
| 785 | |
| 786 | function withEnv(t, vars) { |
| 787 | const old = {}; |
| 788 | for (const [k, v] of Object.entries(vars)) { |
| 789 | old[k] = process.env[k]; |
| 790 | if (v === undefined) delete process.env[k]; else process.env[k] = v; |
| 791 | } |
| 792 | t.after(() => { for (const [k, v] of Object.entries(old)) { if (v === undefined) delete process.env[k]; else process.env[k] = v; } }); |
| 793 | } |
| 794 | |
| 795 | function fakeBundle(t) { |
| 796 | const bundle = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-fake-bundle-')); |
| 797 | t.after(() => fs.rmSync(bundle, { recursive: true, force: true })); |
| 798 | fs.mkdirSync(path.join(bundle, 'Contents', 'MacOS'), { recursive: true }); |
| 799 | fs.writeFileSync(path.join(bundle, 'Contents', 'MacOS', 'accessibility'), ''); |
| 800 | return bundle; |
| 801 | } |
| 802 | |
| 803 | test('open_application on an unknown name, bundle or pid fails as app_not_found', async (t) => { |
| 804 | withEnv(t, { CODEWHALE_CU_APP_BUNDLE: fakeBundle(t) }); |
| 805 | const backend = create({ exec: { |
| 806 | async run(cmd, args) { |
| 807 | if (cmd === 'open') { |
| 808 | const stderr = args.includes('-b') |
| 809 | ? 'LSCopyApplicationURLsForBundleIdentifier() failed while trying to determine the application with bundle identifier com.nonexistent.app.' |
| 810 | : "Unable to find application named 'NoSuchAppZZZ'"; |
| 811 | return { code: 1, stderr, stdout: '' }; |
| 812 | } |
| 813 | const request = JSON.parse(args[0]); |
| 814 | if (request.tool === 'app_info') { |
| 815 | const pid = request.args?.app_ref?.pid; |
| 816 | return pid |
| 817 | ? { code: 1, stderr: `no running application with pid ${pid}`, stdout: '' } |
| 818 | : { code: 1, stderr: 'application not found', stdout: '' }; |
| 819 | } |
| 820 | return { code: 0, stderr: '', stdout: '{}' }; |
| 821 | }, |
| 822 | async runInputLease() { throw new Error('not used'); }, |
| 823 | } }); |
| 824 | await assert.rejects(backend.open_application({ name: 'NoSuchAppZZZ' }), |
| 825 | (e) => e.code === 'app_not_found' && /open failed: Unable to find application/.test(e.message), |
| 826 | 'a name that resolves nowhere is app_not_found, not tool_error'); |
| 827 | await assert.rejects(backend.open_application({ bundle_id: 'com.nonexistent.app' }), |
| 828 | (e) => e.code === 'app_not_found', 'a bundle id that resolves nowhere is app_not_found'); |
| 829 | await assert.rejects(backend.open_application({ pid: 999999 }), |
| 830 | (e) => e.code === 'app_not_found', 'a dead pid is app_not_found'); |
| 831 | }); |
| 832 | |
| 833 | test('preview goes live after a real capture; mute and session close tear it down', async (t) => { |
| 834 | withEnv(t, { |
| 835 | CODEWHALE_CU_APP_BUNDLE: fakeBundle(t), |
| 836 | CODEWHALE_CU_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'cu-preview-state-')), |
| 837 | CODEWHALE_CU_PREVIEW_REFRESH_MS: '60', |
| 838 | }); |
| 839 | const calls = []; |
| 840 | const backend = create({ exec: { |
| 841 | async run(cmd, args) { |
| 842 | if (cmd === 'screencapture') { |
| 843 | fs.writeFileSync(args[args.length - 1], 'png'); |
| 844 | calls.push({ tool: 'screencapture' }); |
| 845 | return { code: 0, stderr: '', stdout: '' }; |
| 846 | } |
| 847 | const request = JSON.parse(args[0]); |
| 848 | calls.push(request); |
| 849 | const body = request.tool === 'app_info' ? { found: true, pid: 123, bundle_id: 'test.app', name: 'TextEdit' } |
| 850 | : request.tool === 'window_info' ? { window_id: 9, name: 'TextEdit', points: { x: 0, y: 0, w: 100, h: 100 } } |
| 851 | : request.tool === 'cursor_position' ? { x: 1, y: 2 } |
| 852 | : { updated: true }; |
| 853 | return { code: 0, stderr: '', stdout: JSON.stringify(body) }; |
| 854 | }, |
| 855 | async runInputLease() { throw new Error('not used'); }, |
| 856 | } }); |
| 857 | const captures = () => calls.filter((c) => c.tool === 'window_info').length; |
| 858 | |
| 859 | await backend.open_application({ name: 'TextEdit' }); |
| 860 | await nap(400); |
| 861 | const live = captures(); |
| 862 | assert.ok(live >= 2, `the panel refreshes on a timer while bound (${live} captures)`); |
| 863 | assert.ok(calls.some((c) => c.tool === 'preview_notify' && c.args?.enabled === true), 'binding shows the panel'); |
| 864 | |
| 865 | await backend.preview({ enabled: false }); |
| 866 | assert.ok(calls.some((c) => c.tool === 'preview_notify' && c.args?.enabled === false), 'mute hides the panel'); |
| 867 | const muted = captures(); |
| 868 | await nap(300); |
| 869 | assert.equal(captures(), muted, 'muting stops the refresh loop'); |
| 870 | |
| 871 | await backend.preview({ enabled: true }); |
| 872 | await nap(300); |
| 873 | assert.ok(captures() > muted, 're-enabling restarts the loop'); |
| 874 | |
| 875 | await backend.closeSession(); |
| 876 | assert.equal(calls.at(-1).tool, 'preview_notify'); |
| 877 | assert.equal(calls.at(-1).args.enabled, false, 'session close hides the panel it showed'); |
| 878 | const closed = captures(); |
| 879 | await nap(300); |
| 880 | assert.equal(captures(), closed, 'session close stops the loop'); |
| 881 | }); |
| 882 | |
| 883 | test('CODEWHALE_CU_PREVIEW_REFRESH_MS=0 keeps the panel a single frame', async (t) => { |
| 884 | withEnv(t, { |
| 885 | CODEWHALE_CU_APP_BUNDLE: fakeBundle(t), |
| 886 | CODEWHALE_CU_STATE_DIR: fs.mkdtempSync(path.join(os.tmpdir(), 'cu-preview-off-')), |
| 887 | CODEWHALE_CU_PREVIEW_REFRESH_MS: '0', |
| 888 | }); |
| 889 | const calls = []; |
| 890 | const backend = create({ exec: { |
| 891 | async run(cmd, args) { |
| 892 | if (cmd === 'screencapture') { fs.writeFileSync(args[args.length - 1], 'png'); return { code: 0, stderr: '', stdout: '' }; } |
| 893 | const request = JSON.parse(args[0]); |
| 894 | calls.push(request); |
| 895 | const body = request.tool === 'app_info' ? { found: true, pid: 123, bundle_id: 'test.app', name: 'TextEdit' } |
| 896 | : request.tool === 'window_info' ? { window_id: 9, name: 'TextEdit', points: { x: 0, y: 0, w: 10, h: 10 } } |
| 897 | : request.tool === 'cursor_position' ? { x: 0, y: 0 } |
| 898 | : { updated: true }; |
| 899 | return { code: 0, stderr: '', stdout: JSON.stringify(body) }; |
| 900 | }, |
| 901 | async runInputLease() { throw new Error('not used'); }, |
| 902 | } }); |
| 903 | await backend.open_application({ name: 'TextEdit' }); |
| 904 | await nap(350); |
| 905 | assert.equal(calls.filter((c) => c.tool === 'window_info').length, 1, 'exactly the bind capture, no timer'); |
| 906 | }); |
| 907 | |
| 908 | test('list_sessions in direct mode reports this process as the only session', async (t) => { |
| 909 | const { backend } = stubBackend(t, (r) => (r.tool === 'app_info' ? { found: true, pid: 321, bundle_id: 'test.app', name: 'TextEdit' } : null)); |
| 910 | assert.equal((await backend.list_sessions()).sessions[0].target, null, 'unbound direct session has no target'); |
| 911 | await backend.open_application({ name: 'TextEdit' }); |
| 912 | const s = await backend.list_sessions(); |
| 913 | assert.equal(s.via, 'direct'); |
| 914 | assert.equal(s.count, 1); |
| 915 | assert.deepEqual(s.sessions[0].target, { pid: 321, bundle_id: 'test.app', name: 'TextEdit' }); |
| 916 | assert.equal(s.sessions[0].mode, 'background'); |
| 917 | assert.equal(s.sessions[0].inputHeld, false); |
| 918 | }); |
| 919 | |
| 920 | test('list_apps {installed:true} returns the installed catalog with running flags', async (t) => { |
| 921 | const catalog = { apps: [{ name: 'Safari', bundle_id: 'com.apple.Safari', path: '/Applications/Safari.app', running: true, pid: 42 }, { name: 'Calculator', bundle_id: 'com.apple.calculator', path: '/System/Applications/Calculator.app', running: false }], count: 2 }; |
| 922 | const { backend, calls } = stubBackend(t, (r) => (r.tool === 'installed_apps' ? catalog : null)); |
| 923 | const r = await backend.list_apps({ installed: true }); |
| 924 | assert.equal(r.installed, true); |
| 925 | assert.equal(r.apps.length, 2); |
| 926 | assert.equal(r.apps[0].running, true); |
| 927 | assert.equal(r.apps[1].running, false); |
| 928 | assert.match(r.note, /takes a moment/); |
| 929 | assert.ok(calls.some((c) => c.tool === 'installed_apps')); |
| 930 | assert.ok(!calls.some((c) => c.tool === 'list_apps'), 'the running-process list is not consulted'); |
| 931 | }); |
| 932 | |
| 933 | test('set_window_frame validates geometry and the window index, then passes the readback through', async (t) => { |
| 934 | const receipt = { action_sent: true, window_id: 0, before: { x: 0, y: 0, w: 100, h: 100 }, after: { x: 40, y: 40, w: 300, h: 200 }, verified: true }; |
| 935 | const { backend, calls } = stubBackend(t, (r) => (r.tool === 'set_window_frame' ? receipt : null)); |
| 936 | await assert.rejects(backend.set_window_frame({ window_id: 0, frame: { x: 1, y: 2, w: 0, h: 5 } }), (e) => e.code === 'bad_args'); |
| 937 | await assert.rejects(backend.set_window_frame({ window_id: -1, frame: { x: 1, y: 2, w: 3, h: 5 } }), (e) => e.code === 'bad_args'); |
| 938 | await assert.rejects(backend.set_window_frame({ window_id: 0, frame: { x: 1, y: 2, w: Number.NaN, h: 5 } }), (e) => e.code === 'bad_args'); |
| 939 | const r = await backend.set_window_frame({ window_id: 0, frame: { x: 40, y: 40, w: 300, h: 200 } }); |
| 940 | assert.equal(r.verified, true); |
| 941 | assert.deepEqual(r.after, { x: 40, y: 40, w: 300, h: 200 }); |
| 942 | const sent = calls.filter((c) => c.tool === 'set_window_frame').at(-1); |
| 943 | assert.deepEqual(sent.args.frame, { x: 40, y: 40, w: 300, h: 200 }); |
| 944 | assert.equal(sent.args.window_id, 0); |
| 945 | }); |
| 946 | |
| 947 | test('kill_app validates its identity client-side and passes the native receipt through', async (t) => { |
| 948 | const receipt = { killed: true, pid: 321, name: 'TextEdit', force_used: false }; |
| 949 | const { backend, calls } = stubBackend(t, (r) => (r.tool === 'kill_app' ? receipt : null)); |
| 950 | await assert.rejects(backend.kill_app({}), (e) => e.code === 'bad_args', 'an identity is required'); |
| 951 | assert.deepEqual(await backend.kill_app({ pid: 321 }), receipt); |
| 952 | const sent = calls.filter((c) => c.tool === 'kill_app').at(-1); |
| 953 | assert.equal(sent.args.pid, 321); |
| 954 | assert.equal(sent.args.force, false, 'force defaults to a graceful quit'); |
| 955 | await backend.kill_app({ name: 'TextEdit', force: true }); |
| 956 | assert.equal(calls.filter((c) => c.tool === 'kill_app').at(-1).args.force, true, 'force passes through'); |
| 957 | }); |
| 958 | |
| 959 | // A 1x1 PNG is enough: screenshot reads its IHDR for the pixel ground truth. |
| 960 | const PNG_1X1 = Buffer.from( |
| 961 | 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', |
| 962 | 'base64', |
| 963 | ); |
| 964 | |
| 965 | // list_displays reports `index` and `id` separately (a lone main display is |
| 966 | // commonly index 1 / id 3). Passing the id where the index belongs must be a |
| 967 | // clean refusal, never a capture labelled with another display's geometry: |
| 968 | // those points/scale feed every later coordinate target. |
| 969 | function displayBackend(t, { displays }) { |
| 970 | const bundle = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-display-test-')); |
| 971 | const old = process.env.CODEWHALE_CU_APP_BUNDLE; |
| 972 | t.after(() => { if (old === undefined) delete process.env.CODEWHALE_CU_APP_BUNDLE; else process.env.CODEWHALE_CU_APP_BUNDLE = old; fs.rmSync(bundle, { recursive: true, force: true }); }); |
| 973 | fs.mkdirSync(path.join(bundle, 'Contents', 'MacOS'), { recursive: true }); |
| 974 | fs.writeFileSync(path.join(bundle, 'Contents', 'MacOS', 'accessibility'), ''); |
| 975 | process.env.CODEWHALE_CU_APP_BUNDLE = bundle; |
| 976 | const captures = []; |
| 977 | const backend = create({ exec: leaseExecutor(async (cmd, args) => { |
| 978 | if (cmd === 'screencapture') { |
| 979 | captures.push(args); |
| 980 | fs.writeFileSync(args.at(-1), PNG_1X1); |
| 981 | return { code: 0, stdout: '', stderr: '' }; |
| 982 | } |
| 983 | const request = JSON.parse(args[0]); |
| 984 | return { code: 0, stderr: '', stdout: JSON.stringify(request.tool === 'displays' ? displays : { action_sent: true }) }; |
| 985 | }) }); |
| 986 | return { backend, captures }; |
| 987 | } |
| 988 | |
| 989 | test('macOS screenshot rejects a display id used as an index instead of mislabelling the raster', async (t) => { |
| 990 | const displays = [{ index: 1, id: 3, main: true, points: { x: 0, y: 0, w: 2880, h: 1620 }, scale: 2 }]; |
| 991 | const { backend, captures } = displayBackend(t, { displays }); |
| 992 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-display-shot-')); |
| 993 | t.after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 994 | |
| 995 | await assert.rejects( |
| 996 | backend.screenshot({ display: 3, path: path.join(dir, 'a.png') }), |
| 997 | (err) => /no display 3/.test(err.message) && /index/.test(err.message), |
| 998 | ); |
| 999 | assert.equal(captures.length, 0, 'a rejected display must not reach screencapture'); |
| 1000 | |
| 1001 | const ok = await backend.screenshot({ display: 1, path: path.join(dir, 'b.png') }); |
| 1002 | assert.equal(ok.display, 1); |
| 1003 | assert.deepEqual(ok.points, displays[0].points); |
| 1004 | assert.deepEqual(captures.at(-1).slice(0, 5), ['-x', '-t', 'png', '-D', '1']); |
| 1005 | }); |
| 1006 | |
| 1007 | // A 5K display captures to ~22MB of PNG, which is ~29MB of base64 — past the |
| 1008 | // 16MB a stdio host will accept in one message. That once dropped the whole |
| 1009 | // transport and every other tool with it. The raster is shrunk to fit instead, |
| 1010 | // and the geometry must follow it: `pixels` is the PNG's, `points` stays in |
| 1011 | // screen points, and `scale` is derived from the two, so raster-to-point |
| 1012 | // conversion stays exact at the smaller size. |
| 1013 | // A real PNG of `w`x`h` random pixels — incompressible, like a busy screen. |
| 1014 | function noisePng(w, h) { |
| 1015 | const chunk = (type, body) => { |
| 1016 | const len = Buffer.alloc(4); len.writeUInt32BE(body.length); |
| 1017 | const tag = Buffer.concat([Buffer.from(type, 'ascii'), body]); |
| 1018 | const crc = Buffer.alloc(4); crc.writeUInt32BE(zlib.crc32 ? zlib.crc32(tag) : crc32(tag)); |
| 1019 | return Buffer.concat([len, tag, crc]); |
| 1020 | }; |
| 1021 | const ihdr = Buffer.alloc(13); |
| 1022 | ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); |
| 1023 | ihdr[8] = 8; ihdr[9] = 2; // 8-bit RGB |
| 1024 | const rows = Buffer.alloc(h * (1 + w * 3)); |
| 1025 | crypto.randomFillSync(rows); |
| 1026 | for (let y = 0; y < h; y += 1) rows[y * (1 + w * 3)] = 0; // filter: none |
| 1027 | return Buffer.concat([ |
| 1028 | Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), |
| 1029 | chunk('IHDR', ihdr), chunk('IDAT', zlib.deflateSync(rows)), chunk('IEND', Buffer.alloc(0)), |
| 1030 | ]); |
| 1031 | } |
| 1032 | function crc32(buf) { |
| 1033 | let c = ~0; |
| 1034 | for (const b of buf) { c ^= b; for (let k = 0; k < 8; k += 1) c = (c >>> 1) ^ (0xedb88320 & -(c & 1)); } |
| 1035 | return (~c) >>> 0; |
| 1036 | } |
| 1037 | |
| 1038 | // A 5K display captures to ~22MB of PNG, which is ~29MB of base64 — past the |
| 1039 | // 16MB a stdio host will accept in one message. That once dropped the whole |
| 1040 | // transport and every other tool with it. The raster is shrunk to fit instead, |
| 1041 | // and the geometry must follow it: `pixels` is the PNG's, `points` stays in |
| 1042 | // screen points, and `scale` is derived from the two, so raster-to-point |
| 1043 | // conversion stays exact at the smaller size. |
| 1044 | // This integration test exercises the real macOS sips resizer. |
| 1045 | test('macOS screenshot shrinks an over-budget raster and keeps its geometry exact', { skip: process.platform !== 'darwin' }, async (t) => { |
| 1046 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-budget-shot-')); |
| 1047 | t.after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 1048 | const big = path.join(dir, 'big.png'); |
| 1049 | fs.writeFileSync(big, noisePng(1600, 900)); |
| 1050 | |
| 1051 | const oldBudget = process.env.CODEWHALE_CU_MAX_IMAGE_BYTES; |
| 1052 | process.env.CODEWHALE_CU_MAX_IMAGE_BYTES = '1500000'; |
| 1053 | t.after(() => { if (oldBudget === undefined) delete process.env.CODEWHALE_CU_MAX_IMAGE_BYTES; else process.env.CODEWHALE_CU_MAX_IMAGE_BYTES = oldBudget; }); |
| 1054 | assert.ok(Math.ceil(fs.statSync(big).size / 3) * 4 > 1_500_000, 'fixture must start over budget'); |
| 1055 | |
| 1056 | const displays = [{ index: 1, id: 3, main: true, points: { x: 0, y: 0, w: 800, h: 450 }, scale: 2 }]; |
| 1057 | const bundle = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-budget-bundle-')); |
| 1058 | const oldBundle = process.env.CODEWHALE_CU_APP_BUNDLE; |
| 1059 | t.after(() => { if (oldBundle === undefined) delete process.env.CODEWHALE_CU_APP_BUNDLE; else process.env.CODEWHALE_CU_APP_BUNDLE = oldBundle; fs.rmSync(bundle, { recursive: true, force: true }); }); |
| 1060 | fs.mkdirSync(path.join(bundle, 'Contents', 'MacOS'), { recursive: true }); |
| 1061 | fs.writeFileSync(path.join(bundle, 'Contents', 'MacOS', 'accessibility'), ''); |
| 1062 | process.env.CODEWHALE_CU_APP_BUNDLE = bundle; |
| 1063 | |
| 1064 | const backend = create({ exec: leaseExecutor(async (cmd, args) => { |
| 1065 | if (cmd === 'screencapture') { |
| 1066 | fs.copyFileSync(big, args.at(-1)); |
| 1067 | return { code: 0, stdout: '', stderr: '' }; |
| 1068 | } |
| 1069 | const request = JSON.parse(args[0]); |
| 1070 | return { code: 0, stderr: '', stdout: JSON.stringify(request.tool === 'displays' ? displays : { action_sent: true }) }; |
| 1071 | }) }); |
| 1072 | |
| 1073 | const out = path.join(dir, 'shot.png'); |
| 1074 | const shot = await backend.screenshot({ display: 1, path: out }); |
| 1075 | |
| 1076 | assert.ok(Math.ceil(fs.statSync(out).size / 3) * 4 <= 1_500_000, 'raster still over budget'); |
| 1077 | assert.ok(shot.pixels.w < 1600, 'an over-budget raster must actually shrink'); |
| 1078 | |
| 1079 | // The invariant that matters: the raster centre still resolves to the centre |
| 1080 | // of the display in screen points. |
| 1081 | assert.equal(shot.points.w, 800, 'points stay in screen points'); |
| 1082 | const centreX = Math.round((shot.pixels.w / 2) / shot.scale) + (shot.points.x ?? 0); |
| 1083 | assert.ok(Math.abs(centreX - 400) <= 2, `raster centre maps to ${centreX}, expected ~400`); |
| 1084 | }); |
| 1085 | |
| 1086 | // A screen is photographic content, and lossless compression of it is enormous: |
| 1087 | // the same 5760x3240 frame measures 21.8MB as PNG and 2.1MB as JPEG at full |
| 1088 | // resolution. The PNG default is what made a single screenshot exceed the |
| 1089 | // 16MB a stdio host accepts in one message and drop the whole session. |
| 1090 | test('macOS screenshot captures JPEG by default and honours an explicit .png path', async (t) => { |
| 1091 | const displays = [{ index: 1, id: 3, main: true, points: { x: 0, y: 0, w: 2880, h: 1620 }, scale: 2 }]; |
| 1092 | const { backend, captures } = displayBackend(t, { displays }); |
| 1093 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-format-')); |
| 1094 | t.after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 1095 | |
| 1096 | const dflt = await backend.screenshot({ display: 1 }); |
| 1097 | assert.deepEqual(captures.at(-1).slice(0, 3), ['-x', '-t', 'jpg'], 'the default capture is JPEG'); |
| 1098 | assert.match(dflt.path, /\.jpg$/); |
| 1099 | |
| 1100 | await backend.screenshot({ display: 1, path: path.join(dir, 'exact.png') }); |
| 1101 | assert.deepEqual(captures.at(-1).slice(0, 3), ['-x', '-t', 'png'], 'an explicit .png path stays lossless'); |
| 1102 | |
| 1103 | await backend.screenshot({ display: 1, path: path.join(dir, 'shot.jpeg') }); |
| 1104 | assert.deepEqual(captures.at(-1).slice(0, 3), ['-x', '-t', 'jpg']); |
| 1105 | |
| 1106 | await assert.rejects( |
| 1107 | backend.screenshot({ display: 1, path: path.join(dir, 'shot.gif') }), |
| 1108 | /must end in \.png, \.jpg or \.jpeg/, |
| 1109 | ); |
| 1110 | }); |
| 1111 | |
| 1112 | // Dimensions come from the raster's own header. A JPEG carries them in a frame |
| 1113 | // marker rather than at a fixed offset, and reading the wrong bytes would |
| 1114 | // mis-scale every coordinate target derived from the capture. |
| 1115 | test('macOS raster dimensions are read from both PNG and JPEG headers', async (t) => { |
| 1116 | const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-dims-')); |
| 1117 | t.after(() => fs.rmSync(dir, { recursive: true, force: true })); |
| 1118 | const png = path.join(dir, 'a.png'); |
| 1119 | fs.writeFileSync(png, noisePng(321, 123)); |
| 1120 | const jpg = path.join(dir, 'a.jpg'); |
| 1121 | spawnSync('sips', ['-s', 'format', 'jpeg', png, '--out', jpg], { stdio: 'ignore' }); |
| 1122 | if (!fs.existsSync(jpg)) { t.skip('sips unavailable'); return; } |
| 1123 | |
| 1124 | const displays = [{ index: 1, id: 3, main: true, points: { x: 0, y: 0, w: 321, h: 123 }, scale: 1 }]; |
| 1125 | for (const [fixture, name] of [[png, 'shot.png'], [jpg, 'shot.jpg']]) { |
| 1126 | const bundle = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-dims-bundle-')); |
| 1127 | const old = process.env.CODEWHALE_CU_APP_BUNDLE; |
| 1128 | fs.mkdirSync(path.join(bundle, 'Contents', 'MacOS'), { recursive: true }); |
| 1129 | fs.writeFileSync(path.join(bundle, 'Contents', 'MacOS', 'accessibility'), ''); |
| 1130 | process.env.CODEWHALE_CU_APP_BUNDLE = bundle; |
| 1131 | const backend = create({ exec: leaseExecutor(async (cmd, args) => { |
| 1132 | if (cmd === 'screencapture') { fs.copyFileSync(fixture, args.at(-1)); return { code: 0, stdout: '', stderr: '' }; } |
| 1133 | const request = JSON.parse(args[0]); |
| 1134 | return { code: 0, stderr: '', stdout: JSON.stringify(request.tool === 'displays' ? displays : { action_sent: true }) }; |
| 1135 | }) }); |
| 1136 | const shot = await backend.screenshot({ display: 1, path: path.join(dir, name) }); |
| 1137 | assert.deepEqual(shot.pixels, { w: 321, h: 123 }, `${name} dimensions`); |
| 1138 | if (old === undefined) delete process.env.CODEWHALE_CU_APP_BUNDLE; else process.env.CODEWHALE_CU_APP_BUNDLE = old; |
| 1139 | fs.rmSync(bundle, { recursive: true, force: true }); |
| 1140 | } |
| 1141 | }); |
| 1142 |