agent-device 0.3.4 → 0.3.5

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.
@@ -0,0 +1,24 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { openIosApp } from '../index.ts';
4
+ import type { DeviceInfo } from '../../../utils/device.ts';
5
+ import { AppError } from '../../../utils/errors.ts';
6
+
7
+ test('openIosApp rejects deep links on iOS physical devices', async () => {
8
+ const device: DeviceInfo = {
9
+ platform: 'ios',
10
+ id: 'ios-device-1',
11
+ name: 'iPhone Device',
12
+ kind: 'device',
13
+ booted: true,
14
+ };
15
+
16
+ await assert.rejects(
17
+ () => openIosApp(device, 'https://example.com/path'),
18
+ (error: unknown) => {
19
+ assert.equal(error instanceof AppError, true);
20
+ assert.equal((error as AppError).code, 'UNSUPPORTED_OPERATION');
21
+ return true;
22
+ },
23
+ );
24
+ });
@@ -3,6 +3,7 @@ import type { ExecResult } from '../../utils/exec.ts';
3
3
  import { AppError } from '../../utils/errors.ts';
4
4
  import type { DeviceInfo } from '../../utils/device.ts';
5
5
  import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
6
+ import { isDeepLinkTarget } from '../../core/open-target.ts';
6
7
  import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
7
8
 
8
9
  const ALIASES: Record<string, string> = {
@@ -14,6 +15,16 @@ const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(
14
15
  TIMEOUT_PROFILES.ios_boot.totalMs,
15
16
  5_000,
16
17
  );
18
+ const IOS_SIMCTL_LIST_TIMEOUT_MS = resolveTimeoutMs(
19
+ process.env.AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS,
20
+ TIMEOUT_PROFILES.ios_boot.operationMs,
21
+ 1_000,
22
+ );
23
+ const IOS_APP_LAUNCH_TIMEOUT_MS = resolveTimeoutMs(
24
+ process.env.AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS,
25
+ 30_000,
26
+ 5_000,
27
+ );
17
28
  const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
18
29
 
19
30
  export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
@@ -35,12 +46,54 @@ export async function resolveIosApp(device: DeviceInfo, app: string): Promise<st
35
46
  throw new AppError('APP_NOT_INSTALLED', `No app found matching "${app}"`);
36
47
  }
37
48
 
38
- export async function openIosApp(device: DeviceInfo, app: string): Promise<void> {
39
- const bundleId = await resolveIosApp(device, app);
49
+ export async function openIosApp(
50
+ device: DeviceInfo,
51
+ app: string,
52
+ options?: { appBundleId?: string },
53
+ ): Promise<void> {
54
+ const deepLinkTarget = app.trim();
55
+ if (isDeepLinkTarget(deepLinkTarget)) {
56
+ if (device.kind !== 'simulator') {
57
+ throw new AppError('UNSUPPORTED_OPERATION', 'Deep link open is only supported on iOS simulators in v1');
58
+ }
59
+ await ensureBootedSimulator(device);
60
+ await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
61
+ await runCmd('xcrun', ['simctl', 'openurl', device.id, deepLinkTarget]);
62
+ return;
63
+ }
64
+ const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
40
65
  if (device.kind === 'simulator') {
41
66
  await ensureBootedSimulator(device);
42
67
  await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
43
- await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId]);
68
+ const launchDeadline = Deadline.fromTimeoutMs(IOS_APP_LAUNCH_TIMEOUT_MS);
69
+ await retryWithPolicy(
70
+ async ({ deadline: attemptDeadline }) => {
71
+ if (attemptDeadline?.isExpired()) {
72
+ throw new AppError('COMMAND_FAILED', 'App launch deadline exceeded', {
73
+ timeoutMs: IOS_APP_LAUNCH_TIMEOUT_MS,
74
+ });
75
+ }
76
+ const result = await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId], {
77
+ allowFailure: true,
78
+ });
79
+ if (result.exitCode === 0) return;
80
+ throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, {
81
+ cmd: 'xcrun',
82
+ args: ['simctl', 'launch', device.id, bundleId],
83
+ stdout: result.stdout,
84
+ stderr: result.stderr,
85
+ exitCode: result.exitCode,
86
+ });
87
+ },
88
+ {
89
+ maxAttempts: 30,
90
+ baseDelayMs: 1_000,
91
+ maxDelayMs: 5_000,
92
+ jitter: 0.2,
93
+ shouldRetry: isTransientSimulatorLaunchFailure,
94
+ },
95
+ { deadline: launchDeadline },
96
+ );
44
97
  return;
45
98
  }
