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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
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: `open [app]` (`open` handles target selection + boot/activation in the normal flow)
30
+ 1. Open app or deep link: `open [app|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
@@ -41,8 +41,11 @@ npx -y agent-device
41
41
  agent-device boot # Ensure target is booted/ready without opening app
42
42
  agent-device boot --platform ios # Boot iOS simulator
43
43
  agent-device boot --platform android # Boot Android emulator/device target
44
- agent-device open [app] # Boot device/simulator; optionally launch app
45
- agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity
44
+ agent-device open [app|url] # Boot device/simulator; optionally launch app or deep link URL
45
+ agent-device open [app] --relaunch # Terminate app process first, then launch (fresh runtime)
46
+ agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity (app targets only)
47
+ agent-device open "myapp://home" --platform android # Android deep link
48
+ agent-device open "https://example.com" --platform ios # iOS simulator deep link
46
49
  agent-device close [app] # Close app or just end session
47
50
  agent-device reinstall <app> <path> # Uninstall + install app in one command
48
51
  agent-device session list # List active sessions
@@ -134,12 +137,14 @@ agent-device screenshot out.png
134
137
  ### Deterministic replay and updating
135
138
 
136
139
  ```bash
140
+ agent-device open App --relaunch # Fresh app process restart in the current session
137
141
  agent-device open App --save-script # Save session script (.ad) on close
138
142
  agent-device replay ./session.ad # Run deterministic replay from .ad script
139
143
  agent-device replay -u ./session.ad # Update selector drift and rewrite .ad script in place
