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.
Files changed (42) hide show
  1. package/README.md +58 -16
  2. package/dist/src/bin.js +35 -96
  3. package/dist/src/daemon.js +16 -15
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
  5. package/ios-runner/README.md +1 -1
  6. package/package.json +1 -1
  7. package/skills/agent-device/SKILL.md +32 -14
  8. package/skills/agent-device/references/permissions.md +15 -1
  9. package/skills/agent-device/references/session-management.md +2 -0
  10. package/skills/agent-device/references/snapshot-refs.md +2 -0
  11. package/skills/agent-device/references/video-recording.md +2 -0
  12. package/src/cli.ts +7 -3
  13. package/src/core/__tests__/capabilities.test.ts +11 -6
  14. package/src/core/__tests__/open-target.test.ts +16 -0
  15. package/src/core/capabilities.ts +26 -20
  16. package/src/core/dispatch.ts +110 -31
  17. package/src/core/open-target.ts +13 -0
  18. package/src/daemon/__tests__/app-state.test.ts +138 -0
  19. package/src/daemon/__tests__/session-store.test.ts +24 -0
  20. package/src/daemon/app-state.ts +37 -38
  21. package/src/daemon/context.ts +12 -0
  22. package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
  23. package/src/daemon/handlers/__tests__/session.test.ts +226 -5
  24. package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
  25. package/src/daemon/handlers/interaction.ts +37 -0
  26. package/src/daemon/handlers/record-trace.ts +1 -1
  27. package/src/daemon/handlers/session.ts +96 -26
  28. package/src/daemon/handlers/snapshot.ts +21 -3
  29. package/src/daemon/session-store.ts +11 -0
  30. package/src/daemon-client.ts +14 -6
  31. package/src/daemon.ts +1 -1
  32. package/src/platforms/android/__tests__/index.test.ts +67 -1
  33. package/src/platforms/android/index.ts +41 -0
  34. package/src/platforms/ios/__tests__/index.test.ts +24 -0
  35. package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
  36. package/src/platforms/ios/devices.ts +40 -18
  37. package/src/platforms/ios/index.ts +70 -5
  38. package/src/platforms/ios/runner-client.ts +329 -42
  39. package/src/utils/__tests__/args.test.ts +175 -0
  40. package/src/utils/args.ts +174 -212
  41. package/src/utils/command-schema.ts +591 -0
  42. 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
- const devicectlAvailable = await whichCmd('xcrun');
42
- if (devicectlAvailable) {
43
- try {
44
- const result = await runCmd('xcrun', ['devicectl', 'list', 'devices', '--json']);
45
- const payload = JSON.parse(result.stdout as string) as {
46
- devices: { identifier: string; name: string; platform: string }[];
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
- for (const device of payload.devices ?? []) {
49
- if (device.platform?.toLowerCase().includes('ios')) {
50
- devices.push({
51
- platform: 'ios',
52
- id: device.identifier,
53
- name: device.name,
54
- kind: 'device',
55
- booted: true,
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
- } catch {
60
- // Ignore devicectl failures; simulators are still supported.
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(device: DeviceInfo, app: string): Promise<void> {
39
- const bundleId = await resolveIosApp(device, app);
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
- await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId]);
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 in v1`,
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: TIMEOUT_PROFILES.ios_boot.operationMs,
433
+ timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS,
369
434
  });
370
435
  if (result.exitCode !== 0) return null;
371
436
  try {