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/README.md +14 -3
- package/dist/src/bin.js +4 -3
- package/dist/src/daemon.js +15 -15
- 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__/replay-heal.test.ts +81 -0
- package/src/daemon/handlers/__tests__/session.test.ts +218 -0
- package/src/daemon/handlers/interaction.ts +1 -0
- package/src/daemon/handlers/session.ts +155 -27
- package/src/daemon/selectors.ts +0 -1
- 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
|
@@ -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
|
|
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
|
};
|
|
@@ -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:
|
|
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
|
}
|
package/src/daemon/selectors.ts
CHANGED
|
@@ -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(
|
|
39
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
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]
|
|
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
|
|
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)
|
package/src/utils/interactors.ts
CHANGED
|
@@ -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),
|