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.
- package/README.md +18 -12
- package/dist/src/bin.js +32 -32
- package/dist/src/daemon.js +18 -14
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +19 -13
- package/skills/agent-device/references/permissions.md +7 -2
- package/skills/agent-device/references/session-management.md +3 -1
- package/src/__tests__/cli-close.test.ts +155 -0
- package/src/cli.ts +32 -16
- package/src/core/__tests__/capabilities.test.ts +2 -1
- package/src/core/__tests__/dispatch-open.test.ts +25 -0
- package/src/core/__tests__/open-target.test.ts +40 -1
- package/src/core/capabilities.ts +1 -1
- package/src/core/dispatch.ts +22 -0
- package/src/core/open-target.ts +14 -0
- package/src/daemon/__tests__/device-ready.test.ts +52 -0
- package/src/daemon/device-ready.ts +146 -4
- package/src/daemon/handlers/__tests__/session.test.ts +477 -0
- package/src/daemon/handlers/session.ts +196 -91
- package/src/daemon/session-store.ts +0 -2
- package/src/daemon-client.ts +118 -18
- package/src/platforms/android/__tests__/index.test.ts +118 -1
- package/src/platforms/android/index.ts +77 -47
- package/src/platforms/ios/__tests__/index.test.ts +292 -4
- package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
- package/src/platforms/ios/apps.ts +358 -0
- package/src/platforms/ios/config.ts +28 -0
- package/src/platforms/ios/devicectl.ts +134 -0
- package/src/platforms/ios/devices.ts +15 -2
- package/src/platforms/ios/index.ts +20 -455
- package/src/platforms/ios/runner-client.ts +72 -16
- package/src/platforms/ios/simulator.ts +164 -0
- package/src/utils/__tests__/args.test.ts +20 -2
- package/src/utils/__tests__/daemon-client.test.ts +21 -4
- package/src/utils/args.ts +6 -1
- package/src/utils/command-schema.ts +7 -14
- package/src/utils/interactors.ts +2 -2
- package/src/utils/timeouts.ts +9 -0
- package/src/daemon/__tests__/app-state.test.ts +0 -138
- 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.
|
|
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
|
|
16
|
-
assert.equal(resolveDaemonRequestTimeoutMs(undefined),
|
|
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'),
|
|
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 {
|
|
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?: '
|
|
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
|
|
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
|
|
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 (
|
|
343
|
+
description: 'List installed apps (includes default/system apps by default)',
|
|
352
344
|
positionalArgs: [],
|
|
353
|
-
allowedFlags: ['appsFilter'
|
|
345
|
+
allowedFlags: ['appsFilter'],
|
|
346
|
+
defaults: { appsFilter: 'all' },
|
|
354
347
|
},
|
|
355
348
|
appstate: {
|
|
356
349
|
description: 'Show foreground app/activity',
|
package/src/utils/interactors.ts
CHANGED
|
@@ -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
|
-
});
|
package/src/daemon/app-state.ts
DELETED
|
@@ -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
|
-
}
|