返回 CodeWhale
darwin-recording.test.mjs
根目录 / crates / tui / plugins / computer-use / tests / darwin-recording.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 { create } from '../src/backends/darwin.mjs';
8 import { withSignal } from '../src/exec.mjs';
9
10 function fixture(t) {
11 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-recording-owner-'));
12 const oldBundle = process.env.CODEWHALE_CU_APP_BUNDLE;
13 const oldDir = process.env.CODEWHALE_CU_RECORDINGS_DIR;
14 t.after(() => {
15 if (oldBundle === undefined) delete process.env.CODEWHALE_CU_APP_BUNDLE; else process.env.CODEWHALE_CU_APP_BUNDLE = oldBundle;
16 if (oldDir === undefined) delete process.env.CODEWHALE_CU_RECORDINGS_DIR; else process.env.CODEWHALE_CU_RECORDINGS_DIR = oldDir;
17 fs.rmSync(root, { recursive: true, force: true });
18 });
19 const helper = path.join(root, 'Contents', 'MacOS', 'accessibility');
20 fs.mkdirSync(path.dirname(helper), { recursive: true });
21 fs.writeFileSync(helper, `#!${process.execPath}\n` + `
22 import fs from 'node:fs';
23 import path from 'node:path';
24 const {args} = JSON.parse(process.argv[2]);
25 const stop = () => { if(args.durationSec === 8) return; fs.writeFileSync(args.file, 'finalized'); process.exit(0); };
26 process.on('SIGINT', stop); process.stdin.resume(); process.stdin.on('end', stop);
27 fs.writeFileSync(path.join(path.dirname(args.file), '..', 'handler-installed'), '');
28 fs.writeFileSync(path.join(path.dirname(args.file), '..', 'last-request.json'), process.argv[2]);
29 fs.writeFileSync(args.file, 'partial');
30 if(args.durationSec !== 7) console.log(JSON.stringify({ready:true}));
31 setInterval(()=>{},1000);
32 `);
33 // No extension: force ESM through package metadata for the fixture launcher.
34 fs.writeFileSync(path.join(root, 'package.json'), '{"type":"module"}');
35 fs.chmodSync(helper, 0o700);
36 process.env.CODEWHALE_CU_APP_BUNDLE = root;
37 process.env.CODEWHALE_CU_RECORDINGS_DIR = path.join(root, 'recordings');
38 const make = (ownerPipe = 1) => create({ exec: { async run(_cmd, args) {
39 const tool = JSON.parse(args[0]).tool;
40 if (tool === 'input_capabilities') return {code:0,stdout:JSON.stringify({record_owner_pipe:ownerPipe}),stderr:''};
41 assert.equal(tool, 'displays');
42 return { code: 0, stdout: JSON.stringify([{index:1,id:1}]), stderr: '' };
43 } } });
44 return { root, make };
45 }
46
47 test('an old recorder helper is refused before any recording process starts', {skip:process.platform==='win32'}, async t => {
48 const { make, root } = fixture(t);
49 await assert.rejects(make(0).recordingStart(), /update Computer Use before recording/);
50 assert.deepEqual(fs.readdirSync(path.join(root, 'recordings')), []);
51 });
52
53 test('closing a session stops only its recorders and retains finalized files', {skip:process.platform==='win32'}, async t => {
54 const { make } = fixture(t);
55 const owner = make(), other = make();
56 const first = await owner.recordingStart();
57 const second = await other.recordingStart();
58 t.after(() => Promise.allSettled([owner.closeSession(), other.closeSession()]));
59 await owner.releaseInput();
60 assert.equal((await owner.recordingStatus({id:first.id})).running, true, 'input cancellation does not stop recording');
61 await owner.closeSession();
62 assert.equal(fs.readFileSync(first.file, 'utf8'), 'finalized');
63 assert.equal((await other.recordingStatus({id:second.id})).running, true);
64 await other.closeSession();
65 assert.equal(fs.readFileSync(second.file, 'utf8'), 'finalized');
66 });
67
68 test('recording startup cancellation owns and stops the pending child', {skip:process.platform==='win32'}, async t => {
69 const { make, root } = fixture(t);
70 const owner = make(), controller = new AbortController();
71 const started = withSignal(controller.signal, () => owner.recordingStart({durationSec:7}));
72 const rejected = assert.rejects(started, error => error.code === 'cancelled');
73 const dir = path.join(root, 'recordings');
74 const handlersReady = path.join(root, 'handler-installed');
75 const deadline = Date.now() + 2000;
76 while ((!fs.existsSync(handlersReady) || !fs.existsSync(dir) || !fs.readdirSync(dir).length) && Date.now() < deadline) await new Promise(resolve => setTimeout(resolve, 10));
77 assert.ok(fs.existsSync(handlersReady), 'fake helper installed cancellation handlers');
78 controller.abort();
79 await rejected;
80 assert.equal((await owner.recordingList()).running.length, 0);
81 const files = fs.readdirSync(dir);
82 assert.equal(files.length, 1);
83 assert.equal(fs.readFileSync(path.join(dir, files[0]), 'utf8'), 'finalized');
84 });
85
86 test('session close bounds a stubborn recorder and retains its partial file', {skip:process.platform==='win32'}, async t => {
87 const { make } = fixture(t);
88 const owner = make();
89 const recording = await owner.recordingStart({durationSec:8});
90 const started = Date.now();
91 await assert.rejects(owner.closeSession(), /partial file retained/);
92 assert.ok(Date.now() - started < 3000);
93 let alive = true;
94 const deadline = Date.now() + 500;
95 while (alive && Date.now() < deadline) {
96 try { process.kill(recording.pid, 0); } catch (error) { assert.equal(error.code, 'ESRCH'); alive = false; }
97 if (alive) await new Promise(resolve => setTimeout(resolve, 10));
98 }
99 assert.equal(alive, false, 'the stubborn capture process was terminated');
100 assert.equal(fs.readFileSync(recording.file, 'utf8'), 'partial');
101 assert.equal((await owner.recordingList()).running.length, 0);
102 });
103
104 test('app_ref recording crops to the window rect on its hosting display', {skip:process.platform==='win32'}, async t => {
105 const { root } = fixture(t);
106 const requests = [];
107 const b = create({ exec: { async run(_cmd, args) {
108 const req = JSON.parse(args[0]);
109 requests.push(req.tool);
110 if (req.tool === 'input_capabilities') return {code:0,stdout:JSON.stringify({record_owner_pipe:1}),stderr:''};
111 if (req.tool === 'window_info') return {code:0,stdout:JSON.stringify({window_id:77,name:'FakeWin',points:{x:100,y:50,w:800,h:600}}),stderr:''};
112 assert.equal(req.tool, 'displays');
113 return { code: 0, stdout: JSON.stringify([{index:1,id:1,points:{x:0,y:0,w:2880,h:1620}},{index:2,id:2,points:{x:2880,y:0,w:1920,h:1080}}]), stderr: '' };
114 } } });
115 const rec = await b.recordingStart({ app_ref: { name: 'FakeApp' } });
116 t.after(() => b.closeSession().catch(() => {}));
117 assert.equal(rec.window.name, 'FakeWin');
118 assert.equal(rec.display, 1, 'window center selects the hosting display');
119 const sent = JSON.parse(fs.readFileSync(path.join(root, 'last-request.json'), 'utf8'));
120 assert.equal(sent.tool, 'record');
121 assert.deepEqual(sent.args.region, [100, 50, 800, 600]);
122 assert.equal(sent.args.displayID, 1);
123 await assert.rejects(b.recordingStart({ app_ref: { name: 'FakeApp' }, region: [0, 0, 10, 10] }), /not both/);
124 });
125
126 test('recording_list returns jpeg screenshots alongside recordings', {skip:process.platform==='win32'}, async t => {
127 const { make, root } = fixture(t);
128 const dir = path.join(root, 'recordings');
129 fs.mkdirSync(dir, { recursive: true });
130 fs.writeFileSync(path.join(dir, 'shot-a.jpg'), 'x');
131 fs.writeFileSync(path.join(dir, 'shot-b.png'), 'x');
132 fs.writeFileSync(path.join(dir, 'notes.txt'), 'x');
133 const list = await make().recordingList();
134 assert.deepEqual(list.recordings.map((r) => path.basename(r.file)).sort(), ['shot-a.jpg', 'shot-b.png']);
135 });
136
137 test('native recording startup notices owner pipe EOF without capturing a screen', {skip:process.platform!=='darwin'}, async t => {
138 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cu-recording-pipe-'));
139 t.after(() => fs.rmSync(root, {recursive:true, force:true}));
140 const source = path.join(root, 'probe.m'), binary = path.join(root, 'probe');
141 const header = path.resolve('src/backends/darwin-recording.h');
142 fs.writeFileSync(source, `#import <Cocoa/Cocoa.h>\n#import <ApplicationServices/ApplicationServices.h>\n#import ${JSON.stringify(header)}\nint main(){@autoreleasepool{cuRecordingOwnerPipe=YES;puts("ready");fflush(stdout);@try{cuWait(dispatch_semaphore_create(0),15,YES);return 2;}@catch(NSException *e){return [e.name isEqual:@"cancelled"]?0:3;}}}`);
143 const build = spawnSync('clang', ['-fobjc-arc','-Os','-framework','Cocoa','-framework','ApplicationServices','-framework','ScreenCaptureKit','-framework','AVFoundation','-framework','CoreMedia',source,'-o',binary], {encoding:'utf8'});
144 assert.equal(build.status, 0, build.stderr);
145 const child = spawn(binary, [], {stdio:['pipe','pipe','pipe']});
146 t.after(() => {if(child.exitCode===null)child.kill('SIGKILL');});
147 await new Promise(resolve => child.stdout.once('data', resolve));
148 const exited = new Promise(resolve => child.once('exit', resolve));
149 child.stdin.end();
150 assert.equal(await exited, 0);
151 });
152
152 lines Plain Text