返回 CodeWhale
darwin.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / darwin.test.mjs
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
1142 lines Plain Text