agent-device 0.3.3 → 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.
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import { dispatchCommand, resolveTargetDevice } from '../../core/dispatch.ts';
3
3
  import { isCommandSupportedOnDevice } from '../../core/capabilities.ts';
4
+ import { isDeepLinkTarget } from '../../core/open-target.ts';
4
5
  import { AppError, asAppError } from '../../utils/errors.ts';
5
6
  import type { DeviceInfo } from '../../utils/device.ts';
6
7
  import type { DaemonRequest, DaemonResponse, SessionAction, SessionState } from '../types.ts';
@@ -10,7 +11,7 @@ import { ensureDeviceReady } from '../device-ready.ts';
10
11
  import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
11
12
  import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts';
12
13
  import { attachRefs, type RawSnapshotNode, type SnapshotState } from '../../utils/snapshot.ts';
13
- import { pruneGroupNodes } from '../snapshot-processing.ts';
14
+ import { extractNodeText, normalizeType, pruneGroupNodes } from '../snapshot-processing.ts';
14
15
  import {
15
16
  buildSelectorChainForNode,
16
17
  resolveSelectorChain,
@@ -53,6 +54,18 @@ const defaultReinstallOps: ReinstallOps = {
53
54
  },
54
55
  };
55
56
 
57
+ async function resolveIosBundleIdForOpen(device: DeviceInfo, openTarget: string | undefined): Promise<string | undefined> {
58
+ if (device.platform !== 'ios' || !openTarget || isDeepLinkTarget(openTarget)) {
59
+ return undefined;
60
+ }
61
+ try {
62
+ const { resolveIosApp } = await import('../../platforms/ios/index.ts');
63
+ return await resolveIosApp(device, openTarget);
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
56
69
  export async function handleSessionCommands(params: {
57
70
  req: DaemonRequest;
58
71
  sessionName: string;
@@ -286,10 +299,21 @@ export async function handleSessionCommands(params: {
286
299
  }
287
300
 
288
301
  if (command === 'open') {
302
+ const shouldRelaunch = req.flags?.relaunch === true;
289
303
  if (sessionStore.has(sessionName)) {
290
304
  const session = sessionStore.get(sessionName);
291
- const appName = req.positionals?.[0];
292
- if (!session || !appName) {
305
+ const requestedOpenTarget = req.positionals?.[0];
306
+ const openTarget = requestedOpenTarget ?? (shouldRelaunch ? session?.appName : undefined);
307
+ if (!session || !openTarget) {
308
+ if (shouldRelaunch) {
309
+ return {
310
+ ok: false,
311
+ error: {
312
+ code: 'INVALID_ARGS',
313
+ message: 'open --relaunch requires an app name or an active session app.',
314
+ },
315
+ };
316
+ }
293
317
  return {
294
318
  ok: false,
295
319
  error: {
@@ -298,33 +322,60 @@ export async function handleSessionCommands(params: {
298
322
  },
299
323
  };
300
324
  }
301
- let appBundleId: string | undefined;
302
- if (session.device.platform === 'ios') {
303
- try {
304
- const { resolveIosApp } = await import('../../platforms/ios/index.ts');
305
- appBundleId = await resolveIosApp(session.device, appName);
306
- } catch {
307
- appBundleId = undefined;
308
- }
325
+ if (shouldRelaunch && isDeepLinkTarget(openTarget)) {
326
+ return {
327
+ ok: false,
328
+ error: {
329
+ code: 'INVALID_ARGS',
330
+ message: 'open --relaunch does not support URL targets.',
331
+ },
332
+ };
333
+ }
334
+ const appBundleId = await resolveIosBundleIdForOpen(session.device, openTarget);
335
+ const openPositionals = requestedOpenTarget ? (req.positionals ?? []) : [openTarget];
336
+ if (shouldRelaunch) {
337
+ const closeTarget = appBundleId ?? openTarget;
338
+ await dispatch(session.device, 'close', [closeTarget], req.flags?.out, {
339
+ ...contextFromFlags(logPath, req.flags, appBundleId ?? session.appBundleId, session.trace?.outPath),
340
+ });
309
341
  }
310
- await dispatch(session.device, 'open', req.positionals ?? [], req.flags?.out, {
342
+ await dispatch(session.device, 'open', openPositionals, req.flags?.out, {
311
343
  ...contextFromFlags(logPath, req.flags, appBundleId),
312
344
  });
313
345
  const nextSession: SessionState = {
314
346
  ...session,
315
347
  appBundleId,
316
- appName,
348
+ appName: openTarget,
317
349
  recordSession: session.recordSession || req.flags?.saveScript === true,
318
350
  snapshot: undefined,
319
351
  };
320
352
  sessionStore.recordAction(nextSession, {
321
353
  command,
322
- positionals: req.positionals ?? [],
354
+ positionals: openPositionals,
323
355
  flags: req.flags ?? {},
324
- result: { session: sessionName, appName, appBundleId },
356
+ result: { session: sessionName, appName: openTarget, appBundleId },
325
357
  });
326
358
  sessionStore.set(sessionName, nextSession);
327
- return { ok: true, data: { session: sessionName, appName, appBundleId } };
359
+ return { ok: true, data: { session: sessionName, appName: openTarget, appBundleId } };
360
+ }
361
+ const openTarget = req.positionals?.[0];
362
+ if (shouldRelaunch && !openTarget) {
363
+ return {
364
+ ok: false,
365
+ error: {
366
+ code: 'INVALID_ARGS',
367
+ message: 'open --relaunch requires an app argument.',
368
+ },
369
+ };
370
+ }
371
+ if (shouldRelaunch && openTarget && isDeepLinkTarget(openTarget)) {
372
+ return {
373
+ ok: false,
374
+ error: {
375
+ code: 'INVALID_ARGS',
376
+ message: 'open --relaunch does not support URL targets.',
377
+ },
378
+ };
328
379
  }
329
380
  const device = await resolveTargetDevice(req.flags ?? {});
330
381
  const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
@@ -338,15 +389,12 @@ export async function handleSessionCommands(params: {
338
389
  },
339
390
  };
340
391
  }
341
- let appBundleId: string | undefined;
342
- const appName = req.positionals?.[0];
343
- if (device.platform === 'ios') {
344
- try {
345
- const { resolveIosApp } = await import('../../platforms/ios/index.ts');
346
- appBundleId = await resolveIosApp(device, req.positionals?.[0] ?? '');
347
- } catch {
348
- appBundleId = undefined;
349
- }
392
+ const appBundleId = await resolveIosBundleIdForOpen(device, openTarget);
393
+ if (shouldRelaunch && openTarget) {
394
+ const closeTarget = appBundleId ?? openTarget;
395
+ await dispatch(device, 'close', [closeTarget], req.flags?.out, {
396
+ ...contextFromFlags(logPath, req.flags, appBundleId),
397
+ });
350
398
  }
351
399
  await dispatch(device, 'open', req.positionals ?? [], req.flags?.out, {
352
400
  ...contextFromFlags(logPath, req.flags, appBundleId),
@@ -356,7 +404,7 @@ export async function handleSessionCommands(params: {
356
404
  device,
357
405
  createdAt: Date.now(),
358
406
  appBundleId,
359
- appName,
407
+ appName: openTarget,
360
408
  recordSession: req.flags?.saveScript === true,
361
409
  actions: [],
362
410
  };
@@ -517,6 +565,10 @@ async function healReplayAction(params: {
517
565
  const session = sessionStore.get(sessionName);
518
566
  if (!session) return null;
519
567
  const requiresRect = action.command === 'click' || action.command === 'fill';
568
+ const allowDisambiguation =
569
+ action.command === 'click' ||
570
+ action.command === 'fill' ||
571
+ (action.command === 'get' && action.positionals?.[0] === 'text');
520
572
  const snapshot = await captureSnapshotForReplay(session, action, logPath, requiresRect, dispatch, sessionStore);
521
573
  const selectorCandidates = collectReplaySelectorCandidates(action);
522
574
  for (const candidate of selectorCandidates) {
@@ -526,7 +578,7 @@ async function healReplayAction(params: {
526
578
  platform: session.device.platform,
527
579
  requireRect: requiresRect,
528
580
  requireUnique: true,
529
- disambiguateAmbiguous: requiresRect,
581
+ disambiguateAmbiguous: allowDisambiguation,
530
582
  });
531
583
  if (!resolved) continue;
532
584
  const selectorChain = buildSelectorChainForNode(resolved.node, session.device.platform, {
@@ -580,6 +632,10 @@ async function healReplayAction(params: {
580
632
  };
581
633
  }
582
634
  }
635
+ const numericDriftHeal = healNumericGetTextDrift(action, snapshot, session);
636
+ if (numericDriftHeal) {
637
+ return numericDriftHeal;
638
+ }
583
639
  return null;
584
640
  }
585
641
 
@@ -697,6 +753,56 @@ function parseSelectorWaitPositionals(positionals: string[]): {
697
753
  };
698
754
  }
699
755
 
756
+ function healNumericGetTextDrift(
757
+ action: SessionAction,
758
+ snapshot: SnapshotState,
759
+ session: SessionState,
760
+ ): SessionAction | null {
761
+ if (action.command !== 'get') return null;
762
+ if (action.positionals?.[0] !== 'text') return null;
763
+ const selectorExpression = action.positionals?.[1];
764
+ if (!selectorExpression) return null;
765
+ const chain = tryParseSelectorChain(selectorExpression);
766
+ if (!chain) return null;
767
+
768
+ const roleFilters = new Set<string>();
769
+ let hasNumericTerm = false;
770
+ for (const selector of chain.selectors) {
771
+ for (const term of selector.terms) {
772
+ if (term.key === 'role' && typeof term.value === 'string') {
773
+ roleFilters.add(normalizeType(term.value));
774
+ }
775
+ if (
776
+ (term.key === 'text' || term.key === 'label' || term.key === 'value') &&
777
+ typeof term.value === 'string' &&
778
+ /^\d+$/.test(term.value.trim())
779
+ ) {
780
+ hasNumericTerm = true;
781
+ }
782
+ }
783
+ }
784
+ if (!hasNumericTerm) return null;
785
+
786
+ const numericNodes = snapshot.nodes.filter((node) => {
787
+ const text = extractNodeText(node).trim();
788
+ if (!/^\d+$/.test(text)) return false;
789
+ if (roleFilters.size === 0) return true;
790
+ return roleFilters.has(normalizeType(node.type ?? ''));
791
+ });
792
+ if (numericNodes.length === 0) return null;
793
+ const numericValues = uniqueStrings(numericNodes.map((node) => extractNodeText(node).trim()));
794
+ if (numericValues.length !== 1) return null;
795
+
796
+ const targetNode = numericNodes[0];
797
+ if (!targetNode) return null;
798
+ const selectorChain = buildSelectorChainForNode(targetNode, session.device.platform, { action: 'get' });
799
+ if (selectorChain.length === 0) return null;
800
+ return {
801
+ ...action,
802
+ positionals: ['text', selectorChain.join(' || ')],
803
+ };
804
+ }
805
+
700
806
  function parseReplayScript(script: string): SessionAction[] {
701
807
  const actions: SessionAction[] = [];
702
808
  const lines = script.split(/\r?\n/);
@@ -764,6 +870,19 @@ function parseReplayScriptLine(line: string): SessionAction | null {
764
870
  return action;
765
871
  }
766
872
 
873
+ if (command === 'open') {
874
+ action.positionals = [];
875
+ for (let index = 0; index < args.length; index += 1) {
876
+ const token = args[index];
877
+ if (token === '--relaunch') {
878
+ action.flags.relaunch = true;
879
+ continue;
880
+ }
881
+ action.positionals.push(token);
882
+ }
883
+ return action;
884
+ }
885
+
767
886
  if (command === 'click') {
768
887
  if (args.length === 0) return action;
769
888
  const target = args[0];
@@ -890,6 +1009,15 @@ function formatReplayActionLine(action: SessionAction): string {
890
1009
  }
891
1010
  return parts.join(' ');
892
1011
  }
1012
+ if (action.command === 'open') {
1013
+ for (const positional of action.positionals ?? []) {
1014
+ parts.push(formatReplayArg(positional));
1015
+ }
1016
+ if (action.flags?.relaunch) {
1017
+ parts.push('--relaunch');
1018
+ }
1019
+ return parts.join(' ');
1020
+ }
893
1021
  for (const positional of action.positionals ?? []) {
894
1022
  parts.push(formatReplayArg(positional));
895
1023
  }
@@ -483,7 +483,6 @@ function analyzeSelectorMatches(
483
483
  }
484
484
  if (!best) {
485
485
  best = node;
486
- tie = false;
487
486
  continue;
488
487
  }
489
488
  const comparison = compareDisambiguationCandidates(node, best);
@@ -166,6 +166,7 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
166
166
  snapshotRaw,
167
167
  snapshotBackend,
168
168
  appsMetadata,
169
+ relaunch,
169
170
  saveScript,
170
171
  noRecord,
171
172
  } = flags;
@@ -183,6 +184,7 @@ function sanitizeFlags(flags: CommandFlags | undefined): SessionAction['flags']
183
184
  snapshotRaw,
184
185
  snapshotBackend,
185
186
  appsMetadata,
187
+ relaunch,
186
188
  saveScript,
187
189
  noRecord,
188
190
  };
@@ -261,6 +263,15 @@ function formatActionLine(action: SessionAction): string {
261
263
  }
262
264
  return parts.join(' ');
263
265
  }
266
+ if (action.command === 'open') {
267
+ for (const positional of action.positionals ?? []) {
268
+ parts.push(formatArg(positional));
269
+ }
270
+ if (action.flags?.relaunch) {
271
+ parts.push('--relaunch');
272
+ }
273
+ return parts.join(' ');
274
+ }
264
275
  for (const positional of action.positionals ?? []) {
265
276
  parts.push(formatArg(positional));
266
277
  }
@@ -1,6 +1,8 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { parseAndroidLaunchComponent } from '../index.ts';
3
+ import { openAndroidApp, parseAndroidLaunchComponent } from '../index.ts';
4
+ import type { DeviceInfo } from '../../../utils/device.ts';
5
+ import { AppError } from '../../../utils/errors.ts';
4
6
  import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
5
7
 
6
8
  test('parseUiHierarchy reads double-quoted Android node attributes', () => {
@@ -89,3 +91,22 @@ test('parseAndroidLaunchComponent returns null when no component is present', ()
89
91
  const stdout = 'No activity found';
90
92
  assert.equal(parseAndroidLaunchComponent(stdout), null);
91
93
  });
94
+
95
+ test('openAndroidApp rejects activity override for deep link URLs', async () => {
96
+ const device: DeviceInfo = {
97
+ platform: 'android',
98
+ id: 'emulator-5554',
99
+ name: 'Pixel',
100
+ kind: 'emulator',
101
+ booted: true,
102
+ };
103
+
104
+ await assert.rejects(
105
+ () => openAndroidApp(device, ' https://example.com/path ', '.MainActivity'),
106
+ (error: unknown) => {
107
+ assert.equal(error instanceof AppError, true);
108
+ assert.equal((error as AppError).code, 'INVALID_ARGS');
109
+ return true;
110
+ },
111
+ );
112
+ });
@@ -4,6 +4,7 @@ import { withRetry } from '../../utils/retry.ts';
4
4
  import { AppError } from '../../utils/errors.ts';
5
5
  import type { DeviceInfo } from '../../utils/device.ts';
6
6
  import type { RawSnapshotNode, SnapshotOptions } from '../../utils/snapshot.ts';
7
+ import { isDeepLinkTarget } from '../../core/open-target.ts';
7
8
  import { waitForAndroidBoot } from './devices.ts';
8
9
  import { findBounds, parseBounds, parseUiHierarchy, readNodeAttributes } from './ui-hierarchy.ts';
9
10
 
@@ -157,6 +158,23 @@ export async function openAndroidApp(
157
158
  if (!device.booted) {
158
159
  await waitForAndroidBoot(device.id);
159
160
  }
161
+ const deepLinkTarget = app.trim();
162
+ if (isDeepLinkTarget(deepLinkTarget)) {
163
+ if (activity) {
164
+ throw new AppError('INVALID_ARGS', 'Activity override is not supported when opening a deep link URL');
165
+ }
166
+ await runCmd('adb', adbArgs(device, [
167
+ 'shell',
168
+ 'am',
169
+ 'start',
170
+ '-W',
171
+ '-a',
172
+ 'android.intent.action.VIEW',
173
+ '-d',
174
+ deepLinkTarget,
175
+ ]));
176
+ return;
177
+ }
160
178
  const resolved = await resolveAndroidApp(device, app);
161
179
  if (resolved.type === 'intent') {
162
180
  if (activity) {
@@ -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),