Skip to content

Commit 274eeb5

Browse files
authored
refactor: route process spawning through exec helpers (#504)
1 parent 3966c5e commit 274eeb5

10 files changed

Lines changed: 218 additions & 137 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,7 @@ A new snapshot/command flag touches up to 7 files in a fixed order. Follow this
102102
Command-only flags (like `find --first`) that don't flow to the platform layer only need steps 1 and the handler file.
103103

104104
## Hard Rules
105-
- Use `runCmd`/`runCmdSync` from `src/utils/exec.ts` for process execution.
105+
- Use process helpers from `src/utils/exec.ts` for TypeScript process execution: `runCmd`, `runCmdStreaming`, `runCmdSync`, `runCmdBackground`, and `runCmdDetached`. Do not import raw `spawn`/`spawnSync` outside `src/utils/exec.ts`; add or extend an exec helper instead. Plain `.mjs` packaging fixtures that cannot import TypeScript helpers should keep child-process usage local and prefer `execFile`/`execFileSync` over spawn.
106106
- Use daemon session flow for interactions (`open` before interactions, `close` after).
107107
- Use `keyboard dismiss` for iOS keyboard dismissal; it may tap safe native controls such as `Done` but must not fall back to system back navigation.
108108
- Do not remove shared snapshot/session model behavior without full migration.

src/daemon/__tests__/app-log-android.test.ts

Lines changed: 21 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,25 +6,24 @@ import os from 'node:os';
66
import path from 'node:path';
77
import { PassThrough } from 'node:stream';
88

9-
vi.mock('node:child_process', () => ({ spawn: vi.fn() }));
109
vi.mock('../../utils/exec.ts', async (importOriginal) => {
1110
const actual = await importOriginal<typeof import('../../utils/exec.ts')>();
1211
return {
1312
...actual,
1413
runCmd: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
14+
runCmdBackground: vi.fn(),
1515
};
1616
});
1717
vi.mock('../app-log-stream.ts', async (importOriginal) => {
1818
const actual = await importOriginal<typeof import('../app-log-stream.ts')>();
1919
return { ...actual, sleep: vi.fn(async () => {}) };
2020
});
2121

22-
import { spawn } from 'node:child_process';
23-
import { runCmd } from '../../utils/exec.ts';
22+
import { runCmd, runCmdBackground } from '../../utils/exec.ts';
2423
import { readRecentAndroidLogcatForPackage, startAndroidAppLog } from '../app-log-android.ts';
2524

26-
const mockSpawn = vi.mocked(spawn);
2725
const mockRunCmd = vi.mocked(runCmd);
26+
const mockRunCmdBackground = vi.mocked(runCmdBackground);
2827

2928
type MockChild = EventEmitter & {
3029
stdout: PassThrough;
@@ -51,6 +50,15 @@ function makeMockChild(pid?: number): MockChild {
5150
return child;
5251
}
5352

53+
function mockBackgroundChild(child: MockChild): ReturnType<typeof runCmdBackground> {
54+
return {
55+
child: child as unknown as ReturnType<typeof runCmdBackground>['child'],
56+
wait: new Promise((resolve) => {
57+
child.once('close', (code) => resolve({ stdout: '', stderr: '', exitCode: code ?? 0 }));
58+
}),
59+
};
60+
}
61+
5462
test('startAndroidAppLog returns to active state after a successful reattach', async () => {
5563
const logDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-android-log-'));
5664
const stream = fs.createWriteStream(path.join(logDir, 'app.log'));
@@ -71,25 +79,25 @@ test('startAndroidAppLog returns to active state after a successful reattach', a
7179
return { stdout: '', stderr: '', exitCode: 0 };
7280
});
7381

74-
mockSpawn.mockReset();
82+
mockRunCmdBackground.mockReset();
7583
let spawnCount = 0;
76-
mockSpawn.mockImplementation(() => {
84+
mockRunCmdBackground.mockImplementation(() => {
7785
spawnCount += 1;
7886
if (spawnCount === 1) {
79-
return firstChild as unknown as ReturnType<typeof spawn>;
87+
return mockBackgroundChild(firstChild);
8088
}
81-
return secondChild as unknown as ReturnType<typeof spawn>;
89+
return mockBackgroundChild(secondChild);
8290
});
8391

8492
const appLog = await startAndroidAppLog('emulator-5554', 'com.example.app', stream, []);
8593
await vi.waitFor(() => {
86-
expect(mockSpawn).toHaveBeenCalledTimes(1);
94+
expect(mockRunCmdBackground).toHaveBeenCalledTimes(1);
8795
});
8896
assert.equal(appLog.getState(), 'active');
8997

9098
firstChild.emit('close', 1);
9199
await vi.waitFor(() => {
92-
expect(mockSpawn).toHaveBeenCalledTimes(2);
100+
expect(mockRunCmdBackground).toHaveBeenCalledTimes(2);
93101
});
94102
assert.equal(appLog.getState(), 'active');
95103

@@ -110,12 +118,12 @@ test('startAndroidAppLog reports active for provider streams without host pid',
110118
return { stdout: '', stderr: '', exitCode: 0 };
111119
});
112120

113-
mockSpawn.mockReset();
114-
mockSpawn.mockImplementation(() => child as unknown as ReturnType<typeof spawn>);
121+
mockRunCmdBackground.mockReset();
122+
mockRunCmdBackground.mockImplementation(() => mockBackgroundChild(child));
115123

116124
const appLog = await startAndroidAppLog('emulator-5554', 'com.example.app', stream, []);
117125
await vi.waitFor(() => {
118-
expect(mockSpawn).toHaveBeenCalledTimes(1);
126+
expect(mockRunCmdBackground).toHaveBeenCalledTimes(1);
119127
});
120128

121129
assert.equal(appLog.getState(), 'active');

src/daemon/app-log-ios.ts

Lines changed: 52 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
1-
import { spawn } from 'node:child_process';
21
import fs from 'node:fs';
32
import { buildSimctlArgs } from '../platforms/ios/simctl.ts';
4-
import { runCmd } from '../utils/exec.ts';
3+
import { runCmd, runCmdBackground } from '../utils/exec.ts';
54
import { clearPidFile, writePidFile, type AppLogResult } from './app-log-process.ts';
65
import { attachChildToStream, createLineWriter, waitForChildExit } from './app-log-stream.ts';
76

@@ -96,38 +95,14 @@ export async function startIosSimulatorAppLog(
9695
simulatorSetPath?: string,
9796
pidPath?: string,
9897
): Promise<AppLogResult> {
99-
let state: 'active' | 'failed' = 'active';
100-
const child = spawn(
101-
'xcrun',
102-
buildIosSimulatorLogStreamArgs({ deviceId, appBundleId, simulatorSetPath }),
103-
{
104-
stdio: ['ignore', 'pipe', 'pipe'],
105-
},
106-
);
107-
const writer = createLineWriter(stream, { redactionPatterns });
108-
if (typeof child.pid === 'number') {
109-
writePidFile(pidPath, child.pid);
110-
}
111-
const wait = attachChildToStream(child, stream, { endStreamOnClose: true, writer }).then(
112-
(result) => {
113-
if (result.exitCode !== 0) state = 'failed';
114-
clearPidFile(pidPath);
115-
return result;
116-
},
117-
);
118-
return {
98+
return startAppleAppLogStream({
11999
backend: 'ios-simulator',
120-
getState: () => state,
121-
startedAt: Date.now(),
122-
wait,
123-
stop: async () => {
124-
if (!child.killed) child.kill('SIGINT');
125-
await waitForChildExit(wait);
126-
if (!child.killed) child.kill('SIGKILL');
127-
await waitForChildExit(wait);
128-
clearPidFile(pidPath);
129-
},
130-
};
100+
cmd: 'xcrun',
101+
args: buildIosSimulatorLogStreamArgs({ deviceId, appBundleId, simulatorSetPath }),
102+
stream,
103+
redactionPatterns,
104+
pidPath,
105+
});
131106
}
132107

133108
export async function startMacOsAppLog(
@@ -136,38 +111,14 @@ export async function startMacOsAppLog(
136111
redactionPatterns: RegExp[],
137112
pidPath?: string,
138113
): Promise<AppLogResult> {
139-
let state: 'active' | 'failed' = 'active';
140-
const child = spawn(
141-
'log',
142-
['stream', '--style', 'compact', '--predicate', buildAppleLogPredicate(appBundleId)],
143-
{
144-
stdio: ['ignore', 'pipe', 'pipe'],
145-
},
146-
);
147-
const writer = createLineWriter(stream, { redactionPatterns });
148-
if (typeof child.pid === 'number') {
149-
writePidFile(pidPath, child.pid);
150-
}
151-
const wait = attachChildToStream(child, stream, { endStreamOnClose: true, writer }).then(
152-
(result) => {
153-
if (result.exitCode !== 0) state = 'failed';
154-
clearPidFile(pidPath);
155-
return result;
156-
},
157-
);
158-
return {
114+
return startAppleAppLogStream({
159115
backend: 'macos',
160-
getState: () => state,
161-
startedAt: Date.now(),
162-
wait,
163-
stop: async () => {
164-
if (!child.killed) child.kill('SIGINT');
165-
await waitForChildExit(wait);
166-
if (!child.killed) child.kill('SIGKILL');
167-
await waitForChildExit(wait);
168-
clearPidFile(pidPath);
169-
},
170-
};
116+
cmd: 'log',
117+
args: ['stream', '--style', 'compact', '--predicate', buildAppleLogPredicate(appBundleId)],
118+
stream,
119+
redactionPatterns,
120+
pidPath,
121+
});
171122
}
172123

173124
export async function startIosDeviceAppLog(
@@ -176,23 +127,52 @@ export async function startIosDeviceAppLog(
176127
redactionPatterns: RegExp[],
177128
pidPath?: string,
178129
): Promise<AppLogResult> {
130+
return startAppleAppLogStream({
131+
backend: 'ios-device',
132+
cmd: 'xcrun',
133+
args: buildIosDeviceLogStreamArgs(deviceId),
134+
stream,
135+
redactionPatterns,
136+
pidPath,
137+
});
138+
}
139+
140+
function startAppleAppLogStream(params: {
141+
backend: AppLogResult['backend'];
142+
cmd: string;
143+
args: string[];
144+
stream: fs.WriteStream;
145+
redactionPatterns: RegExp[];
146+
pidPath?: string;
147+
}): AppLogResult {
179148
let state: 'active' | 'failed' = 'active';
180-
const child = spawn('xcrun', buildIosDeviceLogStreamArgs(deviceId), {
181-
stdio: ['ignore', 'pipe', 'pipe'],
149+
const background = runCmdBackground(params.cmd, params.args, {
150+
allowFailure: true,
151+
captureOutput: false,
182152
});
183-
const writer = createLineWriter(stream, { redactionPatterns });
153+
void background.wait.catch(() => {});
154+
const child = background.child;
155+
const writer = createLineWriter(params.stream, { redactionPatterns: params.redactionPatterns });
184156
if (typeof child.pid === 'number') {
185-
writePidFile(pidPath, child.pid);
157+
writePidFile(params.pidPath, child.pid);
186158
}
187-
const wait = attachChildToStream(child, stream, { endStreamOnClose: true, writer }).then(
159+
const wait = attachChildToStream(child, params.stream, {
160+
endStreamOnClose: true,
161+
writer,
162+
}).then(
188163
(result) => {
189164
if (result.exitCode !== 0) state = 'failed';
190-
clearPidFile(pidPath);
165+
clearPidFile(params.pidPath);
191166
return result;
192167
},
168+
(error: unknown) => {
169+
state = 'failed';
170+
clearPidFile(params.pidPath);
171+
throw error;
172+
},
193173
);
194174
return {
195-
backend: 'ios-device',
175+
backend: params.backend,
196176
getState: () => state,
197177
startedAt: Date.now(),
198178
wait,
@@ -201,7 +181,7 @@ export async function startIosDeviceAppLog(
201181
await waitForChildExit(wait);
202182
if (!child.killed) child.kill('SIGKILL');
203183
await waitForChildExit(wait);
204-
clearPidFile(pidPath);
184+
clearPidFile(params.pidPath);
205185
},
206186
};
207187
}

src/daemon/types.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export type { DaemonLockPolicy } from '../contracts.ts';
1212
import type { CommandFlags } from '../core/dispatch.ts';
1313
import type { SessionSurface } from '../core/session-surface.ts';
1414
import type { DeviceInfo, Platform, PlatformSelector } from '../utils/device.ts';
15-
import type { ExecResult } from '../utils/exec.ts';
15+
import type { ExecBackgroundResult, ExecResult } from '../utils/exec.ts';
1616
import type { SnapshotState } from '../utils/snapshot.ts';
1717
import type { AppLogState } from './app-log-process.ts';
1818

@@ -177,7 +177,7 @@ export type SessionState = {
177177
recording?:
178178
| (SessionRecordingBase & {
179179
platform: 'ios';
180-
child: ReturnType<typeof import('node:child_process').spawn>;
180+
child: ExecBackgroundResult['child'];
181181
wait: Promise<ExecResult>;
182182
remotePath?: string;
183183
})

src/platforms/android/__tests__/adb-executor.test.ts

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,19 @@
11
import assert from 'node:assert/strict';
22
import { test, vi } from 'vitest';
33

4-
const { spawnMock } = vi.hoisted(() => ({
5-
spawnMock: vi.fn(() => ({})),
4+
const { runCmdBackgroundMock } = vi.hoisted(() => ({
5+
runCmdBackgroundMock: vi.fn(() => ({
6+
child: {},
7+
wait: Promise.resolve({ stdout: '', stderr: '', exitCode: 0 }),
8+
})),
69
}));
710

8-
vi.mock('node:child_process', async (importOriginal) => {
9-
const actual = await importOriginal<typeof import('node:child_process')>();
10-
return {
11-
...actual,
12-
spawn: spawnMock,
13-
};
14-
});
15-
1611
vi.mock('../../../utils/exec.ts', async (importOriginal) => {
1712
const actual = await importOriginal<typeof import('../../../utils/exec.ts')>();
1813
return {
1914
...actual,
2015
runCmd: vi.fn(async () => ({ stdout: 'ok', stderr: '', exitCode: 0 })),
16+
runCmdBackground: runCmdBackgroundMock,
2117
};
2218
});
2319

@@ -31,9 +27,10 @@ import {
3127
resolveAndroidAdbProvider,
3228
withAndroidAdbProvider,
3329
} from '../adb-executor.ts';
34-
import { runCmd } from '../../../utils/exec.ts';
30+
import { runCmd, runCmdBackground } from '../../../utils/exec.ts';
3531

3632
const mockRunCmd = vi.mocked(runCmd);
33+
const mockRunCmdBackground = vi.mocked(runCmdBackground);
3734

3835
test('createDeviceAdbExecutor routes local commands through adb with the device serial', async () => {
3936
const adb = createDeviceAdbExecutor({
@@ -117,6 +114,7 @@ test('scoped provider only resolves for the matching device serial', async () =>
117114

118115
test('createLocalAndroidAdbProvider exposes exec, spawn, and reverse over local adb', async () => {
119116
mockRunCmd.mockClear();
117+
mockRunCmdBackground.mockClear();
120118
const provider = createLocalAndroidAdbProvider({
121119
platform: 'android',
122120
id: 'emulator-5554',
@@ -130,11 +128,11 @@ test('createLocalAndroidAdbProvider exposes exec, spawn, and reverse over local
130128
await provider.reverse?.ensure({ local: 'tcp:8081', remote: 'tcp:8081', ownerId: 'session-a' });
131129
await provider.reverse?.removeAllOwned('session-a');
132130

133-
assert.deepEqual(spawnMock.mock.calls, [
131+
assert.deepEqual(mockRunCmdBackground.mock.calls, [
134132
[
135133
'adb',
136134
['-s', 'emulator-5554', 'logcat'],
137-
{ stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true },
135+
{ stdio: ['ignore', 'pipe', 'pipe'], allowFailure: true, captureOutput: false },
138136
],
139137
]);
140138
assert.deepEqual(

src/platforms/android/__tests__/adb-provider-scope.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ import assert from 'node:assert/strict';
22
import fs from 'node:fs';
33
import os from 'node:os';
44
import path from 'node:path';
5-
import type { ChildProcess } from 'node:child_process';
65
import { test } from 'vitest';
76
import { runCmd } from '../../../utils/exec.ts';
8-
import { resolveAndroidAdbProvider, withAndroidAdbProvider } from '../adb-executor.ts';
7+
import {
8+
resolveAndroidAdbProvider,
9+
withAndroidAdbProvider,
10+
type AndroidAdbProcess,
11+
} from '../adb-executor.ts';
912

1013
const device = {
1114
platform: 'android',
@@ -66,7 +69,7 @@ test('withAndroidAdbProvider ignores adb commands for another serial', async ()
6669
});
6770

6871
test('resolveAndroidAdbProvider uses the scoped provider spawner', async () => {
69-
const child = { pid: 123 } as ChildProcess;
72+
const child = { pid: 123 } as AndroidAdbProcess;
7073
const calls: string[][] = [];
7174

7275
const result = await withAndroidAdbProvider(

0 commit comments

Comments
 (0)