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
|
@@ -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),
|