| 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 |