agent-device 0.3.4 → 0.4.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 (42) hide show
  1. package/README.md +58 -16
  2. package/dist/src/bin.js +35 -96
  3. package/dist/src/daemon.js +16 -15
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
  5. package/ios-runner/README.md +1 -1
  6. package/package.json +1 -1
  7. package/skills/agent-device/SKILL.md +32 -14
  8. package/skills/agent-device/references/permissions.md +15 -1
  9. package/skills/agent-device/references/session-management.md +2 -0
  10. package/skills/agent-device/references/snapshot-refs.md +2 -0
  11. package/skills/agent-device/references/video-recording.md +2 -0
  12. package/src/cli.ts +7 -3
  13. package/src/core/__tests__/capabilities.test.ts +11 -6
  14. package/src/core/__tests__/open-target.test.ts +16 -0
  15. package/src/core/capabilities.ts +26 -20
  16. package/src/core/dispatch.ts +110 -31
  17. package/src/core/open-target.ts +13 -0
  18. package/src/daemon/__tests__/app-state.test.ts +138 -0
  19. package/src/daemon/__tests__/session-store.test.ts +24 -0
  20. package/src/daemon/app-state.ts +37 -38
  21. package/src/daemon/context.ts +12 -0
  22. package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
  23. package/src/daemon/handlers/__tests__/session.test.ts +226 -5
  24. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
  25. package/src/daemon/handlers/interaction.ts +37 -0
  26. package/src/daemon/handlers/record-trace.ts +1 -1
  27. package/src/daemon/handlers/session.ts +96 -26
  28. package/src/daemon/handlers/snapshot.ts +21 -3
  29. package/src/daemon/session-store.ts +11 -0
  30. package/src/daemon-client.ts +14 -6
  31. package/src/daemon.ts +1 -1
  32. package/src/platforms/android/__tests__/index.test.ts +67 -1
  33. package/src/platforms/android/index.ts +41 -0
  34. package/src/platforms/ios/__tests__/index.test.ts +24 -0
  35. package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
  36. package/src/platforms/ios/devices.ts +40 -18
  37. package/src/platforms/ios/index.ts +70 -5
  38. package/src/platforms/ios/runner-client.ts +329 -42
  39. package/src/utils/__tests__/args.test.ts +175 -0
  40. package/src/utils/args.ts +174 -212
  41. package/src/utils/command-schema.ts +591 -0
  42. package/src/utils/interactors.ts +13 -3
@@ -4,7 +4,7 @@ import path from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { AppError } from '../../utils/errors.ts';
6
6
  import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
7
- import { withRetry } from '../../utils/retry.ts';
7
+ import { Deadline, isEnvTruthy, retryWithPolicy, withRetry } from '../../utils/retry.ts';
8
8
  import type { DeviceInfo } from '../../utils/device.ts';
9
9
  import net from 'node:net';
10
10
  import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
@@ -13,6 +13,7 @@ export type RunnerCommand = {
13
13
  command:
14
14
  | 'tap'
15
15
  | 'longPress'
16
+ | 'drag'
16
17
  | 'type'
17
18
  | 'swipe'
18
19
  | 'findText'
@@ -29,6 +30,8 @@ export type RunnerCommand = {
29
30
  action?: 'get' | 'accept' | 'dismiss';
30
31
  x?: number;
31
32
  y?: number;
33
+ x2?: number;
34
+ y2?: number;
32
35
  durationMs?: number;
33
36
  direction?: 'up' | 'down' | 'left' | 'right';
34
37
  scale?: number;
@@ -62,8 +65,34 @@ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
62
65
  15_000,
63
66
  1_000,
64
67
  );
68
+ const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
69
+ process.env.AGENT_DEVICE_RUNNER_CONNECT_ATTEMPT_INTERVAL_MS,
70
+ 250,
71
+ 50,
72
+ );
73
+ const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs(
74
+ process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
75
+ 100,
76
+ 10,
77
+ );
78
+ const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
79
+ process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
80
+ 500,
81
+ 10,
82
+ );
83
+ const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
84
+ process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
85
+ 1_000,
86
+ 50,
87
+ );
88
+ const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
89
+ process.env.AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS,
90
+ 10_000,
91
+ 500,
92
+ );
65
93
  const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
