agent-device 0.3.5 → 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 (37) hide show
  1. package/README.md +45 -14
  2. package/dist/src/bin.js +35 -97
  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 +21 -11
  8. package/skills/agent-device/references/permissions.md +15 -1
  9. package/skills/agent-device/references/session-management.md +1 -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/capabilities.ts +26 -20
  15. package/src/core/dispatch.ts +109 -31
  16. package/src/daemon/__tests__/app-state.test.ts +138 -0
  17. package/src/daemon/app-state.ts +37 -38
  18. package/src/daemon/context.ts +12 -0
  19. package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
  20. package/src/daemon/handlers/__tests__/session.test.ts +8 -5
  21. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
  22. package/src/daemon/handlers/interaction.ts +37 -0
  23. package/src/daemon/handlers/record-trace.ts +1 -1
  24. package/src/daemon/handlers/session.ts +1 -1
  25. package/src/daemon/handlers/snapshot.ts +21 -3
  26. package/src/daemon-client.ts +14 -6
  27. package/src/daemon.ts +1 -1
  28. package/src/platforms/android/__tests__/index.test.ts +46 -1
  29. package/src/platforms/android/index.ts +23 -0
  30. package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
  31. package/src/platforms/ios/devices.ts +40 -18
  32. package/src/platforms/ios/index.ts +2 -2
  33. package/src/platforms/ios/runner-client.ts +323 -44
  34. package/src/utils/__tests__/args.test.ts +161 -0
  35. package/src/utils/args.ts +174 -218
  36. package/src/utils/command-schema.ts +591 -0
  37. package/src/utils/interactors.ts +11 -1
@@ -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,11 +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 derived = resolveRunnerDerivedPath();
321
+ const derived = resolveRunnerDerivedPath(device.kind);
296
322
  if (shouldCleanDerived()) {
323
+ assertSafeDerivedCleanup(derived);
297
324
  try {
298
325
  fs.rmSync(derived, { recursive: true, force: true });
299
326
  } catch {
@@ -310,6 +337,8 @@ async function ensureXctestrun(
310
337
  throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath });
311
338
  }
312
339
 
340
+ const signingBuildSettings = resolveRunnerSigningBuildSettings(process.env, device.kind === 'device');
341
+ const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : [];
313
342
  try {
314
343
  await runCmdStreaming(
315
344
  'xcodebuild',
@@ -321,12 +350,14 @@ async function ensureXctestrun(
321
350
  'AgentDeviceRunner',
322
351
  '-parallel-testing-enabled',
323
352
  'NO',
324
- '-maximum-concurrent-test-simulator-destinations',
353
+ resolveRunnerMaxConcurrentDestinationsFlag(device),
325
354
  '1',
326
355
  '-destination',
327
- `platform=iOS Simulator,id=${udid}`,
356
+ resolveRunnerBuildDestination(device),
328
357
  '-derivedDataPath',
329
358
  derived,
359
+ ...provisioningArgs,
360
+ ...signingBuildSettings,
330
361
  ],
331
362
  {
332
363
  onStdoutChunk: (chunk) => {
@@ -339,10 +370,12 @@ async function ensureXctestrun(
339
370
  );
340
371
  } catch (err) {
341
372
  const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
373
+ const hint = resolveSigningFailureHint(appErr);
342
374
  throw new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', {
343
375
  error: appErr.message,
344
376
  details: appErr.details,
345
377
  logPath: options.logPath,
378
+ hint,
346
379
  });
347
380
  }
348
381
 
@@ -353,13 +386,90 @@ async function ensureXctestrun(
353
386
  return built;
354
387
  }
355
388
 
356
- function resolveRunnerDerivedPath(): string {
389
+ function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
357
390
  const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
358
391
  if (override) {
359
392
  return path.resolve(override);
360
393
  }
361
- const base = path.join(os.homedir(), '.agent-device', 'ios-runner');
362
- return path.join(base, 'derived');
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;
363
473
  }
364
474
 
365
475
  function findXctestrun(root: string): string | null {
@@ -425,9 +535,54 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
425
535
  }
426
536
 
427
537
  function shouldCleanDerived(): boolean {
428
- const value = process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED;
429
- if (!value) return false;
430
- 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
+ });
431
586
  }
432
587
 
433
588
  async function waitForRunner(
@@ -437,43 +592,167 @@ async function waitForRunner(
437
592
  logPath?: string,
438
593
  timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
439
594
  ): Promise<Response> {
440
- const start = Date.now();
595
+ const deadline = Deadline.fromTimeoutMs(timeoutMs);
596
+ let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
441
597
  let lastError: unknown = null;
442
- while (Date.now() - start < timeoutMs) {
443
- try {
444
- const response = await fetch(`http://127.0.0.1:${port}/command`, {
445
- method: 'POST',
446
- headers: { 'Content-Type': 'application/json' },
447
- body: JSON.stringify(command),
448
- });
449
- return response;
450
- } catch (err) {
451
- lastError = err;
452
- 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;
453
652
  }
454
653
  }
654
+
455
655
  if (device.kind === 'simulator') {
456
- 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);
457
661
  return new Response(simResponse.body, { status: simResponse.status });
458
662
  }
459
663
 
460
- throw new AppError('COMMAND_FAILED', 'Runner did not accept connection', {
461
- port,
462
- logPath,
463
- lastError: lastError ? String(lastError) : undefined,
464
- reason: classifyBootFailure({
465
- error: lastError,
466
- message: 'Runner did not accept connection',
467
- context: { platform: 'ios', phase: 'connect' },
468
- }),
469
- hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
470
- });
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
+ }
471
749
  }
472
750
 
473
751
  async function postCommandViaSimulator(
474
752
  udid: string,
475
753
  port: number,
476
754
  command: RunnerCommand,
755
+ timeoutMs: number,
477
756
  ): Promise<{ status: number; body: string }> {
478
757
  const payload = JSON.stringify(command);
479
758
  const result = await runCmd(
@@ -492,7 +771,7 @@ async function postCommandViaSimulator(
492
771
  payload,
493
772
  `http://127.0.0.1:${port}/command`,
494
773
  ],
495
- { allowFailure: true },
774
+ { allowFailure: true, timeoutMs },
496
775
  );
497
776
  const body = result.stdout as string;
498
777
  if (result.exitCode !== 0) {
@@ -1,6 +1,9 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
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';
4
7
 
5
8
  test('parseArgs recognizes --relaunch', () => {
6
9
  const parsed = parseArgs(['open', 'settings', '--relaunch']);
@@ -9,6 +12,164 @@ test('parseArgs recognizes --relaunch', () => {
9
12
  assert.equal(parsed.flags.relaunch, true);
10
13
  });
11
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
+
12
65
  test('usage includes --relaunch flag', () => {
13
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/);
14
175
  });