agent-device 0.4.2 → 0.5.1

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