140
144
  ```
141
145
 
142
146
  `replay` reads `.ad` recordings.
147
+ `--relaunch` controls launch semantics; `--save-script` controls recording. Combine only when both are needed.
143
148
 
144
149
  ### Trace logs (AX/XCTest)
145
150
 
@@ -168,10 +173,13 @@ agent-device apps --platform android --user-installed
168
173
  - Prefer `snapshot -i` to reduce output size.
169
174
  - On iOS, `xctest` is the default and does not require Accessibility permission.
170
175
  - If XCTest returns 0 nodes (foreground app changed), agent-device falls back to AX when available.
171
- - `open <app>` can be used within an existing session to switch apps and update the session bundle id.
176
+ - `open <app|url>` can be used within an existing session to switch apps or open deep links.
177
+ - `open <app>` updates session app bundle context; URL opens do not set an app bundle id.
178
+ - Use `open <app> --relaunch` during React Native/Fast Refresh debugging when you need a fresh app process without ending the session.
172
179
  - If AX returns the Simulator window or empty tree, restart Simulator or use `--backend xctest`.
173
180
  - Use `--session <name>` for parallel sessions; avoid device contention.
174
- - Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK).
181
+ - Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
182
+ - iOS deep-link opens are simulator-only in v1.
175
183
  - Use `fill` when you want clear-then-type semantics.
176
184
  - Use `type` when you want to append/enter text without clearing.
177
185
  - On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.
@@ -14,6 +14,7 @@ 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
+ - 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.
17
18
  - For deterministic replay scripts, prefer selector-based actions and assertions.
18
19
  - Use `replay -u` to update selector drift during maintenance.
19
20
 
@@ -0,0 +1,16 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { isDeepLinkTarget } from '../open-target.ts';
4
+
5
+ test('isDeepLinkTarget accepts URL-style deep links', () => {
6
+ assert.equal(isDeepLinkTarget('myapp://home'), true);
7
+ assert.equal(isDeepLinkTarget('https://example.com'), true);
8
+ assert.equal(isDeepLinkTarget('tel:123456789'), true);
9
+ assert.equal(isDeepLinkTarget('mailto:test@example.com'), true);
10
+ });
11
+
12
+ test('isDeepLinkTarget rejects app identifiers and malformed URLs', () => {
13
+ assert.equal(isDeepLinkTarget('com.example.app'), false);
14
+ assert.equal(isDeepLinkTarget('settings'), false);
15
+ assert.equal(isDeepLinkTarget('http:/x'), false);
16
+ });
@@ -34,6 +34,7 @@ export type CommandFlags = {
34
34
  snapshotRaw?: boolean;
35
35
  snapshotBackend?: 'ax' | 'xctest';
36
36
  saveScript?: boolean;
37
+ relaunch?: boolean;
37
38
  noRecord?: boolean;
38
39
  appsFilter?: 'launchable' | 'user-installed' | 'all';
39
40
  appsMetadata?: boolean;
@@ -106,7 +107,7 @@ export async function dispatchCommand(
106
107
  await interactor.openDevice();
107
108
  return { app: null };
108
109
  }
109
- await interactor.open(app, { activity: context?.activity });
110
+ await interactor.open(app, { activity: context?.activity, appBundleId: context?.appBundleId });
110
111
  return { app };
111
112
  }
112
113
  case 'close': {
@@ -0,0 +1,13 @@
1
+ export function isDeepLinkTarget(input: string): boolean {
2
+ const value = input.trim();
3
+ if (!value) return false;
4
+ if (/\s/.test(value)) return false;
5
+ const match = /^([A-Za-z][A-Za-z0-9+.-]*):(.+)$/.exec(value);
6
+ if (!match) return false;
7
+ const scheme = match[1]?.toLowerCase();
8
+ const rest = match[2] ?? '';
9
+ if (scheme === 'http' || scheme === 'https' || scheme === 'ws' || scheme === 'wss' || scheme === 'ftp' || scheme === 'ftps') {
10
+ return rest.startsWith('//');
11
+ }
12
+ return true;
13
+ }
@@ -93,3 +93,27 @@ test('saveScript flag enables .ad session log writing', () => {
93
93
  const files = fs.readdirSync(root);
94
94
  assert.equal(files.filter((file) => file.endsWith('.ad')).length, 1);
95
95
  });
96
+
97
+ test('writeSessionLog persists open --relaunch in script output', () => {
98
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-log-relaunch-'));
99
+ const store = new SessionStore(root);
100
+ const session = makeSession('default');
101
+ store.recordAction(session, {
102
+ command: 'open',
103
+ positionals: ['Settings'],
104
+ flags: { platform: 'ios', saveScript: true, relaunch: true },
105
+ result: {},
106
+ });
107
+ store.recordAction(session, {
108
+ command: 'close',
109
+ positionals: [],
110
+ flags: { platform: 'ios' },
111
+ result: {},
112
+ });
113
+
114
+ store.writeSessionLog(session);
115
+ const scriptFile = fs.readdirSync(root).find((file) => file.endsWith('.ad'));
116
+ assert.ok(scriptFile);
117
+ const script = fs.readFileSync(path.join(root, scriptFile!), 'utf8');
118
+ assert.match(script, /open "Settings" --relaunch/);
119
+ });
@@ -365,6 +365,87 @@ test('replay --update heals selector in is command', async () => {
365
365
  assert.ok(rewrittenSelector.includes('auth_continue'));
366
366
  });
367
367
 
368
+ test('replay --update heals numeric get text drift when numeric candidate value is unique', async () => {
369
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-heal-get-numeric-'));
370
+ const sessionsDir = path.join(tempRoot, 'sessions');
371
+ const replayPath = path.join(tempRoot, 'replay.ad');
372
+ const sessionStore = new SessionStore(sessionsDir);
373
+ const sessionName = 'heal-get-numeric-session';
374
+ sessionStore.set(sessionName, makeSession(sessionName));
375
+
376
+ writeReplayFile(replayPath, {
377
+ ts: Date.now(),
378
+ command: 'get',
379
+ positionals: ['text', 'role="statictext" label="2" || label="2"'],
380
+ flags: {},
381
+ result: {},
382
+ });
383
+
384
+ const invokeCalls: string[] = [];
385
+ const invoke = async (request: DaemonRequest): Promise<DaemonResponse> => {
386
+ if (request.command !== 'get') {
387
+ return { ok: false, error: { code: 'INVALID_ARGS', message: `unexpected command ${request.command}` } };
388
+ }
389
+ const selector = request.positionals?.[1] ?? '';
390
+ invokeCalls.push(selector);
391
+ if (selector.includes('label="2"')) {
392
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: 'selector stale' } };
393
+ }
394
+ if (selector.includes('label="20"')) {
395
+ return { ok: true, data: { text: '20' } };
396
+ }
397
+ return { ok: false, error: { code: 'COMMAND_FAILED', message: 'unexpected selector' } };
398
+ };
399
+
400
+ const dispatch = async (): Promise<Record<string, unknown> | void> => {
401
+ return {
402
+ nodes: [
403
+ {
404
+ index: 0,
405
+ type: 'XCUIElementTypeStaticText',
406
+ label: '20',
407
+ rect: { x: 0, y: 100, width: 100, height: 24 },
408
+ enabled: true,
409
+ hittable: true,
410
+ },
411
+ {
412
+ index: 1,
413
+ type: 'XCUIElementTypeStaticText',
414
+ label: 'Version: 0.84.0',
415
+ rect: { x: 0, y: 200, width: 220, height: 17 },
416
+ enabled: true,
417
+ hittable: true,
418
+ },
419
+ ],
420
+ truncated: false,
421
+ backend: 'xctest',
422
+ };
423
+ };
424
+
425
+ const response = await handleSessionCommands({
426
+ req: {
427
+ token: 't',
428
+ session: sessionName,
429
+ command: 'replay',
430
+ positionals: [replayPath],
431
+ flags: { replayUpdate: true },
432
+ },
433
+ sessionName,
434
+ logPath: path.join(tempRoot, 'daemon.log'),
435
+ sessionStore,
436
+ invoke,
437
+ dispatch,
438
+ });
439
+
440
+ assert.ok(response);
441
+ assert.equal(response.ok, true, JSON.stringify(response));
442
+ if (response.ok) {
443
+ assert.equal(response.data?.healed, 1);
444
+ assert.equal(response.data?.replayed, 1);
445
+ }
446
+ assert.equal(invokeCalls.length, 2);
447
+ });
448
+
368
449
  test('replay rejects legacy JSON payload files', async () => {
369
450
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-json-rejected-'));
370
451
  const sessionsDir = path.join(tempRoot, 'sessions');
@@ -120,3 +120,221 @@ test('boot succeeds for supported device in session', async () => {
120
120
  assert.equal(response.data?.booted, true);
121
121
  }
122
122
  });
123
+
124
+ test('open URL on existing iOS session clears stale app bundle id', async () => {
125
+ const sessionStore = makeSessionStore();
126
+ const sessionName = 'ios-session';
127
+ sessionStore.set(
128
+ sessionName,
129
+ {
130
+ ...makeSession(sessionName, {
131
+ platform: 'ios',
132
+ id: 'sim-1',
133
+ name: 'iPhone 15',
134
+ kind: 'simulator',
135
+ booted: true,
136
+ }),
137
+ appBundleId: 'com.example.old',
138
+ appName: 'Old App',
139
+ },
140
+ );
141
+
142
+ let dispatchedContext: Record<string, unknown> | undefined;
143
+ const response = await handleSessionCommands({
144
+ req: {
145
+ token: 't',
146
+ session: sessionName,
147
+ command: 'open',
148
+ positionals: ['https://example.com/path'],
149
+ flags: {},
150
+ },
151
+ sessionName,
152
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
153
+ sessionStore,
154
+ invoke: noopInvoke,
155
+ dispatch: async (_device, _command, _positionals, _out, context) => {
156
+ dispatchedContext = context as Record<string, unknown> | undefined;
157
+ return {};
158
+ },
159
+ ensureReady: async () => {},
160
+ });
161
+
162
+ assert.ok(response);
163
+ assert.equal(response?.ok, true);
164
+ const updated = sessionStore.get(sessionName);
165
+ assert.equal(updated?.appBundleId, undefined);
166
+ assert.equal(updated?.appName, 'https://example.com/path');
167
+ assert.equal(dispatchedContext?.appBundleId, undefined);
168
+ });
169
+
170
+ test('open app on existing iOS session resolves and stores bundle id', async () => {
171
+ const sessionStore = makeSessionStore();
172
+ const sessionName = 'ios-session';
173
+ sessionStore.set(
174
+ sessionName,
175
+ {
176
+ ...makeSession(sessionName, {
177
+ platform: 'ios',
178
+ id: 'sim-1',
179
+ name: 'iPhone 15',
180
+ kind: 'simulator',
181
+ booted: true,
182
+ }),
183
+ appBundleId: 'com.example.old',
184
+ appName: 'Old App',
185
+ },
186
+ );
187
+
188
+ let dispatchedContext: Record<string, unknown> | undefined;
189
+ const response = await handleSessionCommands({
190
+ req: {
191
+ token: 't',
192
+ session: sessionName,
193
+ command: 'open',
194
+ positionals: ['settings'],
195
+ flags: {},
196
+ },
197
+ sessionName,
198
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
199
+ sessionStore,
200
+ invoke: noopInvoke,
201
+ dispatch: async (_device, _command, _positionals, _out, context) => {
202
+ dispatchedContext = context as Record<string, unknown> | undefined;
203
+ return {};
204
+ },
205
+ ensureReady: async () => {},
206
+ });
207
+
208
+ assert.ok(response);
209
+ assert.equal(response?.ok, true);
210
+ const updated = sessionStore.get(sessionName);
211
+ assert.equal(updated?.appBundleId, 'com.apple.Preferences');
212
+ assert.equal(updated?.appName, 'settings');
213
+ assert.equal(dispatchedContext?.appBundleId, 'com.apple.Preferences');
214
+ });
215
+
216
+ test('open --relaunch closes and reopens active session app', async () => {
217
+ const sessionStore = makeSessionStore();
218
+ const sessionName = 'android-session';
219
+ sessionStore.set(
220
+ sessionName,
221
+ {
222
+ ...makeSession(sessionName, {
223
+ platform: 'android',
224
+ id: 'emulator-5554',
225
+ name: 'Pixel Emulator',
226
+ kind: 'emulator',
227
+ booted: true,
228
+ }),
229
+ appName: 'com.example.app',
230
+ },
231
+ );
232
+
233
+ const calls: Array<{ command: string; positionals: string[] }> = [];
234
+ const response = await handleSessionCommands({
235
+ req: {
236
+ token: 't',
237
+ session: sessionName,
238
+ command: 'open',
239
+ positionals: [],
240
+ flags: { relaunch: true },
241
+ },
242
+ sessionName,
243
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
244
+ sessionStore,
245
+ invoke: noopInvoke,
246
+ dispatch: async (_device, command, positionals) => {
247
+ calls.push({ command, positionals });
248
+ return {};
249
+ },
250
+ });
251
+
252
+ assert.ok(response);
253
+ assert.equal(response?.ok, true);
254
+ assert.equal(calls.length, 2);
255
+ assert.deepEqual(calls[0], { command: 'close', positionals: ['com.example.app'] });
256
+ assert.deepEqual(calls[1], { command: 'open', positionals: ['com.example.app'] });
257
+ });
258
+
259
+ test('open --relaunch rejects URL targets', async () => {
260
+ const sessionStore = makeSessionStore();
261
+ const response = await handleSessionCommands({
262
+ req: {
263
+ token: 't',
264
+ session: 'default',
265
+ command: 'open',
266
+ positionals: ['https://example.com/path'],
267
+ flags: { relaunch: true },
268
+ },
269
+ sessionName: 'default',
270
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
271
+ sessionStore,
272
+ invoke: noopInvoke,
273
+ });
274
+
275
+ assert.ok(response);
276
+ assert.equal(response?.ok, false);
277
+ if (response && !response.ok) {
278
+ assert.equal(response.error.code, 'INVALID_ARGS');
279
+ assert.match(response.error.message, /does not support URL targets/i);
280
+ }
281
+ });
282
+
283
+ test('open --relaunch fails without app when no session exists', async () => {
284
+ const sessionStore = makeSessionStore();
285
+ const response = await handleSessionCommands({
286
+ req: {
287
+ token: 't',
288
+ session: 'default',
289
+ command: 'open',
290
+ positionals: [],
291
+ flags: { relaunch: true },
292
+ },
293
+ sessionName: 'default',
294
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
295
+ sessionStore,
296
+ invoke: noopInvoke,
297
+ });
298
+
299
+ assert.ok(response);
300
+ assert.equal(response?.ok, false);
301
+ if (response && !response.ok) {
302
+ assert.equal(response.error.code, 'INVALID_ARGS');
303
+ assert.match(response.error.message, /requires an app argument/i);
304
+ }
305
+ });
306
+
307
+ test('replay parses open --relaunch flag and replays open with relaunch semantics', async () => {
308
+ const sessionStore = makeSessionStore();
309
+ const replayRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-relaunch-'));
310
+ const replayPath = path.join(replayRoot, 'relaunch.ad');
311
+ fs.writeFileSync(replayPath, 'open "Settings" --relaunch\n');
312
+
313
+ const invoked: DaemonRequest[] = [];
314
+ const response = await handleSessionCommands({
315
+ req: {
316
+ token: 't',
317
+ session: 'default',
318
+ command: 'replay',
319
+ positionals: [replayPath],
320
+ flags: {},
321
+ },
322
+ sessionName: 'default',
323
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
324
+ sessionStore,
325
+ invoke: async (req) => {
326
+ invoked.push(req);
327
+ return { ok: true, data: {} };
328
+ },
329
+ });
330
+
331
+ assert.ok(response);
332
+ assert.equal(response?.ok, true);
333
+ if (response && response.ok) {
334
+ assert.equal(response.data?.replayed, 1);
335
+ }
336
+ assert.equal(invoked.length, 1);
337
+ assert.equal(invoked[0]?.command, 'open');
338
+ assert.deepEqual(invoked[0]?.positionals, ['Settings']);
339
+ assert.equal(invoked[0]?.flags?.relaunch, true);
340
+ });
@@ -310,6 +310,7 @@ export async function handleInteractionCommands(params: {
310
310
  platform: session.device.platform,
311
311
  requireRect: false,
312
312
  requireUnique: true,
313
+ disambiguateAmbiguous: sub === 'text',
313
314
  });
314
315
  if (!resolved) {
315
316
  return {