agent-device 0.4.1 → 0.4.2

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 (41) hide show
  1. package/README.md +18 -12
  2. package/dist/src/bin.js +32 -32
  3. package/dist/src/daemon.js +18 -14
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
  5. package/package.json +1 -1
  6. package/skills/agent-device/SKILL.md +19 -13
  7. package/skills/agent-device/references/permissions.md +7 -2
  8. package/skills/agent-device/references/session-management.md +3 -1
  9. package/src/__tests__/cli-close.test.ts +155 -0
  10. package/src/cli.ts +32 -16
  11. package/src/core/__tests__/capabilities.test.ts +2 -1
  12. package/src/core/__tests__/dispatch-open.test.ts +25 -0
  13. package/src/core/__tests__/open-target.test.ts +40 -1
  14. package/src/core/capabilities.ts +1 -1
  15. package/src/core/dispatch.ts +22 -0
  16. package/src/core/open-target.ts +14 -0
  17. package/src/daemon/__tests__/device-ready.test.ts +52 -0
  18. package/src/daemon/device-ready.ts +146 -4
  19. package/src/daemon/handlers/__tests__/session.test.ts +477 -0
  20. package/src/daemon/handlers/session.ts +196 -91
  21. package/src/daemon/session-store.ts +0 -2
  22. package/src/daemon-client.ts +118 -18
  23. package/src/platforms/android/__tests__/index.test.ts +118 -1
  24. package/src/platforms/android/index.ts +77 -47
  25. package/src/platforms/ios/__tests__/index.test.ts +292 -4
  26. package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
  27. package/src/platforms/ios/apps.ts +358 -0
  28. package/src/platforms/ios/config.ts +28 -0
  29. package/src/platforms/ios/devicectl.ts +134 -0
  30. package/src/platforms/ios/devices.ts +15 -2
  31. package/src/platforms/ios/index.ts +20 -455
  32. package/src/platforms/ios/runner-client.ts +72 -16
  33. package/src/platforms/ios/simulator.ts +164 -0
  34. package/src/utils/__tests__/args.test.ts +20 -2
  35. package/src/utils/__tests__/daemon-client.test.ts +21 -4
  36. package/src/utils/args.ts +6 -1
  37. package/src/utils/command-schema.ts +7 -14
  38. package/src/utils/interactors.ts +2 -2
  39. package/src/utils/timeouts.ts +9 -0
  40. package/src/daemon/__tests__/app-state.test.ts +0 -138
  41. package/src/daemon/app-state.ts +0 -65