46
99
  await runCmd('xcrun', [
@@ -208,6 +261,18 @@ function parseSettingState(state: string): boolean {
208
261
  throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
209
262
  }
210
263
 
264
+ function isTransientSimulatorLaunchFailure(error: unknown): boolean {
265
+ if (!(error instanceof AppError)) return false;
266
+ if (error.code !== 'COMMAND_FAILED') return false;
267
+ const details = (error.details ?? {}) as { exitCode?: number; stderr?: unknown };
268
+ if (details.exitCode !== 4) return false;
269
+ const stderr = String(details.stderr ?? '').toLowerCase();
270
+ return (
271
+ stderr.includes('fbsopenapplicationserviceerrordomain') &&
272
+ stderr.includes('the request to open')
273
+ );
274
+ }
275
+
211
276
  export async function listSimulatorApps(
212
277
  device: DeviceInfo,
213
278
  ): Promise<{ bundleId: string; name: string }[]> {
@@ -365,7 +430,7 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
365
430
  async function getSimulatorState(udid: string): Promise<string | null> {
366
431
  const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], {
367
432
  allowFailure: true,
368
- timeoutMs: TIMEOUT_PROFILES.ios_boot.operationMs,
433
+ timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS,
369
434
  });
370
435
  if (result.exitCode !== 0) return null;
371
436
  try {
@@ -292,8 +292,7 @@ async function ensureXctestrun(
292
292
  udid: string,
293
293
  options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
294
294
  ): Promise<string> {
295
- const base = path.join(os.homedir(), '.agent-device', 'ios-runner');
296
- const derived = path.join(base, 'derived');
295
+ const derived = resolveRunnerDerivedPath();
297
296
  if (shouldCleanDerived()) {
298
297
  try {
299
298
  fs.rmSync(derived, { recursive: true, force: true });
@@ -354,6 +353,15 @@ async function ensureXctestrun(
354
353
  return built;
355
354
  }
356
355
 
356
+ function resolveRunnerDerivedPath(): string {
357
+ const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
358
+ if (override) {
359
+ return path.resolve(override);
360
+ }
361
+ const base = path.join(os.homedir(), '.agent-device', 'ios-runner');
362
+ return path.join(base, 'derived');
363
+ }
364
+
357
365
  function findXctestrun(root: string): string | null {
358
366
  if (!fs.existsSync(root)) return null;
359
367
  const candidates: { path: string; mtimeMs: number }[] = [];
@@ -0,0 +1,14 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseArgs, usage } from '../args.ts';
4
+
5
+ test('parseArgs recognizes --relaunch', () => {
6
+ const parsed = parseArgs(['open', 'settings', '--relaunch']);
7
+ assert.equal(parsed.command, 'open');
8
+ assert.deepEqual(parsed.positionals, ['settings']);
9
+ assert.equal(parsed.flags.relaunch, true);
10
+ });
11
+
12
+ test('usage includes --relaunch flag', () => {
13
+ assert.match(usage(), /--relaunch/);
14
+ });
package/src/utils/args.ts CHANGED
@@ -22,6 +22,7 @@ export type ParsedArgs = {
22
22
  appsMetadata?: boolean;
23
23
  activity?: string;
24
24
  saveScript?: boolean;
25
+ relaunch?: boolean;
25
26
  noRecord?: boolean;
26
27
  replayUpdate?: boolean;
27
28
  help: boolean;
@@ -71,6 +72,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
71
72
  flags.saveScript = true;
72
73
  continue;
73
74
  }
75
+ if (arg === '--relaunch') {
76
+ flags.relaunch = true;
77
+ continue;
78
+ }
74
79
  if (arg === '--update' || arg === '-u') {
75
80
  flags.replayUpdate = true;
76
81
  continue;
@@ -174,7 +179,7 @@ CLI to control iOS and Android devices for AI agents.
174
179
 
175
180
  Commands:
176
181
  boot Ensure target device/simulator is booted and ready
177
- open [app] Boot device/simulator; optionally launch app
182
+ open [app|url] Boot device/simulator; optionally launch app or deep link URL
178
183
  close [app] Close app or just end session
179
184
  reinstall <app> <path> Uninstall + install app from binary path
180
185
  snapshot [-i] [-c] [-d <depth>] [-s <scope>] [--raw] [--backend ax|xctest]
@@ -227,11 +232,12 @@ Flags:
227
232
  --device <name> Device name to target
228
233
  --udid <udid> iOS device UDID
229
234
  --serial <serial> Android device serial
230
- --activity <component> Android activity to launch (package/Activity)
235
+ --activity <component> Android app launch activity (package/Activity); not for URL opens
231
236
  --session <name> Named session
232
237
  --verbose Stream daemon/runner logs
233
238
  --json JSON output
234
239
  --save-script Save session script (.ad) on close
240
+ --relaunch open: terminate app process before launching it
235
241
  --no-record Do not record this action
236
242
  --update, -u Replay: update selectors and rewrite replay file in place
237
243
  --user-installed Apps: list user-installed packages (Android only)
@@ -29,7 +29,7 @@ export type RunnerContext = {
29
29
  };
30
30
 
31
31
  export type Interactor = {
32
- open(app: string, options?: { activity?: string }): Promise<void>;
32
+ open(app: string, options?: { activity?: string; appBundleId?: string }): Promise<void>;
33
33
  openDevice(): Promise<void>;
34
34
  close(app: string): Promise<void>;
35
35
  tap(x: number, y: number): Promise<void>;
@@ -60,7 +60,7 @@ export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext):
60
60
  };
61
61
  case 'ios':
62
62
  return {
63
- open: (app) => openIosApp(device, app),
63
+ open: (app, options) => openIosApp(device, app, { appBundleId: options?.appBundleId }),
64
64
  openDevice: () => openIosDevice(device),
65
65
  close: (app) => closeIosApp(device, app),
66
66
  screenshot: (outPath) => screenshotIos(device, outPath),