66
94
  const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
95
+ const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner');
67
96
 
68
97
  function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
69
98
  if (!raw) return fallback;
@@ -89,6 +118,7 @@ export async function runIosRunnerCommand(
89
118
  command: RunnerCommand,
90
119
  options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
91
120
  ): Promise<Record<string, unknown>> {
121
+ validateRunnerDevice(device);
92
122
  if (isReadOnlyRunnerCommand(command.command)) {
93
123
  return withRetry(
94
124
  () => executeRunnerCommand(device, command, options),
@@ -103,10 +133,6 @@ async function executeRunnerCommand(
103
133
  command: RunnerCommand,
104
134
  options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
105
135
  ): Promise<Record<string, unknown>> {
106
- if (device.kind !== 'simulator') {
107
- throw new AppError('UNSUPPORTED_OPERATION', 'iOS runner only supports simulators in v1');
108
- }
109
-
110
136
  try {
111
137
  const session = await ensureRunnerSession(device, options);
112
138
  const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
@@ -218,8 +244,8 @@ async function ensureRunnerSession(
218
244
  const existing = runnerSessions.get(device.id);
219
245
  if (existing) return existing;
220
246
 
221
- await ensureBooted(device.id);
222
- const xctestrun = await ensureXctestrun(device.id, options);
247
+ await ensureBootedIfNeeded(device);
248
+ const xctestrun = await ensureXctestrun(device, options);
223
249
  const port = await getFreePort();
224
250
  const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv(
225
251
  xctestrun,
@@ -236,12 +262,12 @@ async function ensureRunnerSession(
236
262
  'NO',
237
263
  '-test-timeouts-enabled',
238
264
  'NO',
239
- '-maximum-concurrent-test-simulator-destinations',
265
+ resolveRunnerMaxConcurrentDestinationsFlag(device),
240
266
  '1',
241
267
  '-xctestrun',
242
268
  xctestrunPath,
243
269
  '-destination',
244
- `platform=iOS Simulator,id=${device.id}`,
270
+ resolveRunnerDestination(device),
245
271
  ],
246
272
  {
247
273
  allowFailure: true,
@@ -289,12 +315,12 @@ async function killRunnerProcessTree(
289
315
 
290
316
 
291
317
  async function ensureXctestrun(
292
- udid: string,
318
+ device: DeviceInfo,
293
319
  options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
294
320
  ): Promise<string> {
295
- const base = path.join(os.homedir(), '.agent-device', 'ios-runner');
296
- const derived = path.join(base, 'derived');
321
+ const derived = resolveRunnerDerivedPath(device.kind);
297
322
  if (shouldCleanDerived()) {
323
+ assertSafeDerivedCleanup(derived);
298
324
  try {
299
325
  fs.rmSync(derived, { recursive: true, force: true });
300
326
  } catch {
@@ -311,6 +337,8 @@ async function ensureXctestrun(
311
337
  throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath });
312
338
  }
313
339
 
340
+ const signingBuildSettings = resolveRunnerSigningBuildSettings(process.env, device.kind === 'device');
341
+ const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : [];
314
342
  try {
315
343
  await runCmdStreaming(
316
344
  'xcodebuild',
@@ -322,12 +350,14 @@ async function ensureXctestrun(
322
350
  'AgentDeviceRunner',
323
351
  '-parallel-testing-enabled',
324
352
  'NO',
325
- '-maximum-concurrent-test-simulator-destinations',
353
+ resolveRunnerMaxConcurrentDestinationsFlag(device),
326
354
  '1',
327
355
  '-destination',
328
- `platform=iOS Simulator,id=${udid}`,
356
+ resolveRunnerBuildDestination(device),
329
357
  '-derivedDataPath',
330
358
  derived,
359
+ ...provisioningArgs,
360
+ ...signingBuildSettings,
331
361
  ],
332
362
  {
333
363
  onStdoutChunk: (chunk) => {
@@ -340,10 +370,12 @@ async function ensureXctestrun(
340
370
  );
341
371
  } catch (err) {
342
372
  const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
373
+ const hint = resolveSigningFailureHint(appErr);
343
374
  throw new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', {
344
375
  error: appErr.message,
345
376
  details: appErr.details,
346
377
  logPath: options.logPath,
378
+ hint,
347
379
  });
348
380
  }
349
381
 
@@ -354,6 +386,92 @@ async function ensureXctestrun(
354
386
  return built;
355
387
  }
356
388
 
389
+ function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
390
+ const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
391
+ if (override) {
392
+ return path.resolve(override);
393
+ }
394
+ return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
395
+ }
396
+
397
+ export function resolveRunnerDestination(device: DeviceInfo): string {
398
+ if (device.platform !== 'ios') {
399
+ throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
400
+ }
401
+ if (device.kind === 'simulator') {
402
+ return `platform=iOS Simulator,id=${device.id}`;
403
+ }
404
+ return `platform=iOS,id=${device.id}`;
405
+ }
406
+
407
+ export function resolveRunnerBuildDestination(device: DeviceInfo): string {
408
+ if (device.platform !== 'ios') {
409
+ throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
410
+ }
411
+ if (device.kind === 'simulator') {
412
+ return `platform=iOS Simulator,id=${device.id}`;
413
+ }
414
+ return 'generic/platform=iOS';
415
+ }
416
+
417
+ function ensureBootedIfNeeded(device: DeviceInfo): Promise<void> {
418
+ if (device.kind !== 'simulator') {
419
+ return Promise.resolve();
420
+ }
421
+ return ensureBooted(device.id);
422
+ }
423
+
424
+ function validateRunnerDevice(device: DeviceInfo): void {
425
+ if (device.platform !== 'ios') {
426
+ throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
427
+ }
428
+ if (device.kind !== 'simulator' && device.kind !== 'device') {
429
+ throw new AppError('UNSUPPORTED_OPERATION', `Unsupported iOS device kind for runner: ${device.kind}`);
430
+ }
431
+ }
432
+
433
+ export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string {
434
+ return device.kind === 'device'
435
+ ? '-maximum-concurrent-test-device-destinations'
436
+ : '-maximum-concurrent-test-simulator-destinations';
437
+ }
438
+
439
+ export function resolveRunnerSigningBuildSettings(
440
+ env: NodeJS.ProcessEnv = process.env,
441
+ forDevice = false,
442
+ ): string[] {
443
+ if (!forDevice) {
444
+ return [];
445
+ }
446
+ const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || '';
447
+ const configuredIdentity = env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || '';
448
+ const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || '';
449
+ const args = ['CODE_SIGN_STYLE=Automatic'];
450
+ if (teamId) {
451
+ args.push(`DEVELOPMENT_TEAM=${teamId}`);
452
+ }
453
+ if (configuredIdentity) {
454
+ args.push(`CODE_SIGN_IDENTITY=${configuredIdentity}`);
455
+ }
456
+ if (profile) args.push(`PROVISIONING_PROFILE_SPECIFIER=${profile}`);
457
+ return args;
458
+ }
459
+
460
+ function resolveSigningFailureHint(error: AppError): string | undefined {
461
+ const details = error.details ? JSON.stringify(error.details) : '';
462
+ const combined = `${error.message}\n${details}`.toLowerCase();
463
+ if (combined.includes('requires a development team')) {
464
+ return 'Configure signing in Xcode or set AGENT_DEVICE_IOS_TEAM_ID for physical-device runs.';
465
+ }
466
+ if (combined.includes('no profiles for') || combined.includes('provisioning profile')) {
467
+ return 'Install/select a valid iOS provisioning profile, or set AGENT_DEVICE_IOS_PROVISIONING_PROFILE.';
468
+ }
469
+ if (combined.includes('code signing')) {
470
+ return 'Enable Automatic Signing in Xcode or provide AGENT_DEVICE_IOS_TEAM_ID and optional AGENT_DEVICE_IOS_SIGNING_IDENTITY.';
471
+ }
472
+ return undefined;
473
+ }
474
+
357
475
  function findXctestrun(root: string): string | null {
358
476
  if (!fs.existsSync(root)) return null;
359
477
  const candidates: { path: string; mtimeMs: number }[] = [];
@@ -417,9 +535,54 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
417
535
  }
418
536
 
419
537
  function shouldCleanDerived(): boolean {
420
- const value = process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED;
421
- if (!value) return false;
422
- return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase());
538
+ return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
539
+ }
540
+
541
+ export function assertSafeDerivedCleanup(
542
+ derivedPath: string,
543
+ env: NodeJS.ProcessEnv = process.env,
544
+ ): void {
545
+ const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
546
+ if (!override) {
547
+ return;
548
+ }
549
+ if (isCleanupOverrideAllowed(env)) {
550
+ return;
551
+ }
552
+ throw new AppError(
553
+ 'COMMAND_FAILED',
554
+ 'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically',
555
+ {
556
+ derivedPath,
557
+ hint: 'Unset AGENT_DEVICE_IOS_CLEAN_DERIVED, or set AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1 if you trust this path.',
558
+ },
559
+ );
560
+ }
561
+
562
+ function isCleanupOverrideAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
563
+ return isEnvTruthy(env.AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN);
564
+ }
565
+
566
+ function buildRunnerConnectError(params: {
567
+ port: number;
568
+ endpoints: string[];
569
+ logPath?: string;
570
+ lastError: unknown;
571
+ }): AppError {
572
+ const { port, endpoints, logPath, lastError } = params;
573
+ const message = 'Runner did not accept connection';
574
+ return new AppError('COMMAND_FAILED', message, {
575
+ port,
576
+ endpoints,
577
+ logPath,
578
+ lastError: lastError ? String(lastError) : undefined,
579
+ reason: classifyBootFailure({
580
+ error: lastError,
581
+ message,
582
+ context: { platform: 'ios', phase: 'connect' },
583
+ }),
584
+ hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
585
+ });
423
586
  }
424
587
 
425
588
  async function waitForRunner(
@@ -429,43 +592,167 @@ async function waitForRunner(
429
592
  logPath?: string,
430
593
  timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
431
594
  ): Promise<Response> {
432
- const start = Date.now();
595
+ const deadline = Deadline.fromTimeoutMs(timeoutMs);
596
+ let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
433
597
  let lastError: unknown = null;
434
- while (Date.now() - start < timeoutMs) {
435
- try {
436
- const response = await fetch(`http://127.0.0.1:${port}/command`, {
437
- method: 'POST',
438
- headers: { 'Content-Type': 'application/json' },
439
- body: JSON.stringify(command),
440
- });
441
- return response;
442
- } catch (err) {
443
- lastError = err;
444
- await new Promise((resolve) => setTimeout(resolve, 100));
598
+ const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS));
599
+ try {
600
+ return await retryWithPolicy(
601
+ async ({ deadline: attemptDeadline }) => {
602
+ if (attemptDeadline?.isExpired()) {
603
+ throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
604
+ port,
605
+ timeoutMs,
606
+ });
607
+ }
608
+ if (device.kind === 'device') {
609
+ endpoints = await resolveRunnerCommandEndpoints(device, port, attemptDeadline?.remainingMs());
610
+ }
611
+ for (const endpoint of endpoints) {
612
+ try {
613
+ const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs;
614
+ if (remainingMs <= 0) {
615
+ throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
616
+ port,
617
+ timeoutMs,
618
+ });
619
+ }
620
+ const response = await fetchWithTimeout(
621
+ endpoint,
622
+ {
623
+ method: 'POST',
624
+ headers: { 'Content-Type': 'application/json' },
625
+ body: JSON.stringify(command),
626
+ },
627
+ Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs),
628
+ );
629
+ return response;
630
+ } catch (err) {
631
+ lastError = err;
632
+ }
633
+ }
634
+ throw new AppError('COMMAND_FAILED', 'Runner endpoint probe failed', {
635
+ port,
636
+ endpoints,
637
+ lastError: lastError ? String(lastError) : undefined,
638
+ });
639
+ },
640
+ {
641
+ maxAttempts,
642
+ baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
643
+ maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
644
+ jitter: 0.2,
645
+ shouldRetry: () => true,
646
+ },
647
+ { deadline, phase: 'ios_runner_connect' },
648
+ );
649
+ } catch (error) {
650
+ if (!lastError) {
651
+ lastError = error;
445
652
  }
446
653
  }
654
+
447
655
  if (device.kind === 'simulator') {
448
- const simResponse = await postCommandViaSimulator(device.id, port, command);
656
+ const remainingMs = deadline.remainingMs();
657
+ if (remainingMs <= 0) {
658
+ throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
659
+ }
660
+ const simResponse = await postCommandViaSimulator(device.id, port, command, remainingMs);
449
661
  return new Response(simResponse.body, { status: simResponse.status });
450
662
  }
451
663
 
452
- throw new AppError('COMMAND_FAILED', 'Runner did not accept connection', {
453
- port,
454
- logPath,
455
- lastError: lastError ? String(lastError) : undefined,
456
- reason: classifyBootFailure({
457
- error: lastError,
458
- message: 'Runner did not accept connection',
459
- context: { platform: 'ios', phase: 'connect' },
460
- }),
461
- hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
462
- });
664
+ throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
665
+ }
666
+
667
+ async function resolveRunnerCommandEndpoints(
668
+ device: DeviceInfo,
669
+ port: number,
670
+ timeoutBudgetMs?: number,
671
+ ): Promise<string[]> {
672
+ const endpoints = [`http://127.0.0.1:${port}/command`];
673
+ if (device.kind !== 'device') {
674
+ return endpoints;
675
+ }
676
+ const tunnelIp = await resolveDeviceTunnelIp(device.id, timeoutBudgetMs);
677
+ if (tunnelIp) {
678
+ endpoints.unshift(`http://[${tunnelIp}]:${port}/command`);
679
+ }
680
+ return endpoints;
681
+ }
682
+
683
+ async function fetchWithTimeout(
684
+ url: string,
685
+ init: RequestInit,
686
+ timeoutMs: number,
687
+ ): Promise<Response> {
688
+ const controller = new AbortController();
689
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
690
+ try {
691
+ return await fetch(url, { ...init, signal: controller.signal });
692
+ } finally {
693
+ clearTimeout(timeout);
694
+ }
695
+ }
696
+
697
+ async function resolveDeviceTunnelIp(deviceId: string, timeoutBudgetMs?: number): Promise<string | null> {
698
+ if (typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0) {
699
+ return null;
700
+ }
701
+ const timeoutMs = typeof timeoutBudgetMs === 'number'
702
+ ? Math.max(1, Math.min(RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs))
703
+ : RUNNER_DEVICE_INFO_TIMEOUT_MS;
704
+ const jsonPath = path.join(
705
+ os.tmpdir(),
706
+ `agent-device-devicectl-info-${process.pid}-${Date.now()}.json`,
707
+ );
708
+ try {
709
+ const devicectlTimeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
710
+ const result = await runCmd(
711
+ 'xcrun',
712
+ [
713
+ 'devicectl',
714
+ 'device',
715
+ 'info',
716
+ 'details',
717
+ '--device',
718
+ deviceId,
719
+ '--json-output',
720
+ jsonPath,
721
+ '--timeout',
722
+ String(devicectlTimeoutSeconds),
723
+ ],
724
+ { allowFailure: true, timeoutMs },
725
+ );
726
+ if (result.exitCode !== 0 || !fs.existsSync(jsonPath)) {
727
+ return null;
728
+ }
729
+ const payload = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as {
730
+ info?: { outcome?: string };
731
+ result?: {
732
+ connectionProperties?: { tunnelIPAddress?: string };
733
+ device?: { connectionProperties?: { tunnelIPAddress?: string } };
734
+ };
735
+ };
736
+ if (payload.info?.outcome && payload.info.outcome !== 'success') {
737
+ return null;
738
+ }
739
+ const ip = (
740
+ payload.result?.connectionProperties?.tunnelIPAddress
741
+ ?? payload.result?.device?.connectionProperties?.tunnelIPAddress
742
+ )?.trim();
743
+ return ip && ip.length > 0 ? ip : null;
744
+ } catch {
745
+ return null;
746
+ } finally {
747
+ cleanupTempFile(jsonPath);
748
+ }
463
749
  }
464
750
 
465
751
  async function postCommandViaSimulator(
466
752
  udid: string,
467
753
  port: number,
468
754
  command: RunnerCommand,
755
+ timeoutMs: number,
469
756
  ): Promise<{ status: number; body: string }> {
470
757
  const payload = JSON.stringify(command);
471
758
  const result = await runCmd(
@@ -484,7 +771,7 @@ async function postCommandViaSimulator(
484
771
  payload,
485
772
  `http://127.0.0.1:${port}/command`,
486
773
  ],
487
- { allowFailure: true },
774
+ { allowFailure: true, timeoutMs },
488
775
  );
489
776
  const body = result.stdout as string;
490
777
  if (result.exitCode !== 0) {
@@ -0,0 +1,175 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseArgs, usage } from '../args.ts';
4
+ import { AppError } from '../errors.ts';
5
+ import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts';
6
+ import { listCapabilityCommands } from '../../core/capabilities.ts';
7
+
8
+ test('parseArgs recognizes --relaunch', () => {
9
+ const parsed = parseArgs(['open', 'settings', '--relaunch']);
10
+ assert.equal(parsed.command, 'open');
11
+ assert.deepEqual(parsed.positionals, ['settings']);
12
+ assert.equal(parsed.flags.relaunch, true);
13
+ });
14
+
15
+ test('parseArgs recognizes press series flags', () => {
16
+ const parsed = parseArgs([
17
+ 'press',
18
+ '300',
19
+ '500',
20
+ '--count',
21
+ '12',
22
+ '--interval-ms=45',
23
+ '--hold-ms',
24
+ '120',
25
+ '--jitter-px',
26
+ '3',
27
+ ]);
28
+ assert.equal(parsed.command, 'press');
29
+ assert.deepEqual(parsed.positionals, ['300', '500']);
30
+ assert.equal(parsed.flags.count, 12);
31
+ assert.equal(parsed.flags.intervalMs, 45);
32
+ assert.equal(parsed.flags.holdMs, 120);
33
+ assert.equal(parsed.flags.jitterPx, 3);
34
+ });
35
+
36
+ test('parseArgs recognizes swipe positional + pattern flags', () => {
37
+ const parsed = parseArgs([
38
+ 'swipe',
39
+ '540',
40
+ '1500',
41
+ '540',
42
+ '500',
43
+ '120',
44
+ '--count',
45
+ '8',
46
+ '--pause-ms',
47
+ '30',
48
+ '--pattern',
49
+ 'ping-pong',
50
+ ]);
51
+ assert.equal(parsed.command, 'swipe');
52
+ assert.deepEqual(parsed.positionals, ['540', '1500', '540', '500', '120']);
53
+ assert.equal(parsed.flags.count, 8);
54
+ assert.equal(parsed.flags.pauseMs, 30);
55
+ assert.equal(parsed.flags.pattern, 'ping-pong');
56
+ });
57
+
58
+ test('parseArgs rejects invalid swipe pattern', () => {
59
+ assert.throws(
60
+ () => parseArgs(['swipe', '0', '0', '10', '10', '--pattern', 'diagonal']),
61
+ /Invalid pattern/,
62
+ );
63
+ });
64
+
65
+ test('usage includes --relaunch flag', () => {
66
+ assert.match(usage(), /--relaunch/);
67
+ assert.match(usage(), /pinch <scale> \[x\] \[y\]/);
68
+ assert.match(usage(), /--metadata/);
69
+ });
70
+
71
+ test('every capability command has a parser schema entry', () => {
72
+ const schemaCommands = new Set(getCliCommandNames());
73
+ for (const command of listCapabilityCommands()) {
74
+ assert.equal(schemaCommands.has(command), true, `Missing schema for command: ${command}`);
75
+ }
76
+ });
77
+
78
+ test('schema capability mappings match capability source-of-truth', () => {
79
+ assert.deepEqual(getSchemaCapabilityKeys(), listCapabilityCommands());
80
+ });
81
+
82
+ test('compat mode warns and strips unsupported pilot-command flags', () => {
83
+ const parsed = parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: false });
84
+ assert.equal(parsed.command, 'press');
85
+ assert.equal(parsed.flags.snapshotDepth, undefined);
86
+ assert.equal(parsed.warnings.length, 1);
87
+ assert.match(parsed.warnings[0], /not supported for command press/);
88
+ });
89
+
90
+ test('strict mode rejects unsupported pilot-command flags', () => {
91
+ assert.throws(
92
+ () => parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: true }),
93
+ (error) =>
94
+ error instanceof AppError &&
95
+ error.code === 'INVALID_ARGS' &&
96
+ error.message.includes('not supported for command press'),
97
+ );
98
+ });
99
+
100
+ test('snapshot command accepts command-specific flags', () => {
101
+ const parsed = parseArgs(['snapshot', '-i', '-c', '--depth', '3', '-s', 'Login'], { strictFlags: true });
102
+ assert.equal(parsed.command, 'snapshot');
103
+ assert.equal(parsed.flags.snapshotInteractiveOnly, true);
104
+ assert.equal(parsed.flags.snapshotCompact, true);
105
+ assert.equal(parsed.flags.snapshotDepth, 3);
106
+ assert.equal(parsed.flags.snapshotScope, 'Login');
107
+ });
108
+
109
+ test('unknown short flags are rejected', () => {
110
+ assert.throws(
111
+ () => parseArgs(['press', '10', '20', '-x'], { strictFlags: true }),
112
+ (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Unknown flag: -x',
113
+ );
114
+ });
115
+
116
+ test('negative numeric positionals are accepted without -- separator', () => {
117
+ const typed = parseArgs(['type', '-123'], { strictFlags: true });
118
+ assert.equal(typed.command, 'type');
119
+ assert.deepEqual(typed.positionals, ['-123']);
120
+
121
+ const typedMulti = parseArgs(['type', '-123', '-456'], { strictFlags: true });
122
+ assert.equal(typedMulti.command, 'type');
123
+ assert.deepEqual(typedMulti.positionals, ['-123', '-456']);
124
+
125
+ const pressed = parseArgs(['press', '-10', '20'], { strictFlags: true });
126
+ assert.equal(pressed.command, 'press');
127
+ assert.deepEqual(pressed.positionals, ['-10', '20']);
128
+ });
129
+
130
+ test('command-specific flags without command fail in strict mode', () => {
131
+ assert.throws(
132
+ () => parseArgs(['--depth', '3'], { strictFlags: true }),
133
+ (error) =>
134
+ error instanceof AppError &&
135
+ error.code === 'INVALID_ARGS' &&
136
+ error.message.includes('requires a command that supports it'),
137
+ );
138
+ });
139
+
140
+ test('command-specific flags without command warn and strip in compat mode', () => {
141
+ const parsed = parseArgs(['--depth', '3'], { strictFlags: false });
142
+ assert.equal(parsed.command, null);
143
+ assert.equal(parsed.flags.snapshotDepth, undefined);
144
+ assert.equal(parsed.warnings.length, 1);
145
+ assert.match(parsed.warnings[0], /requires a command that supports/);
146
+ });
147
+
148
+ test('all commands participate in strict command-flag validation', () => {
149
+ assert.throws(
150
+ () => parseArgs(['open', 'Settings', '--depth', '1'], { strictFlags: true }),
151
+ (error) =>
152
+ error instanceof AppError &&
153
+ error.code === 'INVALID_ARGS' &&
154
+ error.message.includes('not supported for command open'),
155
+ );
156
+ });
157
+
158
+ test('invalid enum/range errors are deterministic', () => {
159
+ assert.throws(
160
+ () => parseArgs(['snapshot', '--backend', 'foo'], { strictFlags: true }),
161
+ (error) =>
162
+ error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid backend: foo',
163
+ );
164
+ assert.throws(
165
+ () => parseArgs(['snapshot', '--depth', '-1'], { strictFlags: true }),
166
+ (error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid depth: -1',
167
+ );
168
+ });
169
+
170
+ test('usage includes swipe and press series options', () => {
171
+ const help = usage();
172
+ assert.match(help, /swipe <x1> <y1> <x2> <y2>/);
173
+ assert.match(help, /--pattern one-way\|ping-pong/);
174
+ assert.match(help, /--interval-ms/);
175
+ });