agent-device 0.3.4 → 0.4.0
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 +58 -16
- package/dist/src/bin.js +35 -96
- package/dist/src/daemon.js +16 -15
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
- package/ios-runner/README.md +1 -1
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +32 -14
- package/skills/agent-device/references/permissions.md +15 -1
- package/skills/agent-device/references/session-management.md +2 -0
- package/skills/agent-device/references/snapshot-refs.md +2 -0
- package/skills/agent-device/references/video-recording.md +2 -0
- package/src/cli.ts +7 -3
- package/src/core/__tests__/capabilities.test.ts +11 -6
- package/src/core/__tests__/open-target.test.ts +16 -0
- package/src/core/capabilities.ts +26 -20
- package/src/core/dispatch.ts +110 -31
- package/src/core/open-target.ts +13 -0
- package/src/daemon/__tests__/app-state.test.ts +138 -0
- package/src/daemon/__tests__/session-store.test.ts +24 -0
- package/src/daemon/app-state.ts +37 -38
- package/src/daemon/context.ts +12 -0
- package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
- package/src/daemon/handlers/__tests__/session.test.ts +226 -5
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
- package/src/daemon/handlers/interaction.ts +37 -0
- package/src/daemon/handlers/record-trace.ts +1 -1
- package/src/daemon/handlers/session.ts +96 -26
- package/src/daemon/handlers/snapshot.ts +21 -3
- package/src/daemon/session-store.ts +11 -0
- package/src/daemon-client.ts +14 -6
- package/src/daemon.ts +1 -1
- package/src/platforms/android/__tests__/index.test.ts +67 -1
- package/src/platforms/android/index.ts +41 -0
- package/src/platforms/ios/__tests__/index.test.ts +24 -0
- package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
- package/src/platforms/ios/devices.ts +40 -18
- package/src/platforms/ios/index.ts +70 -5
- package/src/platforms/ios/runner-client.ts +329 -42
- package/src/utils/__tests__/args.test.ts +175 -0
- package/src/utils/args.ts +174 -212
- package/src/utils/command-schema.ts +591 -0
- package/src/utils/interactors.ts +13 -3
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
4
|
+
import {
|
|
5
|
+
assertSafeDerivedCleanup,
|
|
6
|
+
resolveRunnerBuildDestination,
|
|
7
|
+
resolveRunnerDestination,
|
|
8
|
+
resolveRunnerMaxConcurrentDestinationsFlag,
|
|
9
|
+
resolveRunnerSigningBuildSettings,
|
|
10
|
+
} from '../runner-client.ts';
|
|
11
|
+
|
|
12
|
+
const iosSimulator: DeviceInfo = {
|
|
13
|
+
platform: 'ios',
|
|
14
|
+
id: 'sim-1',
|
|
15
|
+
name: 'iPhone Simulator',
|
|
16
|
+
kind: 'simulator',
|
|
17
|
+
booted: true,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const iosDevice: DeviceInfo = {
|
|
21
|
+
platform: 'ios',
|
|
22
|
+
id: '00008110-000E12341234002E',
|
|
23
|
+
name: 'iPhone',
|
|
24
|
+
kind: 'device',
|
|
25
|
+
booted: true,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
test('resolveRunnerDestination uses simulator destination for simulators', () => {
|
|
29
|
+
assert.equal(resolveRunnerDestination(iosSimulator), 'platform=iOS Simulator,id=sim-1');
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('resolveRunnerDestination uses device destination for physical devices', () => {
|
|
33
|
+
assert.equal(
|
|
34
|
+
resolveRunnerDestination(iosDevice),
|
|
35
|
+
'platform=iOS,id=00008110-000E12341234002E',
|
|
36
|
+
);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('resolveRunnerBuildDestination uses generic iOS destination for physical devices', () => {
|
|
40
|
+
assert.equal(resolveRunnerBuildDestination(iosDevice), 'generic/platform=iOS');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('resolveRunnerMaxConcurrentDestinationsFlag uses simulator flag for simulators', () => {
|
|
44
|
+
assert.equal(
|
|
45
|
+
resolveRunnerMaxConcurrentDestinationsFlag(iosSimulator),
|
|
46
|
+
'-maximum-concurrent-test-simulator-destinations',
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('resolveRunnerMaxConcurrentDestinationsFlag uses device flag for physical devices', () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
resolveRunnerMaxConcurrentDestinationsFlag(iosDevice),
|
|
53
|
+
'-maximum-concurrent-test-device-destinations',
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('resolveRunnerSigningBuildSettings returns empty args without env overrides', () => {
|
|
58
|
+
assert.deepEqual(resolveRunnerSigningBuildSettings({}), []);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test('resolveRunnerSigningBuildSettings enables automatic signing for device builds without forcing identity', () => {
|
|
62
|
+
assert.deepEqual(resolveRunnerSigningBuildSettings({}, true), [
|
|
63
|
+
'CODE_SIGN_STYLE=Automatic',
|
|
64
|
+
]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('resolveRunnerSigningBuildSettings ignores device signing overrides for simulator builds', () => {
|
|
68
|
+
assert.deepEqual(resolveRunnerSigningBuildSettings({
|
|
69
|
+
AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345',
|
|
70
|
+
AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development',
|
|
71
|
+
AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile',
|
|
72
|
+
}, false), []);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('resolveRunnerSigningBuildSettings applies optional overrides when provided', () => {
|
|
76
|
+
const settings = resolveRunnerSigningBuildSettings({
|
|
77
|
+
AGENT_DEVICE_IOS_TEAM_ID: 'ABCDE12345',
|
|
78
|
+
AGENT_DEVICE_IOS_SIGNING_IDENTITY: 'Apple Development',
|
|
79
|
+
AGENT_DEVICE_IOS_PROVISIONING_PROFILE: 'My Profile',
|
|
80
|
+
}, true);
|
|
81
|
+
assert.deepEqual(settings, [
|
|
82
|
+
'CODE_SIGN_STYLE=Automatic',
|
|
83
|
+
'DEVELOPMENT_TEAM=ABCDE12345',
|
|
84
|
+
'CODE_SIGN_IDENTITY=Apple Development',
|
|
85
|
+
'PROVISIONING_PROFILE_SPECIFIER=My Profile',
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('assertSafeDerivedCleanup allows cleaning when no override is set', () => {
|
|
90
|
+
assert.doesNotThrow(() => {
|
|
91
|
+
assertSafeDerivedCleanup('/tmp/derived', {});
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('assertSafeDerivedCleanup rejects cleaning override path by default', () => {
|
|
96
|
+
assert.throws(
|
|
97
|
+
() => {
|
|
98
|
+
assertSafeDerivedCleanup('/tmp/custom', {
|
|
99
|
+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: '/tmp/custom',
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
/Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically/,
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('assertSafeDerivedCleanup allows cleaning override path with explicit opt-in', () => {
|
|
107
|
+
assert.doesNotThrow(() => {
|
|
108
|
+
assertSafeDerivedCleanup('/tmp/custom', {
|
|
109
|
+
AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH: '/tmp/custom',
|
|
110
|
+
AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN: '1',
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import { promises as fs } from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
1
4
|
import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
2
5
|
import { AppError } from '../../utils/errors.ts';
|
|
3
6
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
@@ -38,26 +41,45 @@ export async function listIosDevices(): Promise<DeviceInfo[]> {
|
|
|
38
41
|
throw new AppError('COMMAND_FAILED', 'Failed to parse simctl devices JSON', undefined, err);
|
|
39
42
|
}
|
|
40
43
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
let jsonPath: string | null = null;
|
|
45
|
+
try {
|
|
46
|
+
jsonPath = path.join(
|
|
47
|
+
os.tmpdir(),
|
|
48
|
+
`agent-device-devicectl-${process.pid}-${Date.now()}.json`,
|
|
49
|
+
);
|
|
50
|
+
await runCmd('xcrun', ['devicectl', 'list', 'devices', '--json-output', jsonPath]);
|
|
51
|
+
const jsonText = await fs.readFile(jsonPath, 'utf8');
|
|
52
|
+
const payload = JSON.parse(jsonText) as {
|
|
53
|
+
result?: {
|
|
54
|
+
devices?: Array<{
|
|
55
|
+
identifier?: string;
|
|
56
|
+
name?: string;
|
|
57
|
+
hardwareProperties?: { platform?: string; udid?: string };
|
|
58
|
+
deviceProperties?: { name?: string };
|
|
59
|
+
connectionProperties?: { tunnelState?: string };
|
|
60
|
+
}>;
|
|
47
61
|
};
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
62
|
+
};
|
|
63
|
+
for (const device of payload.result?.devices ?? []) {
|
|
64
|
+
const platform = device.hardwareProperties?.platform ?? '';
|
|
65
|
+
if (platform.toLowerCase().includes('ios')) {
|
|
66
|
+
const id = device.hardwareProperties?.udid ?? device.identifier ?? '';
|
|
67
|
+
const name = device.name ?? device.deviceProperties?.name ?? id;
|
|
68
|
+
if (!id) continue;
|
|
69
|
+
devices.push({
|
|
70
|
+
platform: 'ios',
|
|
71
|
+
id,
|
|
72
|
+
name,
|
|
73
|
+
kind: 'device',
|
|
74
|
+
booted: true,
|
|
75
|
+
});
|
|
58
76
|
}
|
|
59
|
-
}
|
|
60
|
-
|
|
77
|
+
}
|
|
78
|
+
} catch {
|
|
79
|
+
// Ignore devicectl failures; simulators are still supported.
|
|
80
|
+
} finally {
|
|
81
|
+
if (jsonPath) {
|
|
82
|
+
await fs.rm(jsonPath, { force: true }).catch(() => {});
|
|
61
83
|
}
|
|
62
84
|
}
|
|
63
85
|
|
|
@@ -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');
|
|
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', [
|
|
@@ -196,7 +249,7 @@ function ensureSimulator(device: DeviceInfo, command: string): void {
|
|
|
196
249
|
if (device.kind !== 'simulator') {
|
|
197
250
|
throw new AppError(
|
|
198
251
|
'UNSUPPORTED_OPERATION',
|
|
199
|
-
`${command} is only supported on iOS simulators
|
|
252
|
+
`${command} is only supported on iOS simulators`,
|
|
200
253
|
);
|
|
201
254
|
}
|
|
202
255
|
}
|
|
@@ -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 {
|