@celljs/ta-computer-use 0.0.0-stage → 4.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "@celljs/ta-computer-use",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "4.8.0",
4
+ "description": "A pi package for Ta: the desktop apps of this computer, used in the background through Cua Driver, which starts only when the agent first needs it",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "homepage": "https://github.com/cellbang/cell",
9
+ "bugs": {
10
+ "url": "https://github.com/cellbang/cell/issues"
11
+ },
12
+ "repository": {
13
+ "type": "git",
14
+ "url": "https://github.com/cellbang/cell.git"
15
+ },
16
+ "license": "MIT",
17
+ "files": [
18
+ "esm",
19
+ "extension",
20
+ "src"
21
+ ],
22
+ "scripts": {
23
+ "build": "tsc -b && bun build src/extension.ts --format esm --target node --outfile extension/index.mjs",
24
+ "clean": "rm -rf lib extension *.tsbuildinfo",
25
+ "test": "bun test ./lib",
26
+ "watch": "tsc -b -w --preserveWatchOutput"
27
+ },
28
+ "devDependencies": {
29
+ "@earendil-works/pi-coding-agent": "^1.5.0"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "pi": {
35
+ "extensions": [
36
+ "./extension/index.mjs"
37
+ ]
38
+ },
39
+ "exports": {
40
+ "./package.json": "./package.json",
41
+ "./lib/*.js": {
42
+ "import": "./esm/*.js",
43
+ "default": "./lib/*.js"
44
+ },
45
+ "./lib/*": {
46
+ "import": "./esm/*.js",
47
+ "default": "./lib/*.js"
48
+ },
49
+ "./*": "./*"
50
+ }
51
+ }
@@ -0,0 +1,147 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { askMac, CuaDriverAccess, type CuaDriverRun, NO_ACCESSIBILITY, NO_SCREEN_RECORDING } from './cua-driver-access';
3
+
4
+ const permissions = (accessibility: boolean, screenRecording: boolean): CuaDriverRun => ({
5
+ code: 0,
6
+ stdout: JSON.stringify({ accessibility, screen_recording: screenRecording, source: { attribution: 'driver-daemon' } }),
7
+ stderr: '',
8
+ });
9
+ const PENDING: CuaDriverRun = { code: 1, stdout: '', stderr: 'permissions_pending: macOS Accessibility or Screen Recording permission is still pending' };
10
+
11
+ /** A Mac whose daemon answers `answers` in turn, the last one again. */
12
+ function mac(answers: CuaDriverRun[], waitMs = 1000, graceMs = 0) {
13
+ const calls: string[][] = [];
14
+ const opened: string[] = [];
15
+ const notes: string[] = [];
16
+ let looks = 0;
17
+ const access = new CuaDriverAccess('/A/cua-driver', {
18
+ platform: 'darwin',
19
+ socket: '/c/s.sock',
20
+ env: { X: '1' },
21
+ pollMs: 1,
22
+ waitMs,
23
+ graceMs,
24
+ run: async (_program, args, _timeout, env) => {
25
+ calls.push([...args, String(env?.X)]);
26
+ return answers[Math.min(calls.length - 1, answers.length - 1)];
27
+ },
28
+ openSettings: pane => opened.push(pane),
29
+ beforeLook: async () => {
30
+ looks++;
31
+ },
32
+ });
33
+ return { access, calls, opened, notes, notify: (text: string) => notes.push(text), looks: () => looks };
34
+ }
35
+
36
+ describe('askMac', () => {
37
+ it("speaks the language of Ta's pages", () => {
38
+ expect(askMac({ TA_LANGUAGE: 'zh-CN', LANG: 'en_US.UTF-8' })).toContain('辅助功能');
39
+ expect(askMac({ LANG: 'en_US.UTF-8' })).toContain('Accessibility');
40
+ });
41
+ });
42
+
43
+ describe('CuaDriverAccess on macOS', () => {
44
+ it('goes on at once when both permissions are on', async () => {
45
+ const m = mac([permissions(true, true)]);
46
+ expect(await m.access.check(m.notify)).toEqual({ ready: true, notes: [], known: true });
47
+ expect(m.calls).toEqual([['call', 'check_permissions', '{"prompt":false}', '--socket', '/c/s.sock', '1']]);
48
+ expect(m.notes).toEqual([]);
49
+ });
50
+
51
+ it("while the driver's guide is open, tells the user and waits until both are on", async () => {
52
+ // The daemon answers nothing but permissions_pending while its guide is open, at either step.
53
+ const m = mac([PENDING, PENDING, PENDING, permissions(true, true)]);
54
+ expect(await m.access.check(m.notify)).toEqual({ ready: true, notes: [], known: true });
55
+ expect(m.notes).toEqual([askMac()]);
56
+ expect(m.opened).toEqual([]);
57
+ expect(m.looks()).toBe(3);
58
+ });
59
+
60
+ it('says nothing while a daemon that just started settles', async () => {
61
+ const m = mac([PENDING, PENDING, permissions(true, true)], 1000, 1000);
62
+ expect(await m.access.check(m.notify)).toEqual({ ready: true, notes: [], known: true });
63
+ expect(m.notes).toEqual([]);
64
+ });
65
+
66
+ it('opens System Settings when the guide was closed, and goes on without Screen Recording', async () => {
67
+ const m = mac([permissions(false, false), permissions(true, false)]);
68
+ expect(await m.access.check(m.notify)).toEqual({ ready: true, notes: [NO_SCREEN_RECORDING], known: true });
69
+ expect(m.opened).toEqual(['Privacy_Accessibility']);
70
+ const s = mac([permissions(true, false)]);
71
+ expect(await s.access.check(s.notify)).toEqual({ ready: true, notes: [NO_SCREEN_RECORDING], known: true });
72
+ expect(s.opened).toEqual(['Privacy_ScreenCapture']);
73
+ });
74
+
75
+ it('stops a call without Accessibility after the wait, or when stopped', async () => {
76
+ const m = mac([PENDING], 20);
77
+ expect(await m.access.check(m.notify)).toEqual({ ready: false, reason: NO_ACCESSIBILITY });
78
+ const stop = new AbortController();
79
+ const s = mac([PENDING], 60_000);
80
+ const started = Date.now();
81
+ setTimeout(() => stop.abort(), 20);
82
+ expect(await s.access.check(s.notify, stop.signal)).toMatchObject({ ready: false });
83
+ expect(Date.now() - started).toBeLessThan(1000);
84
+ });
85
+
86
+ it('does not stop Ta when the daemon cannot tell', async () => {
87
+ const m = mac([{ code: 1, stdout: '', stderr: 'no Cua Driver daemon listening' }]);
88
+ expect(await m.access.check(m.notify)).toEqual({ ready: true, notes: [], known: false });
89
+ });
90
+ });
91
+
92
+ describe('CuaDriverAccess on Linux and Windows', () => {
93
+ const doctor = (probes: object[]): CuaDriverRun => ({ code: 0, stdout: JSON.stringify({ ok: true, probes }), stderr: '' });
94
+ const check = (result: CuaDriverRun, platform: NodeJS.Platform = 'linux') =>
95
+ new CuaDriverAccess('/c/cua-driver', {
96
+ platform,
97
+ run: async (_p, args) => (args[0] === 'doctor' ? result : { code: 1, stdout: '', stderr: '' }),
98
+ }).check(() => undefined);
99
+
100
+ it('goes on when the desktop is reachable, with its warnings for the user', async () => {
101
+ expect(
102
+ await check(
103
+ doctor([
104
+ { label: 'binary', status: 'ok' },
105
+ { label: 'telemetry', status: 'warn', message: 'on' },
106
+ ]),
107
+ ),
108
+ ).toEqual({ ready: true, notes: [], known: true });
109
+ expect(
110
+ await check(
111
+ doctor([
112
+ {
113
+ label: 'AT-SPI',
114
+ status: 'warn',
115
+ message: 'accessibility bus not reachable',
116
+ detail: 'install at-spi2-core and ensure the user session has D-Bus running',
117
+ },
118
+ ]),
119
+ ),
120
+ ).toEqual({
121
+ ready: true,
122
+ notes: ['Cua Driver: AT-SPI: accessibility bus not reachable — install at-spi2-core and ensure the user session has D-Bus running'],
123
+ known: true,
124
+ });
125
+ });
126
+
127
+ it('stops a call without a desktop session or when a probe fails', async () => {
128
+ const noDisplay = await check(doctor([{ label: 'display server', status: 'warn', message: 'neither DISPLAY nor WAYLAND_DISPLAY set' }]));
129
+ expect(noDisplay).toEqual({ ready: false, reason: 'Cua Driver cannot use the desktop: display server: neither DISPLAY nor WAYLAND_DISPLAY set' });
130
+ expect(await check(doctor([{ label: 'session', status: 'error', message: 'session 0' }]), 'win32')).toMatchObject({ ready: false });
131
+ });
132
+
133
+ it('says which library is missing, and how to install them on Debian or Ubuntu', async () => {
134
+ const result = await check({
135
+ code: 127,
136
+ stdout: '',
137
+ stderr: './cua-driver: error while loading shared libraries: libX11.so.6: cannot open shared object file',
138
+ });
139
+ expect(result).toMatchObject({ ready: false });
140
+ expect((result as { reason: string }).reason).toContain('libX11.so.6');
141
+ expect((result as { reason: string }).reason).toContain('sudo apt install');
142
+ });
143
+
144
+ it('does not stop Ta when the report cannot be read', async () => {
145
+ expect(await check({ code: -1, stdout: '', stderr: '' })).toEqual({ ready: true, notes: [], known: true });
146
+ });
147
+ });
@@ -0,0 +1,188 @@
1
+ import { execFile } from 'node:child_process';
2
+
3
+ /** What a run of the program gave; `code` is -1 when it could not run or took too long. */
4
+ export interface CuaDriverRun {
5
+ code: number;
6
+ stdout: string;
7
+ stderr: string;
8
+ }
9
+
10
+ export type CuaDriverRunner = (program: string, args: string[], timeoutMs: number, env?: Readonly<Record<string, string>>) => Promise<CuaDriverRun>;
11
+
12
+ /**
13
+ * Whether Ta may go on with a call that uses Cua Driver; `notes` are for the user, once. `known` is false
14
+ * when the system could not tell (the daemon of the user's own install is not running yet): ask again next time.
15
+ */
16
+ export type CuaDriverReadiness = { ready: true; notes: string[]; known: boolean } | { ready: false; reason: string };
17
+
18
+ /** The two permissions of macOS, as `check_permissions` answers; `pending` while its guide is still open. */
19
+ export interface MacPermissions {
20
+ accessibility: boolean;
21
+ screenRecording: boolean;
22
+ pending: boolean;
23
+ }
24
+
25
+ export interface CuaDriverAccessOptions {
26
+ platform: NodeJS.Platform;
27
+ /** The socket of the daemon to ask, on macOS; without one, the program's default daemon. */
28
+ socket?: string;
29
+ /** Added to the environment of each run. */
30
+ env?: Readonly<Record<string, string>>;
31
+ run?: CuaDriverRunner;
32
+ /** Opens a pane of System Settings, on macOS. */
33
+ openSettings?: (pane: 'Privacy_Accessibility' | 'Privacy_ScreenCapture') => void;
34
+ /** Before each look while waiting, on macOS: the daemon of Ta Desktop restarts if it quit for a permission. */
35
+ beforeLook?: () => Promise<void>;
36
+ pollMs?: number;
37
+ waitMs?: number;
38
+ graceMs?: number;
39
+ }
40
+
41
+ const CHECK_TIMEOUT_MS = 5000;
42
+ const DOCTOR_TIMEOUT_MS = 15000;
43
+ const POLL_MS = 1000;
44
+ /** A daemon that just started answers permissions_pending for a few hundred ms even when it has both. */
45
+ const GRACE_MS = 3000;
46
+ const GRACE_POLL_MS = 100;
47
+ /** How long a call waits for the user to give the permissions of macOS. */
48
+ const WAIT_MS = 3 * 60 * 1000;
49
+ /** The probes of `cua-driver doctor` about reaching the desktop, which the user can fix. */
50
+ const DESKTOP_PROBES = /display|x11|wayland|at-spi|accessib|session|desktop|automation|uia/i;
51
+ const MISSING_LIBRARY = /error while loading shared libraries: (\S+?):/;
52
+
53
+ /** What the user reads while Ta waits for the permissions: in the language of Ta's pages (`TA_LANGUAGE`) or the computer's. */
54
+ export function askMac(env: NodeJS.ProcessEnv = process.env): string {
55
+ return /^zh/i.test(env.TA_LANGUAGE || env.LC_ALL || env.LANG || '')
56
+ ? 'Ta 正在请 macOS 允许 Cua Driver 使用这台 Mac:请在 Cua Driver 打开的窗口或“系统设置”里,打开“辅助功能”(用于点击和输入)和“屏幕录制”(用于查看窗口)中的 Cua Driver。开好后 Ta 会自动继续;点“停止”可放弃。'
57
+ : 'Ta is asking macOS to let Cua Driver use this Mac: turn Cua Driver on under Accessibility (to click and type) and Screen Recording (to see windows), in the window Cua Driver opened or in System Settings. Ta goes on by itself once you have; Stop gives up.';
58
+ }
59
+ export const NO_ACCESSIBILITY =
60
+ 'Cua Driver has no Accessibility permission of macOS, which it needs to read and use apps: ask the user to turn Cua Driver on in System Settings → Privacy & Security → Accessibility (`/computer-use` in Ta opens it), then try again.';
61
+ export const NO_SCREEN_RECORDING =
62
+ 'Cua Driver has no Screen Recording permission of macOS: it reads and uses apps, but takes no screenshots. Turn it on in System Settings → Privacy & Security → Screen & System Audio Recording.';
63
+
64
+ /**
65
+ * Checks what the system needs before Ta first uses Cua Driver, and asks the user for it, so that a
66
+ * missing permission is something the user is told how to fix and not a failed call.
67
+ * - macOS: the Accessibility and Screen Recording permissions of CuaDriver.app. A daemon without them
68
+ * opens its own guide; Ta tells the user, waits while they turn the switches on, and goes on.
69
+ * - Linux and Windows: no permission, but a desktop session; Linux also needs the accessibility bus and
70
+ * some X11 libraries. `cua-driver doctor` says what is missing.
71
+ */
72
+ export class CuaDriverAccess {
73
+ protected readonly run: CuaDriverRunner;
74
+
75
+ constructor(
76
+ readonly program: string,
77
+ protected readonly options: CuaDriverAccessOptions,
78
+ ) {
79
+ this.run = options.run ?? runProgram;
80
+ }
81
+
82
+ check(notify: (message: string) => void, signal?: AbortSignal): Promise<CuaDriverReadiness> {
83
+ return this.options.platform === 'darwin' ? this.checkMac(notify, signal) : this.checkDesktop();
84
+ }
85
+
86
+ /** What the daemon says; undefined when it cannot tell, which does not stop Ta. */
87
+ async permissions(): Promise<MacPermissions | undefined> {
88
+ const socket = this.options.socket ? ['--socket', this.options.socket] : [];
89
+ const result = await this.run(this.program, ['call', 'check_permissions', '{"prompt":false}', ...socket], CHECK_TIMEOUT_MS, this.options.env);
90
+ return CuaDriverAccess.parsePermissions(result);
91
+ }
92
+
93
+ protected async checkMac(notify: (message: string) => void, signal?: AbortSignal): Promise<CuaDriverReadiness> {
94
+ let state = await this.permissions();
95
+ const grace = Date.now() + (this.options.graceMs ?? GRACE_MS);
96
+ while (state?.pending && Date.now() < grace && !signal?.aborted) {
97
+ await sleep(GRACE_POLL_MS, signal);
98
+ state = await this.permissions();
99
+ }
100
+ if (!state || (state.accessibility && state.screenRecording)) {
101
+ return { ready: true, notes: [], known: state !== undefined };
102
+ }
103
+ notify(askMac());
104
+ if (!state.pending) {
105
+ // The guide was closed before: System Settings, where Cua Driver is listed since it asked once.
106
+ this.options.openSettings?.(state.accessibility ? 'Privacy_ScreenCapture' : 'Privacy_Accessibility');
107
+ }
108
+ const deadline = Date.now() + (this.options.waitMs ?? WAIT_MS);
109
+ // Until both are on, or the guide is closed with Accessibility on: Screen Recording is optional.
110
+ while (!signal?.aborted && Date.now() < deadline && !(state?.accessibility && (state.screenRecording || !state.pending))) {
111
+ await sleep(this.options.pollMs ?? POLL_MS, signal);
112
+ await this.options.beforeLook?.();
113
+ state = (await this.permissions()) ?? state;
114
+ }
115
+ if (state?.accessibility) {
116
+ return { ready: true, notes: state.screenRecording ? [] : [NO_SCREEN_RECORDING], known: true };
117
+ }
118
+ return { ready: false, reason: signal?.aborted ? 'Stopped while waiting for the permissions of macOS.' : NO_ACCESSIBILITY };
119
+ }
120
+
121
+ protected async checkDesktop(): Promise<CuaDriverReadiness> {
122
+ const result = await this.run(this.program, ['doctor', '--json'], DOCTOR_TIMEOUT_MS, this.options.env);
123
+ return CuaDriverAccess.parseDoctor(result, this.options.platform);
124
+ }
125
+ }
126
+
127
+ export namespace CuaDriverAccess {
128
+ export function parsePermissions(result: CuaDriverRun): MacPermissions | undefined {
129
+ if (`${result.stdout}${result.stderr}`.includes('permissions_pending')) {
130
+ return { accessibility: false, screenRecording: false, pending: true };
131
+ }
132
+ try {
133
+ const answer = JSON.parse(result.stdout) as { accessibility?: unknown; screen_recording?: unknown };
134
+ if (typeof answer.accessibility !== 'boolean') {
135
+ return undefined;
136
+ }
137
+ return { accessibility: answer.accessibility, screenRecording: answer.screen_recording === true, pending: false };
138
+ } catch {
139
+ return undefined;
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Not ready when the program cannot start (a library is missing), when a probe fails, or when there is
145
+ * no desktop session; the other warnings about the desktop are notes. A report it cannot read does not stop Ta.
146
+ */
147
+ export function parseDoctor(result: CuaDriverRun, platform: NodeJS.Platform): CuaDriverReadiness {
148
+ const library = MISSING_LIBRARY.exec(result.stderr)?.[1];
149
+ if (library) {
150
+ const install = platform === 'linux' ? ' On Debian or Ubuntu: `sudo apt install libx11-6 libxi6 libxkbcommon0 at-spi2-core`.' : '';
151
+ return { ready: false, reason: `Cua Driver cannot start: the system has no ${library}.${install}` };
152
+ }
153
+ let probes: { label?: string; status?: string; message?: string; detail?: string }[];
154
+ try {
155
+ probes = (JSON.parse(result.stdout) as { probes?: typeof probes }).probes ?? [];
156
+ } catch {
157
+ return { ready: true, notes: [], known: true };
158
+ }
159
+ const text = (probe: (typeof probes)[number]) => [`${probe.label}: ${probe.message}`, probe.detail].filter(Boolean).join(' — ');
160
+ const failed = probes.filter(probe => probe.status === 'error' || (probe.status === 'warn' && /display server/i.test(probe.label ?? '')));
161
+ if (failed.length > 0) {
162
+ return { ready: false, reason: `Cua Driver cannot use the desktop: ${failed.map(text).join('; ')}` };
163
+ }
164
+ const warned = probes.filter(probe => probe.status === 'warn' && DESKTOP_PROBES.test(probe.label ?? ''));
165
+ return { ready: true, notes: warned.map(probe => `Cua Driver: ${text(probe)}`), known: true };
166
+ }
167
+ }
168
+
169
+ function runProgram(program: string, args: string[], timeoutMs: number, env?: Readonly<Record<string, string>>): Promise<CuaDriverRun> {
170
+ return new Promise(resolve => {
171
+ execFile(program, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, env: { ...process.env, ...env } }, (error, stdout, stderr) => {
172
+ const code = error ? (typeof error.code === 'number' ? error.code : -1) : 0;
173
+ resolve({ code, stdout: String(stdout), stderr: String(stderr) });
174
+ });
175
+ });
176
+ }
177
+
178
+ function sleep(ms: number, signal?: AbortSignal): Promise<void> {
179
+ return new Promise(resolve => {
180
+ const timer = setTimeout(done, ms);
181
+ signal?.addEventListener('abort', done, { once: true });
182
+ function done() {
183
+ clearTimeout(timer);
184
+ signal?.removeEventListener('abort', done);
185
+ resolve();
186
+ }
187
+ });
188
+ }
@@ -0,0 +1,168 @@
1
+ import { afterEach, describe, expect, it } from 'bun:test';
2
+ import { type ChildProcess, spawn } from 'node:child_process';
3
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { CuaDriverDaemon } from './cua-driver-daemon';
7
+
8
+ /** Stands in for `cua-driver serve --socket <s> --pid-file <p>`. */
9
+ const FAKE = `
10
+ const [, , , , socket, , pidFile] = process.argv;
11
+ require('node:fs').writeFileSync(pidFile, String(process.pid));
12
+ require('node:net').createServer(c => c.end()).listen(socket);
13
+ process.on('SIGTERM', () => process.exit(0));
14
+ `;
15
+
16
+ function exited(child: ChildProcess): Promise<unknown> {
17
+ return child.exitCode !== null || child.signalCode !== null ? Promise.resolve() : new Promise(resolve => child.once('exit', resolve));
18
+ }
19
+
20
+ describe.skipIf(process.platform === 'win32')('CuaDriverDaemon', () => {
21
+ const children: ChildProcess[] = [];
22
+ let dir: string;
23
+
24
+ afterEach(() => {
25
+ for (const child of children) {
26
+ child.kill('SIGKILL');
27
+ }
28
+ children.length = 0;
29
+ rmSync(dir, { recursive: true, force: true });
30
+ });
31
+
32
+ /** An app whose program is the fake; `launches` runs `other`'s program instead when given, as LaunchServices may. */
33
+ function daemon(other?: string): { daemon: CuaDriverDaemon; launches: string[][]; app: string } {
34
+ dir = mkdtempSync(join(tmpdir(), 'ta-cua-'));
35
+ const app = fakeApp(join(dir, 'A'));
36
+ const runs = other ? fakeApp(join(dir, other)) : app;
37
+ const launches: string[][] = [];
38
+ return {
39
+ app,
40
+ launches,
41
+ daemon: new CuaDriverDaemon(app, {
42
+ dir: join(dir, 'cache'),
43
+ env: { CUA_DRIVER_RS_TELEMETRY_ENABLED: '0' },
44
+ launch: async (target, args, env) => {
45
+ launches.push([target, ...args, JSON.stringify(env)]);
46
+ children.push(spawn('node', [join(runs, 'Contents/MacOS/cua-driver'), ...args], { stdio: 'ignore' }));
47
+ },
48
+ }),
49
+ };
50
+ }
51
+
52
+ function fakeApp(at: string): string {
53
+ const app = join(at, 'CuaDriver.app');
54
+ mkdirSync(join(app, 'Contents/MacOS'), { recursive: true });
55
+ writeFileSync(join(app, 'Contents/MacOS/cua-driver'), FAKE);
56
+ return app;
57
+ }
58
+
59
+ it('starts it once on its own socket, uses it while it runs, and stops it', async () => {
60
+ const { daemon: driver, launches } = daemon();
61
+ expect(await driver.alive()).toBe(false);
62
+ // A socket file left behind by one that was killed.
63
+ writeFileSync(join(dir, 'stale'), '');
64
+ await Promise.all([driver.ensure(), driver.ensure()]);
65
+ await driver.ensure();
66
+ expect(launches).toEqual([[driver.app, 'serve', '--socket', driver.socket, '--pid-file', driver.pidFile, '{"CUA_DRIVER_RS_TELEMETRY_ENABLED":"0"}']]);
67
+ expect(await driver.alive()).toBe(true);
68
+ driver.stop();
69
+ await exited(children[0]);
70
+ expect(await driver.alive()).toBe(false);
71
+ await driver.ensure();
72
+ expect(launches.length).toBe(2);
73
+ });
74
+
75
+ it('does not stop one it did not start', async () => {
76
+ const { daemon: first } = daemon();
77
+ await first.ensure();
78
+ const second = new CuaDriverDaemon(first.app, {
79
+ dir: join(dir, 'cache'),
80
+ launch: async () => {
81
+ throw new Error('not again');
82
+ },
83
+ });
84
+ await second.ensure();
85
+ second.stop();
86
+ expect(await first.alive()).toBe(true);
87
+ expect(existsSync(first.pidFile)).toBe(true);
88
+ });
89
+
90
+ it('stops another copy of the app that macOS started instead, and says what to do', async () => {
91
+ const { daemon: driver } = daemon('B');
92
+ await expect(driver.ensure()).rejects.toThrow('doctor --fix');
93
+ await exited(children[0]);
94
+ expect(await driver.alive()).toBe(false);
95
+ });
96
+
97
+ it('replaces a running daemon of another copy, which a relay of this one would not understand', async () => {
98
+ const { daemon: first } = daemon();
99
+ await first.ensure();
100
+ const firstPid = Number(readFileSync(first.pidFile, 'utf8'));
101
+ const launches: string[] = [];
102
+ class Other extends CuaDriverDaemon {
103
+ protected override identify() {
104
+ return Promise.resolve(launches.length === 0 ? { executable: '/Other/CuaDriver.app/Contents/MacOS/cua-driver', pid: firstPid } : undefined);
105
+ }
106
+ }
107
+ const second = new Other(first.app, {
108
+ dir: first.dir,
109
+ launch: async (_app, args) => {
110
+ launches.push('launch');
111
+ children.push(spawn('node', [join(first.app, 'Contents/MacOS/cua-driver'), ...args], { stdio: 'ignore' }));
112
+ },
113
+ });
114
+ await second.ensure();
115
+ await exited(children[0]);
116
+ expect(launches).toEqual(['launch']);
117
+ expect(await second.alive()).toBe(true);
118
+ second.stop();
119
+ });
120
+
121
+ it("runs an app inside another app's bundle from its copy outside, by version, and drops older copies", async () => {
122
+ dir = mkdtempSync(join(tmpdir(), 'ta-cua-'));
123
+ const app = fakeApp(join(dir, 'Ta.app/Contents/Resources/cua-driver'));
124
+ writeFileSync(join(app, 'Contents/Info.plist'), '<plist><dict><key>CFBundleVersion</key>\n<string>0.47.5</string></dict></plist>');
125
+ const stageDir = join(dir, 'stage');
126
+ mkdirSync(join(stageDir, '0.40.0/CuaDriver.app'), { recursive: true });
127
+ const launched: string[] = [];
128
+ const driver = new CuaDriverDaemon(app, {
129
+ dir: join(dir, 'cache'),
130
+ stageDir,
131
+ launch: async (target, args) => {
132
+ launched.push(target);
133
+ children.push(spawn('node', [join(target, 'Contents/MacOS/cua-driver'), ...args], { stdio: 'ignore' }));
134
+ },
135
+ });
136
+ expect(driver.app).toBe(join(stageDir, '0.47.5', 'CuaDriver.app'));
137
+ await driver.ensure();
138
+ expect(launched).toEqual([driver.app]);
139
+ expect(readFileSync(join(driver.app, 'Contents/MacOS/cua-driver'), 'utf8')).toBe(FAKE);
140
+ expect(existsSync(join(stageDir, '0.40.0'))).toBe(false);
141
+ expect(CuaDriverDaemon.nested('/Applications/CuaDriver.app')).toBe(false);
142
+ driver.stop();
143
+ });
144
+
145
+ it('stops by itself when unused', async () => {
146
+ const { daemon: driver } = daemon();
147
+ const idle = new CuaDriverDaemon(driver.app, {
148
+ dir: driver.dir,
149
+ idleMs: 50,
150
+ launch: (driver as unknown as { launch: CuaDriverDaemon['ensure'] }).launch as never,
151
+ });
152
+ await idle.ensure();
153
+ expect(await idle.alive()).toBe(true);
154
+ await exited(children[0]);
155
+ expect(await idle.alive()).toBe(false);
156
+ });
157
+
158
+ it('says so when it does not start', async () => {
159
+ dir = mkdtempSync(join(tmpdir(), 'ta-cua-'));
160
+ const driver = new CuaDriverDaemon(join(dir, 'CuaDriver.app'), {
161
+ dir,
162
+ launch: async () => {
163
+ throw new Error('LSOpenURLsWithRole() failed');
164
+ },
165
+ });
166
+ await expect(driver.ensure()).rejects.toThrow('LSOpenURLsWithRole');
167
+ });
168
+ });