@@ -0,0 +1,164 @@
1
+ import type { DeviceInfo } from '../../utils/device.ts';
2
+ import { AppError } from '../../utils/errors.ts';
3
+ import { runCmd } from '../../utils/exec.ts';
4
+ import { Deadline, retryWithPolicy, type RetryTelemetryEvent } from '../../utils/retry.ts';
5
+ import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
6
+
7
+ import { IOS_BOOT_TIMEOUT_MS, IOS_SIMCTL_LIST_TIMEOUT_MS, RETRY_LOGS_ENABLED } from './config.ts';
8
+
9
+ export function ensureSimulator(device: DeviceInfo, command: string): void {
10
+ if (device.kind !== 'simulator') {
11
+ throw new AppError('UNSUPPORTED_OPERATION', `${command} is only supported on iOS simulators`);
12
+ }
13
+ }
14
+
15
+ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
16
+ if (device.kind !== 'simulator') return;
17
+
18
+ const state = await getSimulatorState(device.id);
19
+ if (state === 'Booted') return;
20
+
21
+ const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
22
+ let bootResult:
23
+ | {
24
+ stdout: string;
25
+ stderr: string;
26
+ exitCode: number;
27
+ }
28
+ | undefined;
29
+ let bootStatusResult:
30
+ | {
31
+ stdout: string;
32
+ stderr: string;
33
+ exitCode: number;
34
+ }
35
+ | undefined;
36
+
37
+ try {
38
+ await retryWithPolicy(
39
+ async ({ deadline: attemptDeadline }) => {
40
+ if (attemptDeadline?.isExpired()) {
41
+ throw new AppError('COMMAND_FAILED', 'iOS simulator boot deadline exceeded', {
42
+ timeoutMs: IOS_BOOT_TIMEOUT_MS,
43
+ });
44
+ }
45
+
46
+ const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? IOS_BOOT_TIMEOUT_MS);
47
+ const boot = await runCmd('xcrun', ['simctl', 'boot', device.id], {
48
+ allowFailure: true,
49
+ timeoutMs: remainingMs,
50
+ });
51
+ bootResult = {
52
+ stdout: String(boot.stdout ?? ''),
53
+ stderr: String(boot.stderr ?? ''),
54
+ exitCode: boot.exitCode,
55
+ };
56
+
57
+ const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
58
+ const bootAlreadyDone =
59
+ bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
60
+
61
+ if (bootResult.exitCode !== 0 && !bootAlreadyDone) {
62
+ throw new AppError('COMMAND_FAILED', 'simctl boot failed', {
63
+ stdout: bootResult.stdout,
64
+ stderr: bootResult.stderr,
65
+ exitCode: bootResult.exitCode,
66
+ });
67
+ }
68
+
69
+ const bootStatus = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
70
+ allowFailure: true,
71
+ timeoutMs: remainingMs,
72
+ });
73
+ bootStatusResult = {
74
+ stdout: String(bootStatus.stdout ?? ''),
75
+ stderr: String(bootStatus.stderr ?? ''),
76
+ exitCode: bootStatus.exitCode,
77
+ };
78
+
79
+ if (bootStatusResult.exitCode !== 0) {
80
+ throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
81
+ stdout: bootStatusResult.stdout,
82
+ stderr: bootStatusResult.stderr,
83
+ exitCode: bootStatusResult.exitCode,
84
+ });
85
+ }
86
+
87
+ const nextState = await getSimulatorState(device.id);
88
+ if (nextState !== 'Booted') {
89
+ throw new AppError('COMMAND_FAILED', 'Simulator is still booting', { state: nextState });
90
+ }
91
+ },
92
+ {
93
+ maxAttempts: 3,
94
+ baseDelayMs: 500,
95
+ maxDelayMs: 2000,
96
+ jitter: 0.2,
97
+ shouldRetry: (error) => {
98
+ const reason = classifyBootFailure({
99
+ error,
100
+ stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
101
+ stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
102
+ context: { platform: 'ios', phase: 'boot' },
103
+ });
104
+ return reason !== 'IOS_BOOT_TIMEOUT' && reason !== 'CI_RESOURCE_STARVATION_SUSPECTED';
105
+ },
106
+ },
107
+ {
108
+ deadline,
109
+ phase: 'boot',
110
+ classifyReason: (error) =>
111
+ classifyBootFailure({
112
+ error,
113
+ stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
114
+ stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
115
+ context: { platform: 'ios', phase: 'boot' },
116
+ }),
117
+ onEvent: (event: RetryTelemetryEvent) => {
118
+ if (!RETRY_LOGS_ENABLED) return;
119
+ process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
120
+ },
121
+ },
122
+ );
123
+ } catch (error) {
124
+ const reason = classifyBootFailure({
125
+ error,
126
+ stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
127
+ stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
128
+ context: { platform: 'ios', phase: 'boot' },
129
+ });
130
+
131
+ throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
132
+ platform: 'ios',
133
+ deviceId: device.id,
134
+ timeoutMs: IOS_BOOT_TIMEOUT_MS,
135
+ elapsedMs: deadline.elapsedMs(),
136
+ reason,
137
+ hint: bootFailureHint(reason),
138
+ boot: bootResult,
139
+ bootstatus: bootStatusResult,
140
+ });
141
+ }
142
+ }
143
+
144
+ export async function getSimulatorState(udid: string): Promise<string | null> {
145
+ const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], {
146
+ allowFailure: true,
147
+ timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS,
148
+ });
149
+ if (result.exitCode !== 0) return null;
150
+
151
+ try {
152
+ const payload = JSON.parse(String(result.stdout ?? '')) as {
153
+ devices: Record<string, { udid: string; state: string }[]>;
154
+ };
155
+
156
+ for (const runtime of Object.values(payload.devices ?? {})) {
157
+ const match = runtime.find((entry) => entry.udid === udid);
158
+ if (match) return match.state;
159
+ }
160
+ return null;
161
+ } catch {
162
+ return null;
163
+ }
164
+ }
@@ -1,6 +1,6 @@
1
1
  import test from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { parseArgs, usage, usageForCommand } from '../args.ts';
3
+ import { parseArgs, toDaemonFlags, usage, usageForCommand } from '../args.ts';
4
4
  import { AppError } from '../errors.ts';
