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.
Files changed (91) hide show
  1. package/README.md +2 -9
  2. package/dist/src/797.js +1 -1
  3. package/dist/src/bin.js +5 -5
  4. package/dist/src/daemon.js +16 -20
  5. package/package.json +7 -9
  6. package/skills/agent-device/SKILL.md +3 -6
  7. package/skills/agent-device/references/permissions.md +3 -15
  8. package/skills/agent-device/references/snapshot-refs.md +1 -4
  9. package/dist/bin/axsnapshot +0 -0
  10. package/ios-runner/AXSnapshot/Package.swift +0 -18
  11. package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
  12. package/src/__tests__/cli-close.test.ts +0 -155
  13. package/src/__tests__/cli-help.test.ts +0 -102
  14. package/src/bin.ts +0 -3
  15. package/src/cli.ts +0 -305
  16. package/src/core/__tests__/capabilities.test.ts +0 -75
  17. package/src/core/__tests__/dispatch-open.test.ts +0 -25
  18. package/src/core/__tests__/open-target.test.ts +0 -55
  19. package/src/core/capabilities.ts +0 -57
  20. package/src/core/dispatch.ts +0 -382
  21. package/src/core/open-target.ts +0 -27
  22. package/src/daemon/__tests__/device-ready.test.ts +0 -52
  23. package/src/daemon/__tests__/is-predicates.test.ts +0 -68
  24. package/src/daemon/__tests__/selectors.test.ts +0 -261
  25. package/src/daemon/__tests__/session-routing.test.ts +0 -108
  26. package/src/daemon/__tests__/session-selector.test.ts +0 -64
  27. package/src/daemon/__tests__/session-store.test.ts +0 -142
  28. package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
  29. package/src/daemon/action-utils.ts +0 -29
  30. package/src/daemon/context.ts +0 -48
  31. package/src/daemon/device-ready.ts +0 -155
  32. package/src/daemon/handlers/__tests__/find.test.ts +0 -99
  33. package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
  34. package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
  35. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
  36. package/src/daemon/handlers/__tests__/session.test.ts +0 -820
  37. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
  38. package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
  39. package/src/daemon/handlers/find.ts +0 -324
  40. package/src/daemon/handlers/interaction.ts +0 -550
  41. package/src/daemon/handlers/parse-utils.ts +0 -8
  42. package/src/daemon/handlers/record-trace.ts +0 -154
  43. package/src/daemon/handlers/session.ts +0 -1137
  44. package/src/daemon/handlers/snapshot.ts +0 -439
  45. package/src/daemon/is-predicates.ts +0 -46
  46. package/src/daemon/selectors.ts +0 -540
  47. package/src/daemon/session-routing.ts +0 -22
  48. package/src/daemon/session-selector.ts +0 -39
  49. package/src/daemon/session-store.ts +0 -296
  50. package/src/daemon/snapshot-processing.ts +0 -131
  51. package/src/daemon/types.ts +0 -56
  52. package/src/daemon-client.ts +0 -272
  53. package/src/daemon.ts +0 -295
  54. package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
  55. package/src/platforms/android/__tests__/index.test.ts +0 -274
  56. package/src/platforms/android/devices.ts +0 -196
  57. package/src/platforms/android/index.ts +0 -784
  58. package/src/platforms/android/ui-hierarchy.ts +0 -312
  59. package/src/platforms/boot-diagnostics.ts +0 -128
  60. package/src/platforms/ios/__tests__/index.test.ts +0 -312
  61. package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
  62. package/src/platforms/ios/apps.ts +0 -358
  63. package/src/platforms/ios/ax-snapshot.ts +0 -207
  64. package/src/platforms/ios/config.ts +0 -28
  65. package/src/platforms/ios/devicectl.ts +0 -134
  66. package/src/platforms/ios/devices.ts +0 -100
  67. package/src/platforms/ios/index.ts +0 -20
  68. package/src/platforms/ios/runner-client.ts +0 -994
  69. package/src/platforms/ios/simulator.ts +0 -164
  70. package/src/utils/__tests__/args.test.ts +0 -239
  71. package/src/utils/__tests__/daemon-client.test.ts +0 -95
  72. package/src/utils/__tests__/exec.test.ts +0 -16
  73. package/src/utils/__tests__/finders.test.ts +0 -34
  74. package/src/utils/__tests__/keyed-lock.test.ts +0 -55
  75. package/src/utils/__tests__/process-identity.test.ts +0 -33
  76. package/src/utils/__tests__/retry.test.ts +0 -44
  77. package/src/utils/args.ts +0 -239
  78. package/src/utils/command-schema.ts +0 -622
  79. package/src/utils/device.ts +0 -84
  80. package/src/utils/errors.ts +0 -35
  81. package/src/utils/exec.ts +0 -339
  82. package/src/utils/finders.ts +0 -101
  83. package/src/utils/interactive.ts +0 -4
  84. package/src/utils/interactors.ts +0 -173
  85. package/src/utils/keyed-lock.ts +0 -14
  86. package/src/utils/output.ts +0 -204
  87. package/src/utils/process-identity.ts +0 -100
  88. package/src/utils/retry.ts +0 -180
  89. package/src/utils/snapshot.ts +0 -64
  90. package/src/utils/timeouts.ts +0 -9
  91. 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
- }