agent-device 0.4.1 → 0.4.2

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 (41) hide show
  1. package/README.md +18 -12
  2. package/dist/src/bin.js +32 -32
  3. package/dist/src/daemon.js +18 -14
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
  5. package/package.json +1 -1
  6. package/skills/agent-device/SKILL.md +19 -13
  7. package/skills/agent-device/references/permissions.md +7 -2
  8. package/skills/agent-device/references/session-management.md +3 -1
  9. package/src/__tests__/cli-close.test.ts +155 -0
  10. package/src/cli.ts +32 -16
  11. package/src/core/__tests__/capabilities.test.ts +2 -1
  12. package/src/core/__tests__/dispatch-open.test.ts +25 -0
  13. package/src/core/__tests__/open-target.test.ts +40 -1
  14. package/src/core/capabilities.ts +1 -1
  15. package/src/core/dispatch.ts +22 -0
  16. package/src/core/open-target.ts +14 -0
  17. package/src/daemon/__tests__/device-ready.test.ts +52 -0
  18. package/src/daemon/device-ready.ts +146 -4
  19. package/src/daemon/handlers/__tests__/session.test.ts +477 -0
  20. package/src/daemon/handlers/session.ts +196 -91
  21. package/src/daemon/session-store.ts +0 -2
  22. package/src/daemon-client.ts +118 -18
  23. package/src/platforms/android/__tests__/index.test.ts +118 -1
  24. package/src/platforms/android/index.ts +77 -47
  25. package/src/platforms/ios/__tests__/index.test.ts +292 -4
  26. package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
  27. package/src/platforms/ios/apps.ts +358 -0
  28. package/src/platforms/ios/config.ts +28 -0
  29. package/src/platforms/ios/devicectl.ts +134 -0
  30. package/src/platforms/ios/devices.ts +15 -2
  31. package/src/platforms/ios/index.ts +20 -455
  32. package/src/platforms/ios/runner-client.ts +72 -16
  33. package/src/platforms/ios/simulator.ts +164 -0
  34. package/src/utils/__tests__/args.test.ts +20 -2
  35. package/src/utils/__tests__/daemon-client.test.ts +21 -4
  36. package/src/utils/args.ts +6 -1
  37. package/src/utils/command-schema.ts +7 -14
  38. package/src/utils/interactors.ts +2 -2
  39. package/src/utils/timeouts.ts +9 -0
  40. package/src/daemon/__tests__/app-state.test.ts +0 -138
  41. package/src/daemon/app-state.ts +0 -65
@@ -216,14 +216,20 @@ final class RunnerTests: XCTestCase {
216
216
  }
217
217
 
