agent-device 0.4.2 → 0.5.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/README.md +2 -9
- package/dist/src/797.js +1 -1
- package/dist/src/bin.js +5 -5
- package/dist/src/daemon.js +16 -20
- package/package.json +7 -9
- package/skills/agent-device/SKILL.md +3 -6
- package/skills/agent-device/references/permissions.md +3 -15
- package/skills/agent-device/references/snapshot-refs.md +1 -4
- package/dist/bin/axsnapshot +0 -0
- package/ios-runner/AXSnapshot/Package.swift +0 -18
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
- package/src/__tests__/cli-close.test.ts +0 -155
- package/src/__tests__/cli-help.test.ts +0 -102
- package/src/bin.ts +0 -3
- package/src/cli.ts +0 -305
- package/src/core/__tests__/capabilities.test.ts +0 -75
- package/src/core/__tests__/dispatch-open.test.ts +0 -25
- package/src/core/__tests__/open-target.test.ts +0 -55
- package/src/core/capabilities.ts +0 -57
- package/src/core/dispatch.ts +0 -382
- package/src/core/open-target.ts +0 -27
- package/src/daemon/__tests__/device-ready.test.ts +0 -52
- package/src/daemon/__tests__/is-predicates.test.ts +0 -68
- package/src/daemon/__tests__/selectors.test.ts +0 -261
- package/src/daemon/__tests__/session-routing.test.ts +0 -108
- package/src/daemon/__tests__/session-selector.test.ts +0 -64
- package/src/daemon/__tests__/session-store.test.ts +0 -142
- package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
- package/src/daemon/action-utils.ts +0 -29
- package/src/daemon/context.ts +0 -48
- package/src/daemon/device-ready.ts +0 -155
- package/src/daemon/handlers/__tests__/find.test.ts +0 -99
- package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
- package/src/daemon/handlers/__tests__/session.test.ts +0 -820
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
- package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
- package/src/daemon/handlers/find.ts +0 -324
- package/src/daemon/handlers/interaction.ts +0 -550
- package/src/daemon/handlers/parse-utils.ts +0 -8
- package/src/daemon/handlers/record-trace.ts +0 -154
- package/src/daemon/handlers/session.ts +0 -1137
- package/src/daemon/handlers/snapshot.ts +0 -439
- package/src/daemon/is-predicates.ts +0 -46
- package/src/daemon/selectors.ts +0 -540
- package/src/daemon/session-routing.ts +0 -22
- package/src/daemon/session-selector.ts +0 -39
- package/src/daemon/session-store.ts +0 -296
- package/src/daemon/snapshot-processing.ts +0 -131
- package/src/daemon/types.ts +0 -56
- package/src/daemon-client.ts +0 -272
- package/src/daemon.ts +0 -295
- package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
- package/src/platforms/android/__tests__/index.test.ts +0 -274
- package/src/platforms/android/devices.ts +0 -196
- package/src/platforms/android/index.ts +0 -784
- package/src/platforms/android/ui-hierarchy.ts +0 -312
- package/src/platforms/boot-diagnostics.ts +0 -128
- package/src/platforms/ios/__tests__/index.test.ts +0 -312
- package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
- package/src/platforms/ios/apps.ts +0 -358
- package/src/platforms/ios/ax-snapshot.ts +0 -207
- package/src/platforms/ios/config.ts +0 -28
- package/src/platforms/ios/devicectl.ts +0 -134
- package/src/platforms/ios/devices.ts +0 -100
- package/src/platforms/ios/index.ts +0 -20
- package/src/platforms/ios/runner-client.ts +0 -994
- package/src/platforms/ios/simulator.ts +0 -164
- package/src/utils/__tests__/args.test.ts +0 -239
- package/src/utils/__tests__/daemon-client.test.ts +0 -95
- package/src/utils/__tests__/exec.test.ts +0 -16
- package/src/utils/__tests__/finders.test.ts +0 -34
- package/src/utils/__tests__/keyed-lock.test.ts +0 -55
- package/src/utils/__tests__/process-identity.test.ts +0 -33
- package/src/utils/__tests__/retry.test.ts +0 -44
- package/src/utils/args.ts +0 -239
- package/src/utils/command-schema.ts +0 -622
- package/src/utils/device.ts +0 -84
- package/src/utils/errors.ts +0 -35
- package/src/utils/exec.ts +0 -339
- package/src/utils/finders.ts +0 -101
- package/src/utils/interactive.ts +0 -4
- package/src/utils/interactors.ts +0 -173
- package/src/utils/keyed-lock.ts +0 -14
- package/src/utils/output.ts +0 -204
- package/src/utils/process-identity.ts +0 -100
- package/src/utils/retry.ts +0 -180
- package/src/utils/snapshot.ts +0 -64
- package/src/utils/timeouts.ts +0 -9
- package/src/utils/version.ts +0 -26
|
@@ -1,994 +0,0 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
|
-
import os from 'node:os';
|
|
3
|
-
import path from 'node:path';
|
|
4
|
-
import { fileURLToPath } from 'node:url';
|
|
5
|
-
import { AppError } from '../../utils/errors.ts';
|
|
6
|
-
import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
|
|
7
|
-
import { Deadline, isEnvTruthy, retryWithPolicy, withRetry } from '../../utils/retry.ts';
|
|
8
|
-
import type { DeviceInfo } from '../../utils/device.ts';
|
|
9
|
-
import { withKeyedLock } from '../../utils/keyed-lock.ts';
|
|
10
|
-
import { isProcessAlive } from '../../utils/process-identity.ts';
|
|
11
|
-
import net from 'node:net';
|
|
12
|
-
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
13
|
-
import { resolveTimeoutMs, resolveTimeoutSeconds } from '../../utils/timeouts.ts';
|
|
14
|
-
|
|
15
|
-
export type RunnerCommand = {
|
|
16
|
-
command:
|
|
17
|
-
| 'tap'
|
|
18
|
-
| 'longPress'
|
|
19
|
-
| 'drag'
|
|
20
|
-
| 'type'
|
|
21
|
-
| 'swipe'
|
|
22
|
-
| 'findText'
|
|
23
|
-
| 'listTappables'
|
|
24
|
-
| 'snapshot'
|
|
25
|
-
| 'back'
|
|
26
|
-
| 'home'
|
|
27
|
-
| 'appSwitcher'
|
|
28
|
-
| 'alert'
|
|
29
|
-
| 'pinch'
|
|
30
|
-
| 'shutdown';
|
|
31
|
-
appBundleId?: string;
|
|
32
|
-
text?: string;
|
|
33
|
-
action?: 'get' | 'accept' | 'dismiss';
|
|
34
|
-
x?: number;
|
|
35
|
-
y?: number;
|
|
36
|
-
x2?: number;
|
|
37
|
-
y2?: number;
|
|
38
|
-
durationMs?: number;
|
|
39
|
-
direction?: 'up' | 'down' | 'left' | 'right';
|
|
40
|
-
scale?: number;
|
|
41
|
-
interactiveOnly?: boolean;
|
|
42
|
-
compact?: boolean;
|
|
43
|
-
depth?: number;
|
|
44
|
-
scope?: string;
|
|
45
|
-
raw?: boolean;
|
|
46
|
-
clearFirst?: boolean;
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
export type RunnerSession = {
|
|
50
|
-
device: DeviceInfo;
|
|
51
|
-
deviceId: string;
|
|
52
|
-
port: number;
|
|
53
|
-
xctestrunPath: string;
|
|
54
|
-
jsonPath: string;
|
|
55
|
-
testPromise: Promise<ExecResult>;
|
|
56
|
-
child: ExecBackgroundResult['child'];
|
|
57
|
-
ready: boolean;
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const runnerSessions = new Map<string, RunnerSession>();
|
|
61
|
-
const runnerSessionLocks = new Map<string, Promise<unknown>>();
|
|
62
|
-
const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
|
|
63
|
-
process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
|
|
64
|
-
45_000,
|
|
65
|
-
5_000,
|
|
66
|
-
);
|
|
67
|
-
const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
|
|
68
|
-
process.env.AGENT_DEVICE_RUNNER_COMMAND_TIMEOUT_MS,
|
|
69
|
-
15_000,
|
|
70
|
-
1_000,
|
|
71
|
-
);
|
|
72
|
-
const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
|
|
73
|
-
process.env.AGENT_DEVICE_RUNNER_CONNECT_ATTEMPT_INTERVAL_MS,
|
|
74
|
-
250,
|
|
75
|
-
50,
|
|
76
|
-
);
|
|
77
|
-
const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs(
|
|
78
|
-
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
|
|
79
|
-
300,
|
|
80
|
-
10,
|
|
81
|
-
);
|
|
82
|
-
const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
|
|
83
|
-
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
|
|
84
|
-
2_000,
|
|
85
|
-
10,
|
|
86
|
-
);
|
|
87
|
-
const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
|
88
|
-
process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
|
|
89
|
-
5_000,
|
|
90
|
-
250,
|
|
91
|
-
);
|
|
92
|
-
const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
|
|
93
|
-
process.env.AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS,
|
|
94
|
-
10_000,
|
|
95
|
-
500,
|
|
96
|
-
);
|
|
97
|
-
const RUNNER_DESTINATION_TIMEOUT_SECONDS = resolveTimeoutSeconds(
|
|
98
|
-
process.env.AGENT_DEVICE_RUNNER_DESTINATION_TIMEOUT_SECONDS,
|
|
99
|
-
20,
|
|
100
|
-
5,
|
|
101
|
-
);
|
|
102
|
-
const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
|
|
103
|
-
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
104
|
-
const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner');
|
|
105
|
-
|
|
106
|
-
export type RunnerSnapshotNode = {
|
|
107
|
-
index: number;
|
|
108
|
-
type?: string;
|
|
109
|
-
label?: string;
|
|
110
|
-
value?: string;
|
|
111
|
-
identifier?: string;
|
|
112
|
-
rect?: { x: number; y: number; width: number; height: number };
|
|
113
|
-
enabled?: boolean;
|
|
114
|
-
hittable?: boolean;
|
|
115
|
-
depth?: number;
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
export async function runIosRunnerCommand(
|
|
119
|
-
device: DeviceInfo,
|
|
120
|
-
command: RunnerCommand,
|
|
121
|
-
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
|
|
122
|
-
): Promise<Record<string, unknown>> {
|
|
123
|
-
validateRunnerDevice(device);
|
|
124
|
-
if (isReadOnlyRunnerCommand(command.command)) {
|
|
125
|
-
return withRetry(
|
|
126
|
-
() => executeRunnerCommand(device, command, options),
|
|
127
|
-
{ shouldRetry: isRetryableRunnerError },
|
|
128
|
-
);
|
|
129
|
-
}
|
|
130
|
-
return executeRunnerCommand(device, command, options);
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function withRunnerSessionLock<T>(deviceId: string, task: () => Promise<T>): Promise<T> {
|
|
134
|
-
return withKeyedLock(runnerSessionLocks, deviceId, task);
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function executeRunnerCommand(
|
|
138
|
-
device: DeviceInfo,
|
|
139
|
-
command: RunnerCommand,
|
|
140
|
-
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
|
|
141
|
-
): Promise<Record<string, unknown>> {
|
|
142
|
-
let session: RunnerSession | undefined;
|
|
143
|
-
try {
|
|
144
|
-
session = await ensureRunnerSession(device, options);
|
|
145
|
-
const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
|
|
146
|
-
return await executeRunnerCommandWithSession(
|
|
147
|
-
device,
|
|
148
|
-
session,
|
|
149
|
-
command,
|
|
150
|
-
options.logPath,
|
|
151
|
-
timeoutMs,
|
|
152
|
-
);
|
|
153
|
-
} catch (err) {
|
|
154
|
-
const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
|
|
155
|
-
if (
|
|
156
|
-
appErr.code === 'COMMAND_FAILED' &&
|
|
157
|
-
typeof appErr.message === 'string' &&
|
|
158
|
-
appErr.message.includes('Runner did not accept connection') &&
|
|
159
|
-
shouldRetryRunnerConnectError(appErr) &&
|
|
160
|
-
session?.ready
|
|
161
|
-
) {
|
|
162
|
-
if (session) {
|
|
163
|
-
await stopRunnerSession(session);
|
|
164
|
-
} else {
|
|
165
|
-
await stopIosRunnerSession(device.id);
|
|
166
|
-
}
|
|
167
|
-
session = await ensureRunnerSession(device, options);
|
|
168
|
-
const response = await waitForRunner(
|
|
169
|
-
session.device,
|
|
170
|
-
session.port,
|
|
171
|
-
command,
|
|
172
|
-
options.logPath,
|
|
173
|
-
RUNNER_STARTUP_TIMEOUT_MS,
|
|
174
|
-
);
|
|
175
|
-
return await parseRunnerResponse(response, session, options.logPath);
|
|
176
|
-
}
|
|
177
|
-
throw err;
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
async function executeRunnerCommandWithSession(
|
|
182
|
-
device: DeviceInfo,
|
|
183
|
-
session: RunnerSession,
|
|
184
|
-
command: RunnerCommand,
|
|
185
|
-
logPath: string | undefined,
|
|
186
|
-
timeoutMs: number,
|
|
187
|
-
): Promise<Record<string, unknown>> {
|
|
188
|
-
const response = await waitForRunner(device, session.port, command, logPath, timeoutMs, session);
|
|
189
|
-
return await parseRunnerResponse(response, session, logPath);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
async function parseRunnerResponse(
|
|
193
|
-
response: Response,
|
|
194
|
-
session: RunnerSession,
|
|
195
|
-
logPath?: string,
|
|
196
|
-
): Promise<Record<string, unknown>> {
|
|
197
|
-
const text = await response.text();
|
|
198
|
-
let json: any = {};
|
|
199
|
-
try {
|
|
200
|
-
json = JSON.parse(text);
|
|
201
|
-
} catch {
|
|
202
|
-
throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text });
|
|
203
|
-
}
|
|
204
|
-
if (!json.ok) {
|
|
205
|
-
throw new AppError('COMMAND_FAILED', json.error?.message ?? 'Runner error', {
|
|
206
|
-
runner: json,
|
|
207
|
-
xcodebuild: {
|
|
208
|
-
exitCode: 1,
|
|
209
|
-
stdout: '',
|
|
210
|
-
stderr: '',
|
|
211
|
-
},
|
|
212
|
-
logPath,
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
session.ready = true;
|
|
216
|
-
return json.data ?? {};
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
export async function stopIosRunnerSession(deviceId: string): Promise<void> {
|
|
220
|
-
await withRunnerSessionLock(deviceId, async () => {
|
|
221
|
-
await stopRunnerSessionInternal(deviceId);
|
|
222
|
-
});
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function stopAllIosRunnerSessions(): Promise<void> {
|
|
226
|
-
// Shutdown cleanup drains the sessions known at invocation time; daemon shutdown closes intake.
|
|
227
|
-
const pending = Array.from(runnerSessions.keys());
|
|
228
|
-
await Promise.allSettled(pending.map(async (deviceId) => {
|
|
229
|
-
await stopIosRunnerSession(deviceId);
|
|
230
|
-
}));
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
async function stopRunnerSession(session: RunnerSession): Promise<void> {
|
|
234
|
-
await withRunnerSessionLock(session.deviceId, async () => {
|
|
235
|
-
await stopRunnerSessionInternal(session.deviceId, session);
|
|
236
|
-
});
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
async function stopRunnerSessionInternal(deviceId: string, sessionOverride?: RunnerSession): Promise<void> {
|
|
240
|
-
const session = sessionOverride ?? runnerSessions.get(deviceId);
|
|
241
|
-
if (!session) return;
|
|
242
|
-
try {
|
|
243
|
-
await waitForRunner(session.device, session.port, {
|
|
244
|
-
command: 'shutdown',
|
|
245
|
-
} as RunnerCommand, undefined, RUNNER_SHUTDOWN_TIMEOUT_MS);
|
|
246
|
-
} catch {
|
|
247
|
-
// Runner not responsive — send SIGTERM so we don't hang on testPromise
|
|
248
|
-
await killRunnerProcessTree(session.child.pid, 'SIGTERM');
|
|
249
|
-
}
|
|
250
|
-
try {
|
|
251
|
-
// Bound the wait so we never hang if xcodebuild refuses to exit
|
|
252
|
-
await Promise.race([
|
|
253
|
-
session.testPromise,
|
|
254
|
-
new Promise<void>((resolve) => setTimeout(resolve, RUNNER_STOP_WAIT_TIMEOUT_MS)),
|
|
255
|
-
]);
|
|
256
|
-
} catch {
|
|
257
|
-
// ignore
|
|
258
|
-
}
|
|
259
|
-
// Force-kill if still alive (harmless if already exited)
|
|
260
|
-
await killRunnerProcessTree(session.child.pid, 'SIGKILL');
|
|
261
|
-
cleanupTempFile(session.xctestrunPath);
|
|
262
|
-
cleanupTempFile(session.jsonPath);
|
|
263
|
-
if (runnerSessions.get(deviceId) === session) {
|
|
264
|
-
runnerSessions.delete(deviceId);
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
async function ensureBooted(udid: string): Promise<void> {
|
|
269
|
-
await runCmd('xcrun', ['simctl', 'bootstatus', udid, '-b'], {
|
|
270
|
-
allowFailure: true,
|
|
271
|
-
timeoutMs: RUNNER_STARTUP_TIMEOUT_MS,
|
|
272
|
-
});
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
async function ensureRunnerSession(
|
|
276
|
-
device: DeviceInfo,
|
|
277
|
-
options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
|
|
278
|
-
): Promise<RunnerSession> {
|
|
279
|
-
return await withRunnerSessionLock(device.id, async () => {
|
|
280
|
-
const existing = runnerSessions.get(device.id);
|
|
281
|
-
if (existing) {
|
|
282
|
-
if (isRunnerProcessAlive(existing.child.pid)) {
|
|
283
|
-
return existing;
|
|
284
|
-
}
|
|
285
|
-
await stopRunnerSessionInternal(device.id, existing);
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
await ensureBootedIfNeeded(device);
|
|
289
|
-
const xctestrun = await ensureXctestrun(device, options);
|
|
290
|
-
const port = await getFreePort();
|
|
291
|
-
const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv(
|
|
292
|
-
xctestrun,
|
|
293
|
-
{ AGENT_DEVICE_RUNNER_PORT: String(port) },
|
|
294
|
-
`session-${device.id}-${port}`,
|
|
295
|
-
);
|
|
296
|
-
const { child, wait: testPromise } = runCmdBackground(
|
|
297
|
-
'xcodebuild',
|
|
298
|
-
[
|
|
299
|
-
'test-without-building',
|
|
300
|
-
'-only-testing',
|
|
301
|
-
'AgentDeviceRunnerUITests/RunnerTests/testCommand',
|
|
302
|
-
'-parallel-testing-enabled',
|
|
303
|
-
'NO',
|
|
304
|
-
'-test-timeouts-enabled',
|
|
305
|
-
'NO',
|
|
306
|
-
resolveRunnerMaxConcurrentDestinationsFlag(device),
|
|
307
|
-
'1',
|
|
308
|
-
'-destination-timeout',
|
|
309
|
-
String(RUNNER_DESTINATION_TIMEOUT_SECONDS),
|
|
310
|
-
'-xctestrun',
|
|
311
|
-
xctestrunPath,
|
|
312
|
-
'-destination',
|
|
313
|
-
resolveRunnerDestination(device),
|
|
314
|
-
],
|
|
315
|
-
{
|
|
316
|
-
allowFailure: true,
|
|
317
|
-
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
|
|
318
|
-
},
|
|
319
|
-
);
|
|
320
|
-
child.stdout?.on('data', (chunk: string) => {
|
|
321
|
-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
322
|
-
});
|
|
323
|
-
child.stderr?.on('data', (chunk: string) => {
|
|
324
|
-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
325
|
-
});
|
|
326
|
-
|
|
327
|
-
const session: RunnerSession = {
|
|
328
|
-
device,
|
|
329
|
-
deviceId: device.id,
|
|
330
|
-
port,
|
|
331
|
-
xctestrunPath,
|
|
332
|
-
jsonPath,
|
|
333
|
-
testPromise,
|
|
334
|
-
child,
|
|
335
|
-
ready: false,
|
|
336
|
-
};
|
|
337
|
-
runnerSessions.set(device.id, session);
|
|
338
|
-
return session;
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
|
|
342
|
-
function isRunnerProcessAlive(pid: number | undefined): boolean {
|
|
343
|
-
if (!pid) return false;
|
|
344
|
-
return isProcessAlive(pid);
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
async function killRunnerProcessTree(
|
|
348
|
-
pid: number | undefined,
|
|
349
|
-
signal: 'SIGTERM' | 'SIGKILL',
|
|
350
|
-
): Promise<void> {
|
|
351
|
-
if (!pid || pid <= 0) return;
|
|
352
|
-
try {
|
|
353
|
-
process.kill(pid, signal);
|
|
354
|
-
} catch {
|
|
355
|
-
// ignore
|
|
356
|
-
}
|
|
357
|
-
const pkillSignal = signal === 'SIGTERM' ? 'TERM' : 'KILL';
|
|
358
|
-
try {
|
|
359
|
-
await runCmd('pkill', [`-${pkillSignal}`, '-P', String(pid)], { allowFailure: true });
|
|
360
|
-
} catch {
|
|
361
|
-
// ignore
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
async function ensureXctestrun(
|
|
367
|
-
device: DeviceInfo,
|
|
368
|
-
options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
|
|
369
|
-
): Promise<string> {
|
|
370
|
-
const derived = resolveRunnerDerivedPath(device.kind);
|
|
371
|
-
if (shouldCleanDerived()) {
|
|
372
|
-
assertSafeDerivedCleanup(derived);
|
|
373
|
-
try {
|
|
374
|
-
fs.rmSync(derived, { recursive: true, force: true });
|
|
375
|
-
} catch {
|
|
376
|
-
// ignore
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
const existing = findXctestrun(derived);
|
|
380
|
-
if (existing) return existing;
|
|
381
|
-
|
|
382
|
-
const projectRoot = findProjectRoot();
|
|
383
|
-
const projectPath = path.join(projectRoot, 'ios-runner', 'AgentDeviceRunner', 'AgentDeviceRunner.xcodeproj');
|
|
384
|
-
|
|
385
|
-
if (!fs.existsSync(projectPath)) {
|
|
386
|
-
throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath });
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
const signingBuildSettings = resolveRunnerSigningBuildSettings(process.env, device.kind === 'device');
|
|
390
|
-
const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : [];
|
|
391
|
-
try {
|
|
392
|
-
await runCmdStreaming(
|
|
393
|
-
'xcodebuild',
|
|
394
|
-
[
|
|
395
|
-
'build-for-testing',
|
|
396
|
-
'-project',
|
|
397
|
-
projectPath,
|
|
398
|
-
'-scheme',
|
|
399
|
-
'AgentDeviceRunner',
|
|
400
|
-
'-parallel-testing-enabled',
|
|
401
|
-
'NO',
|
|
402
|
-
resolveRunnerMaxConcurrentDestinationsFlag(device),
|
|
403
|
-
'1',
|
|
404
|
-
'-destination',
|
|
405
|
-
resolveRunnerBuildDestination(device),
|
|
406
|
-
'-derivedDataPath',
|
|
407
|
-
derived,
|
|
408
|
-
...provisioningArgs,
|
|
409
|
-
...signingBuildSettings,
|
|
410
|
-
],
|
|
411
|
-
{
|
|
412
|
-
onStdoutChunk: (chunk) => {
|
|
413
|
-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
414
|
-
},
|
|
415
|
-
onStderrChunk: (chunk) => {
|
|
416
|
-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
417
|
-
},
|
|
418
|
-
},
|
|
419
|
-
);
|
|
420
|
-
} catch (err) {
|
|
421
|
-
const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
|
|
422
|
-
const hint = resolveSigningFailureHint(appErr);
|
|
423
|
-
throw new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', {
|
|
424
|
-
error: appErr.message,
|
|
425
|
-
details: appErr.details,
|
|
426
|
-
logPath: options.logPath,
|
|
427
|
-
hint,
|
|
428
|
-
});
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
const built = findXctestrun(derived);
|
|
432
|
-
if (!built) {
|
|
433
|
-
throw new AppError('COMMAND_FAILED', 'Failed to locate .xctestrun after build');
|
|
434
|
-
}
|
|
435
|
-
return built;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
|
|
439
|
-
const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
|
|
440
|
-
if (override) {
|
|
441
|
-
return path.resolve(override);
|
|
442
|
-
}
|
|
443
|
-
if (kind === 'simulator') {
|
|
444
|
-
// Keep simulator runtime path aligned with pnpm build:xcuitest/build:all.
|
|
445
|
-
return path.join(RUNNER_DERIVED_ROOT, 'derived');
|
|
446
|
-
}
|
|
447
|
-
return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
export function resolveRunnerDestination(device: DeviceInfo): string {
|
|
451
|
-
if (device.platform !== 'ios') {
|
|
452
|
-
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
453
|
-
}
|
|
454
|
-
if (device.kind === 'simulator') {
|
|
455
|
-
return `platform=iOS Simulator,id=${device.id}`;
|
|
456
|
-
}
|
|
457
|
-
return `platform=iOS,id=${device.id}`;
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
export function resolveRunnerBuildDestination(device: DeviceInfo): string {
|
|
461
|
-
if (device.platform !== 'ios') {
|
|
462
|
-
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
463
|
-
}
|
|
464
|
-
if (device.kind === 'simulator') {
|
|
465
|
-
return `platform=iOS Simulator,id=${device.id}`;
|
|
466
|
-
}
|
|
467
|
-
return 'generic/platform=iOS';
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
function ensureBootedIfNeeded(device: DeviceInfo): Promise<void> {
|
|
471
|
-
if (device.kind !== 'simulator') {
|
|
472
|
-
return Promise.resolve();
|
|
473
|
-
}
|
|
474
|
-
return ensureBooted(device.id);
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
function validateRunnerDevice(device: DeviceInfo): void {
|
|
478
|
-
if (device.platform !== 'ios') {
|
|
479
|
-
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
480
|
-
}
|
|
481
|
-
if (device.kind !== 'simulator' && device.kind !== 'device') {
|
|
482
|
-
throw new AppError('UNSUPPORTED_OPERATION', `Unsupported iOS device kind for runner: ${device.kind}`);
|
|
483
|
-
}
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string {
|
|
487
|
-
return device.kind === 'device'
|
|
488
|
-
? '-maximum-concurrent-test-device-destinations'
|
|
489
|
-
: '-maximum-concurrent-test-simulator-destinations';
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
export function resolveRunnerSigningBuildSettings(
|
|
493
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
494
|
-
forDevice = false,
|
|
495
|
-
): string[] {
|
|
496
|
-
if (!forDevice) {
|
|
497
|
-
return [];
|
|
498
|
-
}
|
|
499
|
-
const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || '';
|
|
500
|
-
const configuredIdentity = env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || '';
|
|
501
|
-
const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || '';
|
|
502
|
-
const args = ['CODE_SIGN_STYLE=Automatic'];
|
|
503
|
-
if (teamId) {
|
|
504
|
-
args.push(`DEVELOPMENT_TEAM=${teamId}`);
|
|
505
|
-
}
|
|
506
|
-
if (configuredIdentity) {
|
|
507
|
-
args.push(`CODE_SIGN_IDENTITY=${configuredIdentity}`);
|
|
508
|
-
}
|
|
509
|
-
if (profile) args.push(`PROVISIONING_PROFILE_SPECIFIER=${profile}`);
|
|
510
|
-
return args;
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
function resolveSigningFailureHint(error: AppError): string | undefined {
|
|
514
|
-
const details = error.details ? JSON.stringify(error.details) : '';
|
|
515
|
-
const combined = `${error.message}\n${details}`.toLowerCase();
|
|
516
|
-
if (combined.includes('requires a development team')) {
|
|
517
|
-
return 'Configure signing in Xcode or set AGENT_DEVICE_IOS_TEAM_ID for physical-device runs.';
|
|
518
|
-
}
|
|
519
|
-
if (combined.includes('no profiles for') || combined.includes('provisioning profile')) {
|
|
520
|
-
return 'Install/select a valid iOS provisioning profile, or set AGENT_DEVICE_IOS_PROVISIONING_PROFILE.';
|
|
521
|
-
}
|
|
522
|
-
if (combined.includes('code signing')) {
|
|
523
|
-
return 'Enable Automatic Signing in Xcode or provide AGENT_DEVICE_IOS_TEAM_ID and optional AGENT_DEVICE_IOS_SIGNING_IDENTITY.';
|
|
524
|
-
}
|
|
525
|
-
return undefined;
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function findXctestrun(root: string): string | null {
|
|
529
|
-
if (!fs.existsSync(root)) return null;
|
|
530
|
-
const candidates: { path: string; mtimeMs: number }[] = [];
|
|
531
|
-
const stack: string[] = [root];
|
|
532
|
-
while (stack.length > 0) {
|
|
533
|
-
const current = stack.pop() as string;
|
|
534
|
-
const entries = fs.readdirSync(current, { withFileTypes: true });
|
|
535
|
-
for (const entry of entries) {
|
|
536
|
-
const full = path.join(current, entry.name);
|
|
537
|
-
if (entry.isDirectory()) {
|
|
538
|
-
stack.push(full);
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
if (entry.isFile() && entry.name.endsWith('.xctestrun')) {
|
|
542
|
-
try {
|
|
543
|
-
const stat = fs.statSync(full);
|
|
544
|
-
candidates.push({ path: full, mtimeMs: stat.mtimeMs });
|
|
545
|
-
} catch {
|
|
546
|
-
// ignore
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
if (candidates.length === 0) return null;
|
|
552
|
-
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
553
|
-
return candidates[0]?.path ?? null;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
function findProjectRoot(): string {
|
|
557
|
-
const start = path.dirname(fileURLToPath(import.meta.url));
|
|
558
|
-
let current = start;
|
|
559
|
-
for (let i = 0; i < 6; i += 1) {
|
|
560
|
-
const pkgPath = path.join(current, 'package.json');
|
|
561
|
-
if (fs.existsSync(pkgPath)) return current;
|
|
562
|
-
current = path.dirname(current);
|
|
563
|
-
}
|
|
564
|
-
return start;
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
function logChunk(chunk: string, logPath?: string, traceLogPath?: string, verbose?: boolean): void {
|
|
568
|
-
if (logPath) fs.appendFileSync(logPath, chunk);
|
|
569
|
-
if (traceLogPath) fs.appendFileSync(traceLogPath, chunk);
|
|
570
|
-
if (verbose) {
|
|
571
|
-
process.stderr.write(chunk);
|
|
572
|
-
}
|
|
573
|
-
}
|
|
574
|
-
|
|
575
|
-
export function isRetryableRunnerError(err: unknown): boolean {
|
|
576
|
-
if (!(err instanceof AppError)) return false;
|
|
577
|
-
if (err.code !== 'COMMAND_FAILED') return false;
|
|
578
|
-
const message = `${err.message ?? ''}`.toLowerCase();
|
|
579
|
-
if (message.includes('xcodebuild exited early')) return false;
|
|
580
|
-
if (message.includes('device is busy') && message.includes('connecting')) return false;
|
|
581
|
-
if (message.includes('runner did not accept connection')) return true;
|
|
582
|
-
if (message.includes('fetch failed')) return true;
|
|
583
|
-
if (message.includes('econnrefused')) return true;
|
|
584
|
-
if (message.includes('socket hang up')) return true;
|
|
585
|
-
return false;
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
|
|
589
|
-
return command === 'snapshot' || command === 'findText' || command === 'listTappables' || command === 'alert';
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
function shouldCleanDerived(): boolean {
|
|
593
|
-
return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
export function assertSafeDerivedCleanup(
|
|
597
|
-
derivedPath: string,
|
|
598
|
-
env: NodeJS.ProcessEnv = process.env,
|
|
599
|
-
): void {
|
|
600
|
-
const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
|
|
601
|
-
if (!override) {
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
if (isCleanupOverrideAllowed(env)) {
|
|
605
|
-
return;
|
|
606
|
-
}
|
|
607
|
-
throw new AppError(
|
|
608
|
-
'COMMAND_FAILED',
|
|
609
|
-
'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically',
|
|
610
|
-
{
|
|
611
|
-
derivedPath,
|
|
612
|
-
hint: 'Unset AGENT_DEVICE_IOS_CLEAN_DERIVED, or set AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1 if you trust this path.',
|
|
613
|
-
},
|
|
614
|
-
);
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function isCleanupOverrideAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
618
|
-
return isEnvTruthy(env.AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN);
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
function buildRunnerConnectError(params: {
|
|
622
|
-
port: number;
|
|
623
|
-
endpoints: string[];
|
|
624
|
-
logPath?: string;
|
|
625
|
-
lastError: unknown;
|
|
626
|
-
}): AppError {
|
|
627
|
-
const { port, endpoints, logPath, lastError } = params;
|
|
628
|
-
const message = 'Runner did not accept connection';
|
|
629
|
-
return new AppError('COMMAND_FAILED', message, {
|
|
630
|
-
port,
|
|
631
|
-
endpoints,
|
|
632
|
-
logPath,
|
|
633
|
-
lastError: lastError ? String(lastError) : undefined,
|
|
634
|
-
reason: classifyBootFailure({
|
|
635
|
-
error: lastError,
|
|
636
|
-
message,
|
|
637
|
-
context: { platform: 'ios', phase: 'connect' },
|
|
638
|
-
}),
|
|
639
|
-
hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
|
|
640
|
-
});
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
export function resolveRunnerEarlyExitHint(message: string, stdout: string, stderr: string): string {
|
|
644
|
-
const haystack = `${message}\n${stdout}\n${stderr}`.toLowerCase();
|
|
645
|
-
if (haystack.includes('device is busy') && haystack.includes('connecting')) {
|
|
646
|
-
return 'Target iOS device is still connecting. Keep it unlocked, wait for device trust/connection to settle, then retry.';
|
|
647
|
-
}
|
|
648
|
-
return bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
|
|
649
|
-
}
|
|
650
|
-
|
|
651
|
-
export function shouldRetryRunnerConnectError(error: unknown): boolean {
|
|
652
|
-
if (!(error instanceof AppError)) return true;
|
|
653
|
-
if (error.code !== 'COMMAND_FAILED') return true;
|
|
654
|
-
const message = String(error.message ?? '').toLowerCase();
|
|
655
|
-
if (message.includes('xcodebuild exited early')) return false;
|
|
656
|
-
return true;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
async function buildRunnerEarlyExitError(params: {
|
|
660
|
-
session: RunnerSession;
|
|
661
|
-
port: number;
|
|
662
|
-
logPath?: string;
|
|
663
|
-
}): Promise<AppError> {
|
|
664
|
-
const { session, port, logPath } = params;
|
|
665
|
-
const result = await session.testPromise;
|
|
666
|
-
const message = 'Runner did not accept connection (xcodebuild exited early)';
|
|
667
|
-
const reason = classifyBootFailure({
|
|
668
|
-
message,
|
|
669
|
-
stdout: result.stdout,
|
|
670
|
-
stderr: result.stderr,
|
|
671
|
-
context: { platform: 'ios', phase: 'connect' },
|
|
672
|
-
});
|
|
673
|
-
return new AppError('COMMAND_FAILED', message, {
|
|
674
|
-
port,
|
|
675
|
-
logPath,
|
|
676
|
-
xcodebuild: {
|
|
677
|
-
exitCode: result.exitCode,
|
|
678
|
-
stdout: result.stdout,
|
|
679
|
-
stderr: result.stderr,
|
|
680
|
-
},
|
|
681
|
-
reason,
|
|
682
|
-
hint: resolveRunnerEarlyExitHint(message, result.stdout, result.stderr),
|
|
683
|
-
});
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
async function waitForRunner(
|
|
687
|
-
device: DeviceInfo,
|
|
688
|
-
port: number,
|
|
689
|
-
command: RunnerCommand,
|
|
690
|
-
logPath?: string,
|
|
691
|
-
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
|
|
692
|
-
session?: RunnerSession,
|
|
693
|
-
): Promise<Response> {
|
|
694
|
-
const deadline = Deadline.fromTimeoutMs(timeoutMs);
|
|
695
|
-
let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
|
|
696
|
-
let lastError: unknown = null;
|
|
697
|
-
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS));
|
|
698
|
-
try {
|
|
699
|
-
return await retryWithPolicy(
|
|
700
|
-
async ({ deadline: attemptDeadline }) => {
|
|
701
|
-
if (attemptDeadline?.isExpired()) {
|
|
702
|
-
throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
|
|
703
|
-
port,
|
|
704
|
-
timeoutMs,
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
if (session && session.child.exitCode !== null && session.child.exitCode !== undefined) {
|
|
708
|
-
throw await buildRunnerEarlyExitError({ session, port, logPath });
|
|
709
|
-
}
|
|
710
|
-
if (device.kind === 'device') {
|
|
711
|
-
endpoints = await resolveRunnerCommandEndpoints(device, port, attemptDeadline?.remainingMs());
|
|
712
|
-
}
|
|
713
|
-
for (const endpoint of endpoints) {
|
|
714
|
-
try {
|
|
715
|
-
const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs;
|
|
716
|
-
if (remainingMs <= 0) {
|
|
717
|
-
throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
|
|
718
|
-
port,
|
|
719
|
-
timeoutMs,
|
|
720
|
-
});
|
|
721
|
-
}
|
|
722
|
-
const response = await fetchWithTimeout(
|
|
723
|
-
endpoint,
|
|
724
|
-
{
|
|
725
|
-
method: 'POST',
|
|
726
|
-
headers: { 'Content-Type': 'application/json' },
|
|
727
|
-
body: JSON.stringify(command),
|
|
728
|
-
},
|
|
729
|
-
Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs),
|
|
730
|
-
);
|
|
731
|
-
return response;
|
|
732
|
-
} catch (err) {
|
|
733
|
-
lastError = err;
|
|
734
|
-
}
|
|
735
|
-
}
|
|
736
|
-
throw new AppError('COMMAND_FAILED', 'Runner endpoint probe failed', {
|
|
737
|
-
port,
|
|
738
|
-
endpoints,
|
|
739
|
-
lastError: lastError ? String(lastError) : undefined,
|
|
740
|
-
});
|
|
741
|
-
},
|
|
742
|
-
{
|
|
743
|
-
maxAttempts,
|
|
744
|
-
baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
|
|
745
|
-
maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
|
|
746
|
-
jitter: 0.2,
|
|
747
|
-
shouldRetry: shouldRetryRunnerConnectError,
|
|
748
|
-
},
|
|
749
|
-
{ deadline, phase: 'ios_runner_connect' },
|
|
750
|
-
);
|
|
751
|
-
} catch (error) {
|
|
752
|
-
if (!lastError) {
|
|
753
|
-
lastError = error;
|
|
754
|
-
}
|
|
755
|
-
}
|
|
756
|
-
|
|
757
|
-
if (device.kind === 'simulator') {
|
|
758
|
-
const remainingMs = deadline.remainingMs();
|
|
759
|
-
if (remainingMs <= 0) {
|
|
760
|
-
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
|
|
761
|
-
}
|
|
762
|
-
const simResponse = await postCommandViaSimulator(device.id, port, command, remainingMs);
|
|
763
|
-
return new Response(simResponse.body, { status: simResponse.status });
|
|
764
|
-
}
|
|
765
|
-
|
|
766
|
-
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
async function resolveRunnerCommandEndpoints(
|
|
770
|
-
device: DeviceInfo,
|
|
771
|
-
port: number,
|
|
772
|
-
timeoutBudgetMs?: number,
|
|
773
|
-
): Promise<string[]> {
|
|
774
|
-
const endpoints = [`http://127.0.0.1:${port}/command`];
|
|
775
|
-
if (device.kind !== 'device') {
|
|
776
|
-
return endpoints;
|
|
777
|
-
}
|
|
778
|
-
const tunnelIp = await resolveDeviceTunnelIp(device.id, timeoutBudgetMs);
|
|
779
|
-
if (tunnelIp) {
|
|
780
|
-
endpoints.unshift(`http://[${tunnelIp}]:${port}/command`);
|
|
781
|
-
}
|
|
782
|
-
return endpoints;
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
async function fetchWithTimeout(
|
|
786
|
-
url: string,
|
|
787
|
-
init: RequestInit,
|
|
788
|
-
timeoutMs: number,
|
|
789
|
-
): Promise<Response> {
|
|
790
|
-
const controller = new AbortController();
|
|
791
|
-
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
792
|
-
try {
|
|
793
|
-
return await fetch(url, { ...init, signal: controller.signal });
|
|
794
|
-
} finally {
|
|
795
|
-
clearTimeout(timeout);
|
|
796
|
-
}
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
async function resolveDeviceTunnelIp(deviceId: string, timeoutBudgetMs?: number): Promise<string | null> {
|
|
800
|
-
if (typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0) {
|
|
801
|
-
return null;
|
|
802
|
-
}
|
|
803
|
-
const timeoutMs = typeof timeoutBudgetMs === 'number'
|
|
804
|
-
? Math.max(1, Math.min(RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs))
|
|
805
|
-
: RUNNER_DEVICE_INFO_TIMEOUT_MS;
|
|
806
|
-
const jsonPath = path.join(
|
|
807
|
-
os.tmpdir(),
|
|
808
|
-
`agent-device-devicectl-info-${process.pid}-${Date.now()}.json`,
|
|
809
|
-
);
|
|
810
|
-
try {
|
|
811
|
-
const devicectlTimeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
812
|
-
const result = await runCmd(
|
|
813
|
-
'xcrun',
|
|
814
|
-
[
|
|
815
|
-
'devicectl',
|
|
816
|
-
'device',
|
|
817
|
-
'info',
|
|
818
|
-
'details',
|
|
819
|
-
'--device',
|
|
820
|
-
deviceId,
|
|
821
|
-
'--json-output',
|
|
822
|
-
jsonPath,
|
|
823
|
-
'--timeout',
|
|
824
|
-
String(devicectlTimeoutSeconds),
|
|
825
|
-
],
|
|
826
|
-
{ allowFailure: true, timeoutMs },
|
|
827
|
-
);
|
|
828
|
-
if (result.exitCode !== 0 || !fs.existsSync(jsonPath)) {
|
|
829
|
-
return null;
|
|
830
|
-
}
|
|
831
|
-
const payload = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as {
|
|
832
|
-
info?: { outcome?: string };
|
|
833
|
-
result?: {
|
|
834
|
-
connectionProperties?: { tunnelIPAddress?: string };
|
|
835
|
-
device?: { connectionProperties?: { tunnelIPAddress?: string } };
|
|
836
|
-
};
|
|
837
|
-
};
|
|
838
|
-
if (payload.info?.outcome && payload.info.outcome !== 'success') {
|
|
839
|
-
return null;
|
|
840
|
-
}
|
|
841
|
-
const ip = (
|
|
842
|
-
payload.result?.connectionProperties?.tunnelIPAddress
|
|
843
|
-
?? payload.result?.device?.connectionProperties?.tunnelIPAddress
|
|
844
|
-
)?.trim();
|
|
845
|
-
return ip && ip.length > 0 ? ip : null;
|
|
846
|
-
} catch {
|
|
847
|
-
return null;
|
|
848
|
-
} finally {
|
|
849
|
-
cleanupTempFile(jsonPath);
|
|
850
|
-
}
|
|
851
|
-
}
|
|
852
|
-
|
|
853
|
-
async function postCommandViaSimulator(
|
|
854
|
-
udid: string,
|
|
855
|
-
port: number,
|
|
856
|
-
command: RunnerCommand,
|
|
857
|
-
timeoutMs: number,
|
|
858
|
-
): Promise<{ status: number; body: string }> {
|
|
859
|
-
const payload = JSON.stringify(command);
|
|
860
|
-
const result = await runCmd(
|
|
861
|
-
'xcrun',
|
|
862
|
-
[
|
|
863
|
-
'simctl',
|
|
864
|
-
'spawn',
|
|
865
|
-
udid,
|
|
866
|
-
'/usr/bin/curl',
|
|
867
|
-
'-s',
|
|
868
|
-
'-X',
|
|
869
|
-
'POST',
|
|
870
|
-
'-H',
|
|
871
|
-
'Content-Type: application/json',
|
|
872
|
-
'--data',
|
|
873
|
-
payload,
|
|
874
|
-
`http://127.0.0.1:${port}/command`,
|
|
875
|
-
],
|
|
876
|
-
{ allowFailure: true, timeoutMs },
|
|
877
|
-
);
|
|
878
|
-
const body = result.stdout as string;
|
|
879
|
-
if (result.exitCode !== 0) {
|
|
880
|
-
const reason = classifyBootFailure({
|
|
881
|
-
message: 'Runner did not accept connection (simctl spawn)',
|
|
882
|
-
stdout: result.stdout,
|
|
883
|
-
stderr: result.stderr,
|
|
884
|
-
context: { platform: 'ios', phase: 'connect' },
|
|
885
|
-
});
|
|
886
|
-
throw new AppError('COMMAND_FAILED', 'Runner did not accept connection (simctl spawn)', {
|
|
887
|
-
port,
|
|
888
|
-
stdout: result.stdout,
|
|
889
|
-
stderr: result.stderr,
|
|
890
|
-
exitCode: result.exitCode,
|
|
891
|
-
reason,
|
|
892
|
-
hint: bootFailureHint(reason),
|
|
893
|
-
});
|
|
894
|
-
}
|
|
895
|
-
return { status: 200, body };
|
|
896
|
-
}
|
|
897
|
-
|
|
898
|
-
async function getFreePort(): Promise<number> {
|
|
899
|
-
return await new Promise((resolve, reject) => {
|
|
900
|
-
const server = net.createServer();
|
|
901
|
-
server.listen(0, '127.0.0.1', () => {
|
|
902
|
-
const address = server.address();
|
|
903
|
-
server.close();
|
|
904
|
-
if (typeof address === 'object' && address?.port) {
|
|
905
|
-
resolve(address.port);
|
|
906
|
-
} else {
|
|
907
|
-
reject(new AppError('COMMAND_FAILED', 'Failed to allocate port'));
|
|
908
|
-
}
|
|
909
|
-
});
|
|
910
|
-
server.on('error', reject);
|
|
911
|
-
});
|
|
912
|
-
}
|
|
913
|
-
|
|
914
|
-
async function prepareXctestrunWithEnv(
|
|
915
|
-
xctestrunPath: string,
|
|
916
|
-
envVars: Record<string, string>,
|
|
917
|
-
suffix: string,
|
|
918
|
-
): Promise<{ xctestrunPath: string; jsonPath: string }> {
|
|
919
|
-
const dir = path.dirname(xctestrunPath);
|
|
920
|
-
const safeSuffix = suffix.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
921
|
-
const tmpJsonPath = path.join(dir, `AgentDeviceRunner.env.${safeSuffix}.json`);
|
|
922
|
-
const tmpXctestrunPath = path.join(dir, `AgentDeviceRunner.env.${safeSuffix}.xctestrun`);
|
|
923
|
-
|
|
924
|
-
const jsonResult = await runCmd('plutil', ['-convert', 'json', '-o', '-', xctestrunPath], {
|
|
925
|
-
allowFailure: true,
|
|
926
|
-
});
|
|
927
|
-
if (jsonResult.exitCode !== 0 || !jsonResult.stdout.trim()) {
|
|
928
|
-
throw new AppError('COMMAND_FAILED', 'Failed to read xctestrun plist', {
|
|
929
|
-
xctestrunPath,
|
|
930
|
-
stderr: jsonResult.stderr,
|
|
931
|
-
});
|
|
932
|
-
}
|
|
933
|
-
|
|
934
|
-
let parsed: Record<string, any>;
|
|
935
|
-
try {
|
|
936
|
-
parsed = JSON.parse(jsonResult.stdout) as Record<string, any>;
|
|
937
|
-
} catch (err) {
|
|
938
|
-
throw new AppError('COMMAND_FAILED', 'Failed to parse xctestrun JSON', {
|
|
939
|
-
xctestrunPath,
|
|
940
|
-
error: String(err),
|
|
941
|
-
});
|
|
942
|
-
}
|
|
943
|
-
|
|
944
|
-
const applyEnvToTarget = (target: Record<string, any>) => {
|
|
945
|
-
target.EnvironmentVariables = { ...(target.EnvironmentVariables ?? {}), ...envVars };
|
|
946
|
-
target.UITestEnvironmentVariables = { ...(target.UITestEnvironmentVariables ?? {}), ...envVars };
|
|
947
|
-
target.UITargetAppEnvironmentVariables = {
|
|
948
|
-
...(target.UITargetAppEnvironmentVariables ?? {}),
|
|
949
|
-
...envVars,
|
|
950
|
-
};
|
|
951
|
-
target.TestingEnvironmentVariables = { ...(target.TestingEnvironmentVariables ?? {}), ...envVars };
|
|
952
|
-
};
|
|
953
|
-
|
|
954
|
-
const configs = parsed.TestConfigurations;
|
|
955
|
-
if (Array.isArray(configs)) {
|
|
956
|
-
for (const config of configs) {
|
|
957
|
-
if (!config || typeof config !== 'object') continue;
|
|
958
|
-
const targets = config.TestTargets;
|
|
959
|
-
if (!Array.isArray(targets)) continue;
|
|
960
|
-
for (const target of targets) {
|
|
961
|
-
if (!target || typeof target !== 'object') continue;
|
|
962
|
-
applyEnvToTarget(target);
|
|
963
|
-
}
|
|
964
|
-
}
|
|
965
|
-
}
|
|
966
|
-
|
|
967
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
968
|
-
if (value && typeof value === 'object' && value.TestBundlePath) {
|
|
969
|
-
applyEnvToTarget(value);
|
|
970
|
-
parsed[key] = value;
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
|
|
974
|
-
fs.writeFileSync(tmpJsonPath, JSON.stringify(parsed, null, 2));
|
|
975
|
-
const plistResult = await runCmd('plutil', ['-convert', 'xml1', '-o', tmpXctestrunPath, tmpJsonPath], {
|
|
976
|
-
allowFailure: true,
|
|
977
|
-
});
|
|
978
|
-
if (plistResult.exitCode !== 0) {
|
|
979
|
-
throw new AppError('COMMAND_FAILED', 'Failed to write xctestrun plist', {
|
|
980
|
-
tmpXctestrunPath,
|
|
981
|
-
stderr: plistResult.stderr,
|
|
982
|
-
});
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
return { xctestrunPath: tmpXctestrunPath, jsonPath: tmpJsonPath };
|
|
986
|
-
}
|
|
987
|
-
|
|
988
|
-
function cleanupTempFile(filePath: string): void {
|
|
989
|
-
try {
|
|
990
|
-
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
|
991
|
-
} catch {
|
|
992
|
-
// ignore
|
|
993
|
-
}
|
|
994
|
-
}
|