5
5
  import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts';
6
6
  import { listCapabilityCommands } from '../../core/capabilities.ts';
@@ -12,6 +12,14 @@ test('parseArgs recognizes --relaunch', () => {
12
12
  assert.equal(parsed.flags.relaunch, true);
13
13
  });
14
14
 
15
+ test('toDaemonFlags strips CLI-only flags', () => {
16
+ const parsed = parseArgs(['open', 'settings', '--json']);
17
+ const daemonFlags = toDaemonFlags(parsed.flags);
18
+ assert.equal(Object.hasOwn(daemonFlags, 'json'), false);
19
+ assert.equal(Object.hasOwn(daemonFlags, 'help'), false);
20
+ assert.equal(Object.hasOwn(daemonFlags, 'version'), false);
21
+ });
22
+
15
23
  test('parseArgs accepts --save-script with optional path value', () => {
16
24
  const withoutPath = parseArgs(['open', 'settings', '--save-script']);
17
25
  assert.equal(withoutPath.command, 'open');
@@ -93,7 +101,17 @@ test('usage includes --relaunch flag', () => {
93
101
  assert.match(usage(), /--relaunch/);
94
102
  assert.match(usage(), /--save-script \[path\]/);
95
103
  assert.match(usage(), /pinch <scale> \[x\] \[y\]/);
96
- assert.match(usage(), /--metadata/);
104
+ assert.doesNotMatch(usage(), /--metadata/);
105
+ });
106
+
107
+ test('apps defaults to --all filter and allows overrides', () => {
108
+ const defaultFilter = parseArgs(['apps'], { strictFlags: true });
109
+ assert.equal(defaultFilter.command, 'apps');
110
+ assert.equal(defaultFilter.flags.appsFilter, 'all');
111
+
112
+ const userInstalled = parseArgs(['apps', '--user-installed'], { strictFlags: true });
113
+ assert.equal(userInstalled.command, 'apps');
114
+ assert.equal(userInstalled.flags.appsFilter, 'user-installed');
97
115
  });
98
116
 
99
117
  test('every capability command has a parser schema entry', () => {
@@ -4,7 +4,7 @@ import { spawn } from 'node:child_process';
4
4
  import fs from 'node:fs';
5
5
  import os from 'node:os';
6
6
  import path from 'node:path';
7
- import { resolveDaemonRequestTimeoutMs } from '../../daemon-client.ts';
7
+ import { resolveDaemonRequestTimeoutMs, resolveDaemonStartupHint } from '../../daemon-client.ts';
8
8
  import {
9
9
  isProcessAlive,
10
10
  readProcessCommand,
@@ -12,14 +12,31 @@ import {
12
12
  waitForProcessExit,
13
13
  } from '../process-identity.ts';
14
14
 
15
- test('resolveDaemonRequestTimeoutMs defaults to 180000', () => {
16
- assert.equal(resolveDaemonRequestTimeoutMs(undefined), 180000);
15
+ test('resolveDaemonRequestTimeoutMs defaults to 90000', () => {
16
+ assert.equal(resolveDaemonRequestTimeoutMs(undefined), 90000);
17
17
  });
18
18
 
19
19
  test('resolveDaemonRequestTimeoutMs enforces minimum timeout', () => {
20
20
  assert.equal(resolveDaemonRequestTimeoutMs('100'), 1000);
21
21
  assert.equal(resolveDaemonRequestTimeoutMs('2500'), 2500);
22
- assert.equal(resolveDaemonRequestTimeoutMs('invalid'), 180000);
22
+ assert.equal(resolveDaemonRequestTimeoutMs('invalid'), 90000);
23
+ });
24
+
25
+ test('resolveDaemonStartupHint prefers stale lock guidance when lock exists without info', () => {
26
+ const hint = resolveDaemonStartupHint({ hasInfo: false, hasLock: true });
27
+ assert.match(hint, /daemon\.lock/i);
28
+ assert.match(hint, /delete/i);
29
+ });
30
+
31
+ test('resolveDaemonStartupHint covers stale info+lock pair', () => {
32
+ const hint = resolveDaemonStartupHint({ hasInfo: true, hasLock: true });
33
+ assert.match(hint, /daemon\.json/i);
34
+ assert.match(hint, /daemon\.lock/i);
35
+ });
36
+
37
+ test('resolveDaemonStartupHint falls back to daemon.json guidance', () => {
38
+ const hint = resolveDaemonStartupHint({ hasInfo: true, hasLock: false });
39
+ assert.match(hint, /daemon\.json/i);
23
40
  });
24
41
 
25
42
  test('stopDaemonProcessForTakeover terminates a matching daemon process', async (t) => {
package/src/utils/args.ts CHANGED
@@ -221,7 +221,12 @@ function formatUnsupportedFlagMessage(command: string | null, unsupported: strin
221
221
  }
222
222
 
223
223
  export function toDaemonFlags(flags: CliFlags): Omit<CliFlags, 'json' | 'help' | 'version'> {
224
- const { json: _json, help: _help, version: _version, ...daemonFlags } = flags;
224
+ const {
225
+ json: _json,
226
+ help: _help,
227
+ version: _version,
228
+ ...daemonFlags
229
+ } = flags;
225
230
  return daemonFlags;
226
231
  }
227
232
 
@@ -13,8 +13,7 @@ export type CliFlags = {
13
13
  snapshotScope?: string;
14
14
  snapshotRaw?: boolean;
15
15
  snapshotBackend?: 'ax' | 'xctest';
16
- appsFilter?: 'launchable' | 'user-installed' | 'all';
17
- appsMetadata?: boolean;
16
+ appsFilter?: 'user-installed' | 'all';
18
17
  count?: number;
19
18
  intervalMs?: number;
20
19
  holdMs?: number;
@@ -232,7 +231,7 @@ export const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
232
231
  type: 'enum',
233
232
  setValue: 'user-installed',
234
233
  usageLabel: '--user-installed',
235
- usageDescription: 'Apps: list user-installed packages (Android only)',
234
+ usageDescription: 'Apps: list user-installed apps',
236
235
  },
237
236
  {
238
237
  key: 'appsFilter',
@@ -240,14 +239,7 @@ export const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
240
239
  type: 'enum',
241
240
  setValue: 'all',
242
241
  usageLabel: '--all',
243
- usageDescription: 'Apps: list all packages (Android only)',
244
- },
245
- {
246
- key: 'appsMetadata',
247
- names: ['--metadata'],
248
- type: 'boolean',
249
- usageLabel: '--metadata',
250
- usageDescription: 'Apps: return metadata objects',
242
+ usageDescription: 'Apps: list all apps (include system/default apps)',
251
243
  },
252
244
  {
253
245
  key: 'snapshotInteractiveOnly',
@@ -323,7 +315,7 @@ export const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
323
315
  },
324
316
  open: {
325
317
  description: 'Boot device/simulator; optionally launch app or deep link URL',
326
- positionalArgs: ['appOrUrl?'],
318
+ positionalArgs: ['appOrUrl?', 'url?'],
327
319
  allowedFlags: ['activity', 'saveScript', 'relaunch'],
328
320
  },
329
321
  close: {
@@ -348,9 +340,10 @@ export const COMMAND_SCHEMAS: Record<string, CommandSchema> = {
348
340
  skipCapabilityCheck: true,
349
341
  },
350
342
  apps: {
351
- description: 'List installed apps (Android launchable by default, iOS simulator)',
343
+ description: 'List installed apps (includes default/system apps by default)',
352
344
  positionalArgs: [],
353
- allowedFlags: ['appsFilter', 'appsMetadata'],
345
+ allowedFlags: ['appsFilter'],
346
+ defaults: { appsFilter: 'all' },
354
347
  },
355
348
  appstate: {
356
349
  description: 'Show foreground app/activity',
@@ -30,7 +30,7 @@ export type RunnerContext = {
30
30
  };
31
31
 
32
32
  export type Interactor = {
33
- open(app: string, options?: { activity?: string; appBundleId?: string }): Promise<void>;
33
+ open(app: string, options?: { activity?: string; appBundleId?: string; url?: string }): Promise<void>;
34
34
  openDevice(): Promise<void>;
35
35
  close(app: string): Promise<void>;
36
36
  tap(x: number, y: number): Promise<void>;
@@ -63,7 +63,7 @@ export function getInteractor(device: DeviceInfo, runnerContext: RunnerContext):
63
63
  };
64
64
  case 'ios':
65
65
  return {
66
- open: (app, options) => openIosApp(device, app, { appBundleId: options?.appBundleId }),
66
+ open: (app, options) => openIosApp(device, app, { appBundleId: options?.appBundleId, url: options?.url }),
67
67
  openDevice: () => openIosDevice(device),
68
68
  close: (app) => closeIosApp(device, app),
69
69
  screenshot: (outPath) => screenshotIos(device, outPath),
@@ -0,0 +1,9 @@
1
+ export function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
2
+ if (!raw) return fallback;
3
+ const parsed = Number(raw);
4
+ if (!Number.isFinite(parsed)) return fallback;
5
+ return Math.max(min, Math.floor(parsed));
6
+ }
7
+
8
+ /** Alias for `resolveTimeoutMs` — semantically marks the caller expects seconds. */
9
+ export const resolveTimeoutSeconds = resolveTimeoutMs;
@@ -1,138 +0,0 @@
1
- import test from 'node:test';
2
- import assert from 'node:assert/strict';
3
- import type { DeviceInfo } from '../../utils/device.ts';
4
- import { resolveIosAppStateFromSnapshots } from '../app-state.ts';
5
- import type { CommandFlags, dispatchCommand } from '../../core/dispatch.ts';
6
-
7
- const iosSimulator: DeviceInfo = {
8
- platform: 'ios',
9
- id: 'sim-1',
10
- name: 'iPhone Simulator',
11
- kind: 'simulator',
12
- booted: true,
13
- };
14
-
15
- const iosDevice: DeviceInfo = {
16
- platform: 'ios',
17
- id: '00008110-000E12341234002E',
18
- name: 'iPhone',
19
- kind: 'device',
20
- booted: true,
21
- };
22
-
23
- test('appstate uses xctest first on iOS simulator', async () => {
24
- const backends: string[] = [];
25
- const fakeDispatch: typeof dispatchCommand = async (_device, _command, _positionals, _outPath, context) => {
26
- backends.push(context?.snapshotBackend ?? 'unknown');
27
- return {
28
- nodes: [
29
- {
30
- type: 'XCUIElementTypeApplication',
31
- label: 'Settings',
32
- identifier: 'com.apple.Preferences',
33
- },
34
- ],
35
- };
36
- };
37
-
38
- const result = await resolveIosAppStateFromSnapshots(
39
- iosSimulator,
40
- '/tmp/daemon.log',
41
- undefined,
42
- {} as CommandFlags,
43
- fakeDispatch,
44
- );
45
-
46
- assert.deepEqual(backends, ['xctest']);
47
- assert.equal(result.source, 'snapshot-xctest');
48
- assert.equal(result.appBundleId, 'com.apple.Preferences');
49
- });
50
-
51
- test('appstate does not try ax on iOS device when xctest succeeds', async () => {
52
- const backends: string[] = [];
53
- const fakeDispatch: typeof dispatchCommand = async (_device, _command, _positionals, _outPath, context) => {
54
- backends.push(context?.snapshotBackend ?? 'unknown');
55
- return {
56
- nodes: [
57
- {
58
- type: 'XCUIElementTypeApplication',
59
- label: 'Settings',
60
- identifier: 'com.apple.Preferences',
61
- },
62
- ],
63
- };
64
- };
65
-
66
- const result = await resolveIosAppStateFromSnapshots(
67
- iosDevice,
68
- '/tmp/daemon.log',
69
- undefined,
70
- {} as CommandFlags,
71
- fakeDispatch,
72
- );
73
-
74
- assert.deepEqual(backends, ['xctest']);
75
- assert.equal(result.source, 'snapshot-xctest');
76
- assert.equal(result.appName, 'Settings');
77
- });
78
-
79
- test('appstate fails on simulator when xctest is empty', async () => {
80
- const backends: string[] = [];
81
- const fakeDispatch: typeof dispatchCommand = async (_device, _command, _positionals, _outPath, context) => {
82
- backends.push(context?.snapshotBackend ?? 'unknown');
83
- return { nodes: [] };
84
- };
85
-
86
- await assert.rejects(
87
- resolveIosAppStateFromSnapshots(
88
- iosSimulator,
89
- '/tmp/daemon.log',
90
- undefined,
91
- {} as CommandFlags,
92
- fakeDispatch,
93
- ),
94
- /not recommended/,
95
- );
96
- assert.deepEqual(backends, ['xctest']);
97
- });
98
-
99
- test('appstate fails on simulator when xctest throws', async () => {
100
- const backends: string[] = [];
101
- const fakeDispatch: typeof dispatchCommand = async (_device, _command, _positionals, _outPath, context) => {
102
- const backend = context?.snapshotBackend ?? 'unknown';
103
- backends.push(backend);
104
- throw new Error('xctest failed');
105
- };
106
-
107
- await assert.rejects(
108
- resolveIosAppStateFromSnapshots(
109
- iosSimulator,
110
- '/tmp/daemon.log',
111
- undefined,
112
- {} as CommandFlags,
113
- fakeDispatch,
114
- ),
115
- /not recommended/,
116
- );
117
- assert.deepEqual(backends, ['xctest']);
118
- });
119
-
120
- test('appstate fails on device when xctest throws', async () => {
121
- const backends: string[] = [];
122
- const fakeDispatch: typeof dispatchCommand = async (_device, _command, _positionals, _outPath, context) => {
123
- backends.push(context?.snapshotBackend ?? 'unknown');
124
- throw new Error('xctest failed');
125
- };
126
-
127
- await assert.rejects(
128
- resolveIosAppStateFromSnapshots(
129
- iosDevice,
130
- '/tmp/daemon.log',
131
- undefined,
132
- {} as CommandFlags,
133
- fakeDispatch,
134
- ),
135
- /not recommended/,
136
- );
137
- assert.deepEqual(backends, ['xctest']);
138
- });
@@ -1,65 +0,0 @@
1
- import { dispatchCommand, type CommandFlags } from '../core/dispatch.ts';
2
- import type { DeviceInfo } from '../utils/device.ts';
3
- import { attachRefs, type RawSnapshotNode } from '../utils/snapshot.ts';
4
- import { AppError } from '../utils/errors.ts';
5
- import { contextFromFlags } from './context.ts';
6
- import { normalizeType } from './snapshot-processing.ts';
7
-
8
- export async function resolveIosAppStateFromSnapshots(
9
- device: DeviceInfo,
10
- logPath: string,
11
- traceLogPath: string | undefined,
12
- flags: CommandFlags | undefined,
13
- dispatch: typeof dispatchCommand = dispatchCommand,
14
- ): Promise<{ appName: string; appBundleId?: string; source: 'snapshot-xctest' }> {
15
- let xctestResult: { nodes?: RawSnapshotNode[] } | undefined;
16
- try {
17
- xctestResult = (await dispatch(device, 'snapshot', [], flags?.out, {
18
- ...contextFromFlags(
19
- logPath,
20
- {
21
- ...flags,
22
- snapshotDepth: 1,
23
- snapshotCompact: true,
24
- snapshotBackend: 'xctest',
25
- },
26
- undefined,
27
- traceLogPath,
28
- ),
29
- })) as { nodes?: RawSnapshotNode[] };
30
- } catch (error) {
31
- const cause = error instanceof Error ? error.message : String(error);
32
- throw new AppError(
33
- 'COMMAND_FAILED',
34
- 'Unable to resolve iOS app state from XCTest snapshot. You can try snapshot --backend ax for diagnostics, but AX snapshots are not recommended.',
35
- { cause },
36
- );
37
- }
38
-
39
- const xcNode = extractAppNodeFromSnapshot(xctestResult);
40
- if (xcNode?.appName || xcNode?.appBundleId) {
41
- return {
42
- appName: xcNode.appName ?? xcNode.appBundleId ?? 'unknown',
43
- appBundleId: xcNode.appBundleId,
44
- source: 'snapshot-xctest',
45
- };
46
- }
47
-
48
- throw new AppError(
49
- 'COMMAND_FAILED',
50
- 'Unable to resolve iOS app state from XCTest snapshot (0 nodes or missing application node). You can try snapshot --backend ax for diagnostics, but AX snapshots are not recommended.',
51
- );
52
- }
53
-
54
- function extractAppNodeFromSnapshot(
55
- data: { nodes?: RawSnapshotNode[] } | undefined,
56
- ): { appName?: string; appBundleId?: string } | null {
57
- const rawNodes = data?.nodes ?? [];
58
- const nodes = attachRefs(rawNodes);
59
- const appNode = nodes.find((node) => normalizeType(node.type ?? '') === 'application') ?? nodes[0];
60
- if (!appNode) return null;
61
- const appName = appNode.label?.trim();
62
- const appBundleId = appNode.identifier?.trim();
63
- if (!appName && !appBundleId) return null;
64
- return { appName: appName || undefined, appBundleId: appBundleId || undefined };
65
- }