218
218
  private func executeOnMain(command: Command) throws -> Response {
219
- let bundleId = command.appBundleId ?? currentBundleId ?? "com.apple.Preferences"
220
- if currentBundleId != bundleId {
219
+ let normalizedBundleId = command.appBundleId?
220
+ .trimmingCharacters(in: .whitespacesAndNewlines)
221
+ let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
222
+ if let bundleId = requestedBundleId, currentBundleId != bundleId {
221
223
  let target = XCUIApplication(bundleIdentifier: bundleId)
222
224
  NSLog("AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d", bundleId, target.state.rawValue)
223
225
  // activate avoids terminating and relaunching the target app
224
226
  target.activate()
225
227
  currentApp = target
226
228
  currentBundleId = bundleId
229
+ } else if requestedBundleId == nil {
230
+ // Do not reuse stale bundle targets when the caller does not explicitly request one.
231
+ currentApp = nil
232
+ currentBundleId = nil
227
233
  }
228
234
  let activeApp = currentApp ?? app
229
235
  _ = activeApp.waitForExistence(timeout: 5)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.4.1",
3
+ "version": "0.4.2",
4
4
  "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Callstack",
@@ -27,7 +27,7 @@ npx -y agent-device
27
27
 
28
28
  ## Core workflow
29
29
 
30
- 1. Open app or deep link: `open [app|url]` (`open` handles target selection + boot/activation in the normal flow)
30
+ 1. Open app or deep link: `open [app|url] [url]` (`open` handles target selection + boot/activation in the normal flow)
31
31
  2. Snapshot: `snapshot` to get refs from accessibility tree
32
32
  3. Interact using refs (`click @ref`, `fill @ref "text"`)
33
33
  4. Re-snapshot after navigation/UI changes
@@ -39,13 +39,14 @@ npx -y agent-device
39
39
 
40
40
  ```bash
41
41
  agent-device boot # Ensure target is booted/ready without opening app
42
- agent-device boot --platform ios # Boot iOS simulator/device target
42
+ agent-device boot --platform ios # Boot iOS target
43
43
  agent-device boot --platform android # Boot Android emulator/device target
44
- agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
44
+ agent-device open [app|url] [url] # Boot device/simulator; optionally launch app or deep link URL
45
45
  agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
46
46
  agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
47
47
  agent-device open "myapp://home" --platform android # Android deep link
48
- agent-device open "https://example.com" --platform ios # iOS simulator deep link (device unsupported)
48
+ agent-device open "https://example.com" --platform ios # iOS deep link (opens in browser)
49
+ agent-device open MyApp "myapp://screen/to" --platform ios # iOS deep link in app context
49
50
  agent-device close [app] # Close app or just end session
50
51
  agent-device reinstall <app> <path> # Uninstall + install app in one command
51
52
  agent-device session list # List active sessions
@@ -101,10 +102,12 @@ iOS settings helpers are simulator-only.
101
102
 
102
103
  ```bash
103
104
  agent-device appstate
104
- agent-device apps --metadata --platform ios
105
- agent-device apps --metadata --platform android
106
105
  ```
107
106
 
107
+ - Android: `appstate` reports live foreground package/activity.
108
+ - iOS: `appstate` is session-scoped and reports the app tracked by the active session on the target device.
109
+ - For iOS `appstate`, ensure a matching session exists (for example `open --session <name> --platform ios --device "<name>" <app>`).
110
+
108
111
  ### Interactions (use @refs from snapshot)
109
112
 
110
113
  ```bash
@@ -167,9 +170,11 @@ agent-device trace stop ./trace.log # Stop and move trace log
167
170
 
168
171
  ```bash
169
172
  agent-device devices
170
- agent-device apps --platform ios
171
- agent-device apps --platform android # default: launchable only
172
- agent-device apps --platform android --all
173
+ agent-device apps --platform ios # iOS simulator + iOS device, includes default/system apps
174
+ agent-device apps --platform ios --all # explicit include-all (same as default)
175
+ agent-device apps --platform ios --user-installed
176
+ agent-device apps --platform android # includes default/system apps
177
+ agent-device apps --platform android --all # explicit include-all (same as default)
173
178
  agent-device apps --platform android --user-installed
174
179
  ```
175
180
 
@@ -184,15 +189,16 @@ agent-device apps --platform android --user-installed
184
189
  - Prefer `snapshot -i` to reduce output size.
185
190
  - On iOS, `xctest` is the default and does not require Accessibility permission.
186
191
  - If XCTest returns 0 nodes (foreground app changed), treat it as an explicit failure and retry the flow/app state.
187
- - `open <app|url>` can be used within an existing session to switch apps or open deep links.
188
- - `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
192
+ - `open <app|url> [url]` can be used within an existing session to switch apps or open deep links.
193
+ - `open <app>` updates session app bundle context; `open <app> <url>` opens a deep link on iOS.
189
194
  - Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
190
195
  - If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
191
196
  - Use `--session <name>` for parallel sessions; avoid device contention.
192
197
  - Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
193
- - iOS deep-link opens are simulator-only.
198
+ - On iOS devices, `http(s)://` URLs fall back to Safari automatically; custom scheme URLs require an active app in the session.
194
199
  - iOS physical-device runner requires Xcode signing/provisioning; optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
195
- - For long first-run physical-device setup/build, increase daemon timeout: `AGENT_DEVICE_DAEMON_TIMEOUT_MS=180000` (or higher).
200
+ - Default daemon request timeout is `45000`ms. For slow physical-device setup/build, increase `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `120000`).
201
+ - For daemon startup troubleshooting, follow stale metadata hints for `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock`.
196
202
  - Use `fill` when you want clear-then-type semantics.
197
203
  - Use `type` when you want to append/enter text without clearing.
198
204
  - On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.
@@ -24,9 +24,14 @@ Use Automatic Signing in Xcode, or provide optional overrides:
24
24
  - `AGENT_DEVICE_IOS_SIGNING_IDENTITY`
25
25
  - `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`
26
26
 
27
- If first-run setup/build takes long, increase:
27
+ If setup/build takes long, increase:
28
28
 
29
- - `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `180000`)
29
+ - `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (default `45000`, for example `120000`)
30
+
31
+ If daemon startup fails with stale metadata hints, clean stale files and retry:
32
+
33
+ - `~/.agent-device/daemon.json`
34
+ - `~/.agent-device/daemon.lock`
30
35
 
31
36
  ## Simulator troubleshooting
32
37
 
@@ -14,7 +14,9 @@ Sessions isolate device context. A device can only be held by one session at a t
14
14
  - Name sessions semantically.
15
15
  - Close sessions when done.
16
16
  - Use separate sessions for parallel work.
17
- - In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only.
17
+ - In iOS sessions, use `open <app>`. `open <url>` opens deep links; on devices `http(s)://` opens Safari when no app is active, and custom schemes require an active app in the session.
18
+ - In iOS sessions, `open <app> <url>` opens a deep link.
19
+ - On iOS, `appstate` is session-scoped and requires a matching active session on the target device.
18
20
  - For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
19
21
  - Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
20
22
  - For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.
@@ -0,0 +1,155 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { runCli } from '../cli.ts';
4
+ import { AppError } from '../utils/errors.ts';
5
+ import type { DaemonResponse } from '../daemon-client.ts';
6
+
7
+ class ExitSignal extends Error {
8
+ public readonly code: number;
9
+
10
+ constructor(code: number) {
11
+ super(`EXIT_${code}`);
12
+ this.code = code;
13
+ }
14
+ }
15
+
16
+ type RunResult = {
17
+ code: number | null;
18
+ stdout: string;
19
+ stderr: string;
20
+ daemonCalls: number;
21
+ };
22
+
23
+ async function runCliCapture(argv: string[]): Promise<RunResult> {
24
+ let daemonCalls = 0;
25
+ let stdout = '';
26
+ let stderr = '';
27
+ let code: number | null = null;
28
+
29
+ const originalExit = process.exit;
30
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
31
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
32
+
33
+ (process as any).exit = ((nextCode?: number) => {
34
+ throw new ExitSignal(nextCode ?? 0);
35
+ }) as typeof process.exit;
36
+ (process.stdout as any).write = ((chunk: unknown) => {
37
+ stdout += String(chunk);
38
+ return true;
39
+ }) as typeof process.stdout.write;
40
+ (process.stderr as any).write = ((chunk: unknown) => {
41
+ stderr += String(chunk);
42
+ return true;
43
+ }) as typeof process.stderr.write;
44
+
45
+ const sendToDaemon = async (): Promise<DaemonResponse> => {
46
+ daemonCalls += 1;
47
+ throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
48
+ infoPath: '/tmp/daemon.json',
49
+ hint: 'stale daemon info',
50
+ });
51
+ };
52
+
53
+ try {
54
+ await runCli(argv, { sendToDaemon });
55
+ } catch (error) {
56
+ if (error instanceof ExitSignal) code = error.code;
57
+ else throw error;
58
+ } finally {
59
+ process.exit = originalExit;
60
+ process.stdout.write = originalStdoutWrite;
61
+ process.stderr.write = originalStderrWrite;
62
+ }
63
+
64
+ return { code, stdout, stderr, daemonCalls };
65
+ }
66
+
67
+ async function runCliCaptureWithErrorDetails(
68
+ argv: string[],
69
+ details: Record<string, unknown>,
70
+ message = 'Failed to start daemon',
71
+ ): Promise<RunResult> {
72
+ let daemonCalls = 0;
73
+ let stdout = '';
74
+ let stderr = '';
75
+ let code: number | null = null;
76
+
77
+ const originalExit = process.exit;
78
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
79
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
80
+
81
+ (process as any).exit = ((nextCode?: number) => {
82
+ throw new ExitSignal(nextCode ?? 0);
83
+ }) as typeof process.exit;
84
+ (process.stdout as any).write = ((chunk: unknown) => {
85
+ stdout += String(chunk);
86
+ return true;
87
+ }) as typeof process.stdout.write;
88
+ (process.stderr as any).write = ((chunk: unknown) => {
89
+ stderr += String(chunk);
90
+ return true;
91
+ }) as typeof process.stderr.write;
92
+
93
+ const sendToDaemon = async (): Promise<DaemonResponse> => {
94
+ daemonCalls += 1;
95
+ throw new AppError('COMMAND_FAILED', message, details);
96
+ };
97
+
98
+ try {
99
+ await runCli(argv, { sendToDaemon });
100
+ } catch (error) {
101
+ if (error instanceof ExitSignal) code = error.code;
102
+ else throw error;
103
+ } finally {
104
+ process.exit = originalExit;
105
+ process.stdout.write = originalStdoutWrite;
106
+ process.stderr.write = originalStderrWrite;
107
+ }
108
+
109
+ return { code, stdout, stderr, daemonCalls };
110
+ }
111
+
112
+ test('close treats daemon startup failure as no-op', async () => {
113
+ const result = await runCliCapture(['close']);
114
+ assert.equal(result.code, null);
115
+ assert.equal(result.daemonCalls, 1);
116
+ assert.equal(result.stdout, '');
117
+ assert.equal(result.stderr, '');
118
+ });
119
+
120
+ test('close --json treats daemon startup failure as no-op success', async () => {
121
+ const result = await runCliCapture(['close', '--json']);
122
+ assert.equal(result.code, null);
123
+ assert.equal(result.daemonCalls, 1);
124
+ const payload = JSON.parse(result.stdout);
125
+ assert.equal(payload.success, true);
126
+ assert.equal(payload.data.closed, 'session');
127
+ assert.equal(payload.data.source, 'no-daemon');
128
+ assert.equal(result.stderr, '');
129
+ });
130
+
131
+ test('close treats lock-only daemon startup failure as no-op', async () => {
132
+ const result = await runCliCaptureWithErrorDetails(['close'], {
133
+ lockPath: '/tmp/daemon.lock',
134
+ hint: 'stale daemon lock',
135
+ });
136
+ assert.equal(result.code, null);
137
+ assert.equal(result.daemonCalls, 1);
138
+ assert.equal(result.stdout, '');
139
+ assert.equal(result.stderr, '');
140
+ });
141
+
142
+ test('close treats structured daemon startup failure as no-op without relying on message text', async () => {
143
+ const result = await runCliCaptureWithErrorDetails(
144
+ ['close'],
145
+ {
146
+ kind: 'daemon_startup_failed',
147
+ lockPath: '/tmp/daemon.lock',
148
+ },
149
+ 'daemon bootstrap failed',
150
+ );
151
+ assert.equal(result.code, null);
152
+ assert.equal(result.daemonCalls, 1);
153
+ assert.equal(result.stdout, '');
154
+ assert.equal(result.stderr, '');
155
+ });
package/src/cli.ts CHANGED
@@ -179,9 +179,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
179
179
  const bundleId = app.bundleId ?? app.package;
