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.
- package/README.md +14 -3
- package/dist/src/bin.js +4 -3
- package/dist/src/daemon.js +13 -13
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +13 -5
- package/skills/agent-device/references/session-management.md +1 -0
- package/src/core/__tests__/open-target.test.ts +16 -0
- package/src/core/dispatch.ts +2 -1
- package/src/core/open-target.ts +13 -0
- package/src/daemon/__tests__/session-store.test.ts +24 -0
- package/src/daemon/handlers/__tests__/session.test.ts +218 -0
- package/src/daemon/handlers/session.ts +95 -25
- package/src/daemon/session-store.ts +11 -0
- package/src/platforms/android/__tests__/index.test.ts +22 -1
- package/src/platforms/android/index.ts +18 -0
- package/src/platforms/ios/__tests__/index.test.ts +24 -0
- package/src/platforms/ios/index.ts +69 -4
- package/src/platforms/ios/runner-client.ts +10 -2
- package/src/utils/__tests__/args.test.ts +14 -0
- package/src/utils/args.ts +8 -2
- package/src/utils/interactors.ts +2 -2
package/package.json
CHANGED
|
@@ -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]
|
|
45
|
-
agent-device open [app] --
|
|
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
|
|
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
|
+
});
|
package/src/core/dispatch.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -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';
|
|
@@ -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
|
|
292
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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',
|
|
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:
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
};
|
|
@@ -822,6 +870,19 @@ function parseReplayScriptLine(line: string): SessionAction | null {
|
|
|
822
870
|
return action;
|
|
823
871
|
}
|
|
824
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
|
+
|
|
825
886
|
if (command === 'click') {
|
|
826
887
|
if (args.length === 0) return action;
|
|
827
888
|
const target = args[0];
|
|
@@ -948,6 +1009,15 @@ function formatReplayActionLine(action: SessionAction): string {
|
|
|
948
1009
|
}
|
|
949
1010
|
return parts.join(' ');
|
|
950
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
|
+
}
|
|
951
1021
|
for (const positional of action.positionals ?? []) {
|
|
952
1022
|
parts.push(formatReplayArg(positional));
|
|
953
1023
|
}
|
|
@@ -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) {
|