180
180
  const name = app.name ?? app.label;
181
181
  if (name && bundleId) return `${name} (${bundleId})`;
182
- if (bundleId && typeof app.launchable === 'boolean') {
183
- return `${bundleId} (launchable=${app.launchable})`;
184
- }
185
182
  if (bundleId) return String(bundleId);
186
183
  return JSON.stringify(app);
187
184
  }
@@ -199,7 +196,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
199
196
  const pkg = (data as any)?.package;
200
197
  const activity = (data as any)?.activity;
201
198
  if (platform === 'ios') {
202
- process.stdout.write(`Foreground app: ${appName ?? appBundleId}\n`);
199
+ process.stdout.write(`Foreground app: ${appName ?? appBundleId ?? 'unknown'}\n`);
203
200
  if (appBundleId) process.stdout.write(`Bundle: ${appBundleId}\n`);
204
201
  if (source) process.stdout.write(`Source: ${source}\n`);
205
202
  if (logTailStopper) logTailStopper();
@@ -220,6 +217,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
220
217
  throw new AppError(response.error.code as any, response.error.message, response.error.details);
221
218
  } catch (err) {
222
219
  const appErr = asAppError(err);
220
+ if (command === 'close' && isDaemonStartupFailure(appErr)) {
221
+ if (flags.json) {
222
+ printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } });
223
+ }
224
+ if (logTailStopper) logTailStopper();
225
+ return;
226
+ }
223
227
  if (flags.json) {
224
228
  printJson({
225
229
  success: false,
@@ -229,9 +233,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
229
233
  printHumanError(appErr);
230
234
  if (flags.verbose) {
231
235
  try {
232
- const fs = await import('node:fs');
233
- const os = await import('node:os');
234
- const path = await import('node:path');
235
236
  const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log');
236
237
  if (fs.existsSync(logPath)) {
237
238
  const content = fs.readFileSync(logPath, 'utf8');
@@ -251,6 +252,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
251
252
  }
252
253
  }
253
254
 
255
+ function isDaemonStartupFailure(error: AppError): boolean {
256
+ if (error.code !== 'COMMAND_FAILED') return false;
257
+ if (error.details?.kind === 'daemon_startup_failed') return true;
258
+ if (!error.message.toLowerCase().includes('failed to start daemon')) return false;
259
+ return typeof error.details?.infoPath === 'string' || typeof error.details?.lockPath === 'string';
260
+ }
261
+
254
262
  const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url;
255
263
  if (isDirectRun) {
256
264
  runCli(process.argv.slice(2)).catch((err) => {
@@ -268,15 +276,23 @@ function startDaemonLogTail(): (() => void) | null {
268
276
  const interval = setInterval(() => {
269
277
  if (stopped) return;
270
278
  if (!fs.existsSync(logPath)) return;
271
- const stats = fs.statSync(logPath);
272
- if (stats.size <= offset) return;
273
- const fd = fs.openSync(logPath, 'r');
274
- const buffer = Buffer.alloc(stats.size - offset);
275
- fs.readSync(fd, buffer, 0, buffer.length, offset);
276
- fs.closeSync(fd);
277
- offset = stats.size;
278
- if (buffer.length > 0) {
279
- process.stdout.write(buffer.toString('utf8'));
279
+ try {
280
+ const stats = fs.statSync(logPath);
281
+ if (stats.size < offset) offset = 0;
282
+ if (stats.size <= offset) return;
283
+ const fd = fs.openSync(logPath, 'r');
284
+ try {
285
+ const buffer = Buffer.alloc(stats.size - offset);
286
+ fs.readSync(fd, buffer, 0, buffer.length, offset);
287
+ offset = stats.size;
288
+ if (buffer.length > 0) {
289
+ process.stdout.write(buffer.toString('utf8'));
290
+ }
291
+ } finally {
292
+ fs.closeSync(fd);
293
+ }
294
+ } catch {
295
+ // Best-effort tailing should not crash CLI flow.
280
296
  }
281
297
  }, 200);
282
298
  return () => {
@@ -33,7 +33,7 @@ test('iOS simulator-only commands reject iOS devices and Android', () => {
33
33
  });
34
34
 
35
35
  test('simulator-only iOS commands with Android support reject iOS devices', () => {
36
- for (const cmd of ['apps', 'reinstall', 'record', 'settings', 'swipe']) {
36
+ for (const cmd of ['reinstall', 'record', 'settings', 'swipe']) {
37
37
  assert.equal(isCommandSupportedOnDevice(cmd, iosSimulator), true, `${cmd} on iOS sim`);
38
38
  assert.equal(isCommandSupportedOnDevice(cmd, iosDevice), false, `${cmd} on iOS device`);
39
39
  assert.equal(isCommandSupportedOnDevice(cmd, androidDevice), true, `${cmd} on Android`);
@@ -43,6 +43,7 @@ test('simulator-only iOS commands with Android support reject iOS devices', () =
43
43
  test('core commands support iOS simulator, iOS device, and Android', () => {
44
44
  for (const cmd of [
45
45
  'app-switcher',
46
+ 'apps',
46
47
  'back',
47
48
  'boot',
48
49
  'click',
@@ -0,0 +1,25 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { dispatchCommand } from '../dispatch.ts';
4
+ import { AppError } from '../../utils/errors.ts';
5
+ import type { DeviceInfo } from '../../utils/device.ts';
6
+
7
+ test('dispatch open rejects URL as first argument when second URL is provided', async () => {
8
+ const device: DeviceInfo = {
9
+ platform: 'ios',
10
+ id: 'sim-1',
11
+ name: 'iPhone 15',
12
+ kind: 'simulator',
13
+ booted: true,
14
+ };
15
+
16
+ await assert.rejects(
17
+ () => dispatchCommand(device, 'open', ['myapp://first', 'myapp://second']),
18
+ (error: unknown) => {
19
+ assert.equal(error instanceof AppError, true);
20
+ assert.equal((error as AppError).code, 'INVALID_ARGS');
21
+ assert.match((error as AppError).message, /requires an app target as the first argument/i);
22
+ return true;
23
+ },
24
+ );
25
+ });
@@ -1,6 +1,11 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { isDeepLinkTarget } from '../open-target.ts';
3
+ import {
4
+ IOS_SAFARI_BUNDLE_ID,
5
+ isDeepLinkTarget,
6
+ isWebUrl,
7
+ resolveIosDeviceDeepLinkBundleId,
8
+ } from '../open-target.ts';
4
9
 
5
10
  test('isDeepLinkTarget accepts URL-style deep links', () => {
6
11
  assert.equal(isDeepLinkTarget('myapp://home'), true);
@@ -14,3 +19,37 @@ test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
14
19
  assert.equal(isDeepLinkTarget('settings'), false);
15
20
  assert.equal(isDeepLinkTarget('http:/x'), false);
16
21
  });
22
+
23
+ test('isWebUrl accepts http and https URLs', () => {
24
+ assert.equal(isWebUrl('https://example.com'), true);
25
+ assert.equal(isWebUrl('http://example.com/path'), true);
26
+ assert.equal(isWebUrl('https://example.com/path?q=1'), true);
27
+ });
28
+
29
+ test('isWebUrl rejects custom schemes and non-URLs', () => {
30
+ assert.equal(isWebUrl('myapp://home'), false);
31
+ assert.equal(isWebUrl('tel:123456789'), false);
32
+ assert.equal(isWebUrl('com.example.app'), false);
33
+ assert.equal(isWebUrl('settings'), false);
34
+ });
35
+
36
+ test('resolveIosDeviceDeepLinkBundleId prefers active app context', () => {
37
+ assert.equal(
38
+ resolveIosDeviceDeepLinkBundleId('com.example.app', 'myapp://home'),
39
+ 'com.example.app',
40
+ );
41
+ });
42
+
43
+ test('resolveIosDeviceDeepLinkBundleId falls back to Safari for web URLs', () => {
44
+ assert.equal(
45
+ resolveIosDeviceDeepLinkBundleId(undefined, 'https://example.com/path'),
46
+ IOS_SAFARI_BUNDLE_ID,
47
+ );
48
+ });
49
+
50
+ test('resolveIosDeviceDeepLinkBundleId returns undefined for custom scheme without app context', () => {
51
+ assert.equal(
52
+ resolveIosDeviceDeepLinkBundleId(undefined, 'myapp://home'),
53
+ undefined,
54
+ );
55
+ });
@@ -17,7 +17,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
17
17
  alert: { ios: { simulator: true }, android: {} },
18
18
  pinch: { ios: { simulator: true }, android: {} },
19
19
  'app-switcher': { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
20
- apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
20
+ apps: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
21
21
  back: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
22
22
  boot: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
23
23
  click: { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true } },
@@ -16,6 +16,7 @@ import { getInteractor, type RunnerContext } from '../utils/interactors.ts';
16
16
  import { runIosRunnerCommand } from '../platforms/ios/runner-client.ts';
17
17
  import { snapshotAx } from '../platforms/ios/ax-snapshot.ts';
18
18
  import { setIosSetting } from '../platforms/ios/index.ts';
19
+ import { isDeepLinkTarget } from './open-target.ts';
19
20
  import type { RawSnapshotNode } from '../utils/snapshot.ts';
20
21
  import type { CliFlags } from '../utils/command-schema.ts';
21
22
 
@@ -89,10 +90,31 @@ export async function dispatchCommand(
89
90
  switch (command) {
90
91
  case 'open': {
91
92
  const app = positionals[0];
93
+ const url = positionals[1];
94
+ if (positionals.length > 2) {
95
+ throw new AppError('INVALID_ARGS', 'open accepts at most two arguments: <app|url> [url]');
96
+ }
92
97
  if (!app) {
93
98
  await interactor.openDevice();
94
99
  return { app: null };
95
100
  }
101
+ if (url !== undefined) {
102
+ if (device.platform !== 'ios') {
103
+ throw new AppError('INVALID_ARGS', 'open <app> <url> is supported only on iOS');
104
+ }
105
+ if (isDeepLinkTarget(app)) {
106
+ throw new AppError('INVALID_ARGS', 'open <app> <url> requires an app target as the first argument');
107
+ }
108
+ if (!isDeepLinkTarget(url)) {
109
+ throw new AppError('INVALID_ARGS', 'open <app> <url> requires a valid URL target');
110
+ }
111
+ await interactor.open(app, {
112
+ activity: context?.activity,
113
+ appBundleId: context?.appBundleId,
114
+ url,
115
+ });
116
+ return { app, url };
117
+ }
96
118
  await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
97
119
  return { app };
98
120
  }
@@ -11,3 +11,17 @@ export function isDeepLinkTarget(input: string): boolean {
11
11
  }
12
12
  return true;
13
13
  }
14
+
15
+ export function isWebUrl(input: string): boolean {
16
+ const scheme = input.trim().split(':')[0]?.toLowerCase();
17
+ return scheme === 'http' || scheme === 'https';
18
+ }
19
+
20
+ export const IOS_SAFARI_BUNDLE_ID = 'com.apple.mobilesafari';
21
+
22
+ export function resolveIosDeviceDeepLinkBundleId(appBundleId: string | undefined, url: string): string | undefined {
23
+ const bundleId = appBundleId?.trim();
24
+ if (bundleId) return bundleId;
25
+ if (isWebUrl(url)) return IOS_SAFARI_BUNDLE_ID;
26
+ return undefined;
27
+ }
@@ -0,0 +1,52 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { parseIosReadyPayload, resolveIosReadyHint } from '../device-ready.ts';
4
+
5
+ test('parseIosReadyPayload reads tunnelState from direct connectionProperties', () => {
6
+ const parsed = parseIosReadyPayload({
7
+ result: {
8
+ connectionProperties: {
9
+ tunnelState: 'connected',
10
+ },
11
+ },
12
+ });
13
+ assert.equal(parsed.tunnelState, 'connected');
14
+ });
15
+
16
+ test('parseIosReadyPayload reads tunnelState from nested device connectionProperties', () => {
17
+ const parsed = parseIosReadyPayload({
18
+ result: {
19
+ device: {
20
+ connectionProperties: {
21
+ tunnelState: 'connecting',
22
+ },
23
+ },
24
+ },
25
+ });
26
+ assert.equal(parsed.tunnelState, 'connecting');
27
+ });
28
+
29
+ test('parseIosReadyPayload returns empty payload for malformed input', () => {
30
+ assert.deepEqual(parseIosReadyPayload(null), {});
31
+ assert.deepEqual(parseIosReadyPayload({}), {});
32
+ assert.deepEqual(
33
+ parseIosReadyPayload({
34
+ result: { connectionProperties: { tunnelState: 123 } },
35
+ }),
36
+ {},
37
+ );
38
+ });
39
+
40
+ test('resolveIosReadyHint maps known connection errors', () => {
41
+ const connecting = resolveIosReadyHint('', 'Device is busy (Connecting to iPhone)');
42
+ assert.match(connecting, /still connecting/i);
43
+
44
+ const coreDeviceTimeout = resolveIosReadyHint('CoreDeviceService timed out', '');
45
+ assert.match(coreDeviceTimeout, /coredevice service/i);
46
+ });
47
+
48
+ test('resolveIosReadyHint falls back to generic guidance', () => {
49
+ const hint = resolveIosReadyHint('unexpected failure', '');
50
+ assert.match(hint, /unlocked/i);
51
+ assert.match(hint, /xcode/i);
52
+ });