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
|
@@ -1,455 +1,20 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
);
|
|
28
|
-
const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
|
|
29
|
-
|
|
30
|
-
export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
|
|
31
|
-
const trimmed = app.trim();
|
|
32
|
-
if (trimmed.includes('.')) return trimmed;
|
|
33
|
-
|
|
34
|
-
const alias = ALIASES[trimmed.toLowerCase()];
|
|
35
|
-
if (alias) return alias;
|
|
36
|
-
|
|
37
|
-
if (device.kind === 'simulator') {
|
|
38
|
-
const list = await listSimulatorApps(device);
|
|
39
|
-
const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase());
|
|
40
|
-
if (matches.length === 1) return matches[0].bundleId;
|
|
41
|
-
if (matches.length > 1) {
|
|
42
|
-
throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches });
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
throw new AppError('APP_NOT_INSTALLED', `No app found matching "${app}"`);
|
|
47
|
-
}
|
|
48
|
-
|
|
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));
|
|
65
|
-
if (device.kind === 'simulator') {
|
|
66
|
-
await ensureBootedSimulator(device);
|
|
67
|
-
await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
|
|
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
|
-
);
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
await runCmd('xcrun', [
|
|
100
|
-
'devicectl',
|
|
101
|
-
'device',
|
|
102
|
-
'process',
|
|
103
|
-
'launch',
|
|
104
|
-
'--device',
|
|
105
|
-
device.id,
|
|
106
|
-
bundleId,
|
|
107
|
-
]);
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
export async function openIosDevice(device: DeviceInfo): Promise<void> {
|
|
111
|
-
if (device.kind !== 'simulator') return;
|
|
112
|
-
const state = await getSimulatorState(device.id);
|
|
113
|
-
if (state === 'Booted') return;
|
|
114
|
-
await ensureBootedSimulator(device);
|
|
115
|
-
await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
export async function closeIosApp(device: DeviceInfo, app: string): Promise<void> {
|
|
119
|
-
const bundleId = await resolveIosApp(device, app);
|
|
120
|
-
if (device.kind === 'simulator') {
|
|
121
|
-
await ensureBootedSimulator(device);
|
|
122
|
-
const result = await runCmd('xcrun', ['simctl', 'terminate', device.id, bundleId], {
|
|
123
|
-
allowFailure: true,
|
|
124
|
-
});
|
|
125
|
-
if (result.exitCode !== 0) {
|
|
126
|
-
const stderr = result.stderr.toLowerCase();
|
|
127
|
-
if (stderr.includes('found nothing to terminate')) return;
|
|
128
|
-
throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, {
|
|
129
|
-
cmd: 'xcrun',
|
|
130
|
-
args: ['simctl', 'terminate', device.id, bundleId],
|
|
131
|
-
stdout: result.stdout,
|
|
132
|
-
stderr: result.stderr,
|
|
133
|
-
exitCode: result.exitCode,
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
await runCmd('xcrun', [
|
|
139
|
-
'devicectl',
|
|
140
|
-
'device',
|
|
141
|
-
'process',
|
|
142
|
-
'terminate',
|
|
143
|
-
'--device',
|
|
144
|
-
device.id,
|
|
145
|
-
bundleId,
|
|
146
|
-
]);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> {
|
|
150
|
-
ensureSimulator(device, 'reinstall');
|
|
151
|
-
const bundleId = await resolveIosApp(device, app);
|
|
152
|
-
await ensureBootedSimulator(device);
|
|
153
|
-
const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], {
|
|
154
|
-
allowFailure: true,
|
|
155
|
-
});
|
|
156
|
-
if (result.exitCode !== 0) {
|
|
157
|
-
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
158
|
-
if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) {
|
|
159
|
-
throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, {
|
|
160
|
-
stdout: result.stdout,
|
|
161
|
-
stderr: result.stderr,
|
|
162
|
-
exitCode: result.exitCode,
|
|
163
|
-
});
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
return { bundleId };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
export async function installIosApp(device: DeviceInfo, appPath: string): Promise<void> {
|
|
170
|
-
ensureSimulator(device, 'reinstall');
|
|
171
|
-
await ensureBootedSimulator(device);
|
|
172
|
-
await runCmd('xcrun', ['simctl', 'install', device.id, appPath]);
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export async function reinstallIosApp(
|
|
176
|
-
device: DeviceInfo,
|
|
177
|
-
app: string,
|
|
178
|
-
appPath: string,
|
|
179
|
-
): Promise<{ bundleId: string }> {
|
|
180
|
-
const { bundleId } = await uninstallIosApp(device, app);
|
|
181
|
-
await installIosApp(device, appPath);
|
|
182
|
-
return { bundleId };
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
|
|
186
|
-
if (device.kind === 'simulator') {
|
|
187
|
-
await ensureBootedSimulator(device);
|
|
188
|
-
await runCmd('xcrun', ['simctl', 'io', device.id, 'screenshot', outPath]);
|
|
189
|
-
return;
|
|
190
|
-
}
|
|
191
|
-
await runCmd('xcrun', ['devicectl', 'device', 'screenshot', '--device', device.id, outPath]);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
export async function setIosSetting(
|
|
195
|
-
device: DeviceInfo,
|
|
196
|
-
setting: string,
|
|
197
|
-
state: string,
|
|
198
|
-
appBundleId?: string,
|
|
199
|
-
): Promise<void> {
|
|
200
|
-
ensureSimulator(device, 'settings');
|
|
201
|
-
await ensureBootedSimulator(device);
|
|
202
|
-
const normalized = setting.toLowerCase();
|
|
203
|
-
const enabled = parseSettingState(state);
|
|
204
|
-
switch (normalized) {
|
|
205
|
-
case 'wifi': {
|
|
206
|
-
const mode = enabled ? 'active' : 'failed';
|
|
207
|
-
await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'override', '--wifiMode', mode]);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
case 'airplane': {
|
|
211
|
-
if (enabled) {
|
|
212
|
-
await runCmd('xcrun', [
|
|
213
|
-
'simctl',
|
|
214
|
-
'status_bar',
|
|
215
|
-
device.id,
|
|
216
|
-
'override',
|
|
217
|
-
'--dataNetwork',
|
|
218
|
-
'hide',
|
|
219
|
-
'--wifiMode',
|
|
220
|
-
'failed',
|
|
221
|
-
'--wifiBars',
|
|
222
|
-
'0',
|
|
223
|
-
'--cellularMode',
|
|
224
|
-
'failed',
|
|
225
|
-
'--cellularBars',
|
|
226
|
-
'0',
|
|
227
|
-
'--operatorName',
|
|
228
|
-
'',
|
|
229
|
-
]);
|
|
230
|
-
} else {
|
|
231
|
-
await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'clear']);
|
|
232
|
-
}
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
case 'location': {
|
|
236
|
-
if (!appBundleId) {
|
|
237
|
-
throw new AppError('INVALID_ARGS', 'location setting requires an active app in session');
|
|
238
|
-
}
|
|
239
|
-
const action = enabled ? 'grant' : 'revoke';
|
|
240
|
-
await runCmd('xcrun', ['simctl', 'privacy', device.id, action, 'location', appBundleId]);
|
|
241
|
-
return;
|
|
242
|
-
}
|
|
243
|
-
default:
|
|
244
|
-
throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
function ensureSimulator(device: DeviceInfo, command: string): void {
|
|
249
|
-
if (device.kind !== 'simulator') {
|
|
250
|
-
throw new AppError(
|
|
251
|
-
'UNSUPPORTED_OPERATION',
|
|
252
|
-
`${command} is only supported on iOS simulators`,
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function parseSettingState(state: string): boolean {
|
|
258
|
-
const normalized = state.toLowerCase();
|
|
259
|
-
if (normalized === 'on' || normalized === 'true' || normalized === '1') return true;
|
|
260
|
-
if (normalized === 'off' || normalized === 'false' || normalized === '0') return false;
|
|
261
|
-
throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
|
|
262
|
-
}
|
|
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
|
-
|
|
276
|
-
export async function listSimulatorApps(
|
|
277
|
-
device: DeviceInfo,
|
|
278
|
-
): Promise<{ bundleId: string; name: string }[]> {
|
|
279
|
-
const result = await runCmd('xcrun', ['simctl', 'listapps', device.id], { allowFailure: true });
|
|
280
|
-
const stdout = result.stdout as string;
|
|
281
|
-
const trimmed = stdout.trim();
|
|
282
|
-
if (!trimmed) return [];
|
|
283
|
-
let parsed: Record<string, { CFBundleDisplayName?: string; CFBundleName?: string }> | null = null;
|
|
284
|
-
if (trimmed.startsWith('{')) {
|
|
285
|
-
try {
|
|
286
|
-
parsed = JSON.parse(trimmed) as Record<
|
|
287
|
-
string,
|
|
288
|
-
{ CFBundleDisplayName?: string; CFBundleName?: string }
|
|
289
|
-
>;
|
|
290
|
-
} catch {
|
|
291
|
-
parsed = null;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
if (!parsed && trimmed.startsWith('{')) {
|
|
295
|
-
try {
|
|
296
|
-
const converted = await runCmd('plutil', ['-convert', 'json', '-o', '-', '-'], {
|
|
297
|
-
allowFailure: true,
|
|
298
|
-
stdin: trimmed,
|
|
299
|
-
});
|
|
300
|
-
if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) {
|
|
301
|
-
parsed = JSON.parse(converted.stdout) as Record<
|
|
302
|
-
string,
|
|
303
|
-
{ CFBundleDisplayName?: string; CFBundleName?: string }
|
|
304
|
-
>;
|
|
305
|
-
}
|
|
306
|
-
} catch {
|
|
307
|
-
parsed = null;
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
if (!parsed) return [];
|
|
311
|
-
return Object.entries(parsed).map(([bundleId, info]) => ({
|
|
312
|
-
bundleId,
|
|
313
|
-
name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId,
|
|
314
|
-
}));
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
318
|
-
if (device.kind !== 'simulator') return;
|
|
319
|
-
const state = await getSimulatorState(device.id);
|
|
320
|
-
if (state === 'Booted') return;
|
|
321
|
-
const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
|
|
322
|
-
let bootResult: ExecResult | undefined;
|
|
323
|
-
let bootStatusResult: ExecResult | undefined;
|
|
324
|
-
try {
|
|
325
|
-
await retryWithPolicy(
|
|
326
|
-
async ({ deadline: attemptDeadline }) => {
|
|
327
|
-
if (attemptDeadline?.isExpired()) {
|
|
328
|
-
throw new AppError('COMMAND_FAILED', 'iOS simulator boot deadline exceeded', {
|
|
329
|
-
timeoutMs: IOS_BOOT_TIMEOUT_MS,
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? IOS_BOOT_TIMEOUT_MS);
|
|
333
|
-
bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], {
|
|
334
|
-
allowFailure: true,
|
|
335
|
-
timeoutMs: remainingMs,
|
|
336
|
-
});
|
|
337
|
-
const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
|
|
338
|
-
const bootAlreadyDone =
|
|
339
|
-
bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
|
|
340
|
-
if (bootResult.exitCode !== 0 && !bootAlreadyDone) {
|
|
341
|
-
throw new AppError('COMMAND_FAILED', 'simctl boot failed', {
|
|
342
|
-
stdout: bootResult.stdout,
|
|
343
|
-
stderr: bootResult.stderr,
|
|
344
|
-
exitCode: bootResult.exitCode,
|
|
345
|
-
});
|
|
346
|
-
}
|
|
347
|
-
bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
|
|
348
|
-
allowFailure: true,
|
|
349
|
-
timeoutMs: remainingMs,
|
|
350
|
-
});
|
|
351
|
-
if (bootStatusResult.exitCode !== 0) {
|
|
352
|
-
throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
|
|
353
|
-
stdout: bootStatusResult.stdout,
|
|
354
|
-
stderr: bootStatusResult.stderr,
|
|
355
|
-
exitCode: bootStatusResult.exitCode,
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
const nextState = await getSimulatorState(device.id);
|
|
359
|
-
if (nextState !== 'Booted') {
|
|
360
|
-
throw new AppError('COMMAND_FAILED', 'Simulator is still booting', {
|
|
361
|
-
state: nextState,
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
},
|
|
365
|
-
{
|
|
366
|
-
maxAttempts: 3,
|
|
367
|
-
baseDelayMs: 500,
|
|
368
|
-
maxDelayMs: 2000,
|
|
369
|
-
jitter: 0.2,
|
|
370
|
-
shouldRetry: (error) => {
|
|
371
|
-
const reason = classifyBootFailure({
|
|
372
|
-
error,
|
|
373
|
-
stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
|
|
374
|
-
stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
|
|
375
|
-
context: { platform: 'ios', phase: 'boot' },
|
|
376
|
-
});
|
|
377
|
-
return reason !== 'IOS_BOOT_TIMEOUT' && reason !== 'CI_RESOURCE_STARVATION_SUSPECTED';
|
|
378
|
-
},
|
|
379
|
-
},
|
|
380
|
-
{
|
|
381
|
-
deadline,
|
|
382
|
-
phase: 'boot',
|
|
383
|
-
classifyReason: (error) =>
|
|
384
|
-
classifyBootFailure({
|
|
385
|
-
error,
|
|
386
|
-
stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
|
|
387
|
-
stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
|
|
388
|
-
context: { platform: 'ios', phase: 'boot' },
|
|
389
|
-
}),
|
|
390
|
-
onEvent: (event: RetryTelemetryEvent) => {
|
|
391
|
-
if (!RETRY_LOGS_ENABLED) return;
|
|
392
|
-
process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
|
|
393
|
-
},
|
|
394
|
-
},
|
|
395
|
-
);
|
|
396
|
-
} catch (error) {
|
|
397
|
-
const bootStdout = bootResult?.stdout;
|
|
398
|
-
const bootStderr = bootResult?.stderr;
|
|
399
|
-
const bootExitCode = bootResult?.exitCode;
|
|
400
|
-
const bootstatusStdout = bootStatusResult?.stdout;
|
|
401
|
-
const bootstatusStderr = bootStatusResult?.stderr;
|
|
402
|
-
const bootstatusExitCode = bootStatusResult?.exitCode;
|
|
403
|
-
const reason = classifyBootFailure({
|
|
404
|
-
error,
|
|
405
|
-
stdout: bootstatusStdout ?? bootStdout,
|
|
406
|
-
stderr: bootstatusStderr ?? bootStderr,
|
|
407
|
-
context: { platform: 'ios', phase: 'boot' },
|
|
408
|
-
});
|
|
409
|
-
throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
|
|
410
|
-
platform: 'ios',
|
|
411
|
-
deviceId: device.id,
|
|
412
|
-
timeoutMs: IOS_BOOT_TIMEOUT_MS,
|
|
413
|
-
elapsedMs: deadline.elapsedMs(),
|
|
414
|
-
reason,
|
|
415
|
-
hint: bootFailureHint(reason),
|
|
416
|
-
boot: bootResult
|
|
417
|
-
? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr }
|
|
418
|
-
: undefined,
|
|
419
|
-
bootstatus: bootStatusResult
|
|
420
|
-
? {
|
|
421
|
-
exitCode: bootstatusExitCode,
|
|
422
|
-
stdout: bootstatusStdout,
|
|
423
|
-
stderr: bootstatusStderr,
|
|
424
|
-
}
|
|
425
|
-
: undefined,
|
|
426
|
-
});
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
async function getSimulatorState(udid: string): Promise<string | null> {
|
|
431
|
-
const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], {
|
|
432
|
-
allowFailure: true,
|
|
433
|
-
timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS,
|
|
434
|
-
});
|
|
435
|
-
if (result.exitCode !== 0) return null;
|
|
436
|
-
try {
|
|
437
|
-
const payload = JSON.parse(result.stdout as string) as {
|
|
438
|
-
devices: Record<string, { udid: string; state: string }[]>;
|
|
439
|
-
};
|
|
440
|
-
for (const runtime of Object.values(payload.devices ?? {})) {
|
|
441
|
-
const match = runtime.find((d) => d.udid === udid);
|
|
442
|
-
if (match) return match.state;
|
|
443
|
-
}
|
|
444
|
-
} catch {
|
|
445
|
-
return null;
|
|
446
|
-
}
|
|
447
|
-
return null;
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
|
|
451
|
-
if (!raw) return fallback;
|
|
452
|
-
const parsed = Number(raw);
|
|
453
|
-
if (!Number.isFinite(parsed)) return fallback;
|
|
454
|
-
return Math.max(min, Math.floor(parsed));
|
|
455
|
-
}
|
|
1
|
+
export {
|
|
2
|
+
closeIosApp,
|
|
3
|
+
installIosApp,
|
|
4
|
+
listIosApps,
|
|
5
|
+
listSimulatorApps,
|
|
6
|
+
openIosApp,
|
|
7
|
+
openIosDevice,
|
|
8
|
+
reinstallIosApp,
|
|
9
|
+
resolveIosApp,
|
|
10
|
+
screenshotIos,
|
|
11
|
+
setIosSetting,
|
|
12
|
+
uninstallIosApp,
|
|
13
|
+
} from './apps.ts';
|
|
14
|
+
|
|
15
|
+
export { ensureBootedSimulator } from './simulator.ts';
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
parseIosDeviceAppsPayload,
|
|
19
|
+
type IosAppInfo,
|
|
20
|
+
} from './devicectl.ts';
|
|
@@ -10,6 +10,7 @@ import { withKeyedLock } from '../../utils/keyed-lock.ts';
|
|
|
10
10
|
import { isProcessAlive } from '../../utils/process-identity.ts';
|
|
11
11
|
import net from 'node:net';
|
|
12
12
|
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
13
|
+
import { resolveTimeoutMs, resolveTimeoutSeconds } from '../../utils/timeouts.ts';
|
|
13
14
|
|
|
14
15
|
export type RunnerCommand = {
|
|
15
16
|
command:
|
|
@@ -60,7 +61,7 @@ const runnerSessions = new Map<string, RunnerSession>();
|
|
|
60
61
|
const runnerSessionLocks = new Map<string, Promise<unknown>>();
|
|
61
62
|
const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
|
|
62
63
|
process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
|
|
63
|
-
|
|
64
|
+
45_000,
|
|
64
65
|
5_000,
|
|
65
66
|
);
|
|
66
67
|
const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
|
|
@@ -75,35 +76,33 @@ const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
|
|
|
75
76
|
);
|
|
76
77
|
const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs(
|
|
77
78
|
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
|
|
78
|
-
|
|
79
|
+
300,
|
|
79
80
|
10,
|
|
80
81
|
);
|
|
81
82
|
const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
|
|
82
83
|
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
|
|
83
|
-
|
|
84
|
+
2_000,
|
|
84
85
|
10,
|
|
85
86
|
);
|
|
86
87
|
const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
|
87
88
|
process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
|
|
88
|
-
|
|
89
|
-
|
|
89
|
+
5_000,
|
|
90
|
+
250,
|
|
90
91
|
);
|
|
91
92
|
const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
|
|
92
93
|
process.env.AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS,
|
|
93
94
|
10_000,
|
|
94
95
|
500,
|
|
95
96
|
);
|
|
97
|
+
const RUNNER_DESTINATION_TIMEOUT_SECONDS = resolveTimeoutSeconds(
|
|
98
|
+
process.env.AGENT_DEVICE_RUNNER_DESTINATION_TIMEOUT_SECONDS,
|
|
99
|
+
20,
|
|
100
|
+
5,
|
|
101
|
+
);
|
|
96
102
|
const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
|
|
97
103
|
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
98
104
|
const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner');
|
|
99
105
|
|
|
100
|
-
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
|
|
101
|
-
if (!raw) return fallback;
|
|
102
|
-
const parsed = Number(raw);
|
|
103
|
-
if (!Number.isFinite(parsed)) return fallback;
|
|
104
|
-
return Math.max(min, Math.floor(parsed));
|
|
105
|
-
}
|
|
106
|
-
|
|
107
106
|
export type RunnerSnapshotNode = {
|
|
108
107
|
index: number;
|
|
109
108
|
type?: string;
|
|
@@ -156,7 +155,9 @@ async function executeRunnerCommand(
|
|
|
156
155
|
if (
|
|
157
156
|
appErr.code === 'COMMAND_FAILED' &&
|
|
158
157
|
typeof appErr.message === 'string' &&
|
|
159
|
-
appErr.message.includes('Runner did not accept connection')
|
|
158
|
+
appErr.message.includes('Runner did not accept connection') &&
|
|
159
|
+
shouldRetryRunnerConnectError(appErr) &&
|
|
160
|
+
session?.ready
|
|
160
161
|
) {
|
|
161
162
|
if (session) {
|
|
162
163
|
await stopRunnerSession(session);
|
|
@@ -184,7 +185,7 @@ async function executeRunnerCommandWithSession(
|
|
|
184
185
|
logPath: string | undefined,
|
|
185
186
|
timeoutMs: number,
|
|
186
187
|
): Promise<Record<string, unknown>> {
|
|
187
|
-
const response = await waitForRunner(device, session.port, command, logPath, timeoutMs);
|
|
188
|
+
const response = await waitForRunner(device, session.port, command, logPath, timeoutMs, session);
|
|
188
189
|
return await parseRunnerResponse(response, session, logPath);
|
|
189
190
|
}
|
|
190
191
|
|
|
@@ -304,6 +305,8 @@ async function ensureRunnerSession(
|
|
|
304
305
|
'NO',
|
|
305
306
|
resolveRunnerMaxConcurrentDestinationsFlag(device),
|
|
306
307
|
'1',
|
|
308
|
+
'-destination-timeout',
|
|
309
|
+
String(RUNNER_DESTINATION_TIMEOUT_SECONDS),
|
|
307
310
|
'-xctestrun',
|
|
308
311
|
xctestrunPath,
|
|
309
312
|
'-destination',
|
|
@@ -437,6 +440,10 @@ function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
|
|
|
437
440
|
if (override) {
|
|
438
441
|
return path.resolve(override);
|
|
439
442
|
}
|
|
443
|
+
if (kind === 'simulator') {
|
|
444
|
+
// Keep simulator runtime path aligned with pnpm build:xcuitest/build:all.
|
|
445
|
+
return path.join(RUNNER_DERIVED_ROOT, 'derived');
|
|
446
|
+
}
|
|
440
447
|
return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
|
|
441
448
|
}
|
|
442
449
|
|
|
@@ -565,10 +572,12 @@ function logChunk(chunk: string, logPath?: string, traceLogPath?: string, verbos
|
|
|
565
572
|
}
|
|
566
573
|
}
|
|
567
574
|
|
|
568
|
-
function isRetryableRunnerError(err: unknown): boolean {
|
|
575
|
+
export function isRetryableRunnerError(err: unknown): boolean {
|
|
569
576
|
if (!(err instanceof AppError)) return false;
|
|
570
577
|
if (err.code !== 'COMMAND_FAILED') return false;
|
|
571
578
|
const message = `${err.message ?? ''}`.toLowerCase();
|
|
579
|
+
if (message.includes('xcodebuild exited early')) return false;
|
|
580
|
+
if (message.includes('device is busy') && message.includes('connecting')) return false;
|
|
572
581
|
if (message.includes('runner did not accept connection')) return true;
|
|
573
582
|
if (message.includes('fetch failed')) return true;
|
|
574
583
|
if (message.includes('econnrefused')) return true;
|
|
@@ -631,12 +640,56 @@ function buildRunnerConnectError(params: {
|
|
|
631
640
|
});
|
|
632
641
|
}
|
|
633
642
|
|
|
643
|
+
export function resolveRunnerEarlyExitHint(message: string, stdout: string, stderr: string): string {
|
|
644
|
+
const haystack = `${message}\n${stdout}\n${stderr}`.toLowerCase();
|
|
645
|
+
if (haystack.includes('device is busy') && haystack.includes('connecting')) {
|
|
646
|
+
return 'Target iOS device is still connecting. Keep it unlocked, wait for device trust/connection to settle, then retry.';
|
|
647
|
+
}
|
|
648
|
+
return bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
export function shouldRetryRunnerConnectError(error: unknown): boolean {
|
|
652
|
+
if (!(error instanceof AppError)) return true;
|
|
653
|
+
if (error.code !== 'COMMAND_FAILED') return true;
|
|
654
|
+
const message = String(error.message ?? '').toLowerCase();
|
|
655
|
+
if (message.includes('xcodebuild exited early')) return false;
|
|
656
|
+
return true;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
async function buildRunnerEarlyExitError(params: {
|
|
660
|
+
session: RunnerSession;
|
|
661
|
+
port: number;
|
|
662
|
+
logPath?: string;
|
|
663
|
+
}): Promise<AppError> {
|
|
664
|
+
const { session, port, logPath } = params;
|
|
665
|
+
const result = await session.testPromise;
|
|
666
|
+
const message = 'Runner did not accept connection (xcodebuild exited early)';
|
|
667
|
+
const reason = classifyBootFailure({
|
|
668
|
+
message,
|
|
669
|
+
stdout: result.stdout,
|
|
670
|
+
stderr: result.stderr,
|
|
671
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
672
|
+
});
|
|
673
|
+
return new AppError('COMMAND_FAILED', message, {
|
|
674
|
+
port,
|
|
675
|
+
logPath,
|
|
676
|
+
xcodebuild: {
|
|
677
|
+
exitCode: result.exitCode,
|
|
678
|
+
stdout: result.stdout,
|
|
679
|
+
stderr: result.stderr,
|
|
680
|
+
},
|
|
681
|
+
reason,
|
|
682
|
+
hint: resolveRunnerEarlyExitHint(message, result.stdout, result.stderr),
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
634
686
|
async function waitForRunner(
|
|
635
687
|
device: DeviceInfo,
|
|
636
688
|
port: number,
|
|
637
689
|
command: RunnerCommand,
|
|
638
690
|
logPath?: string,
|
|
639
691
|
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
|
|
692
|
+
session?: RunnerSession,
|
|
640
693
|
): Promise<Response> {
|
|
641
694
|
const deadline = Deadline.fromTimeoutMs(timeoutMs);
|
|
642
695
|
let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
|
|
@@ -651,6 +704,9 @@ async function waitForRunner(
|
|
|
651
704
|
timeoutMs,
|
|
652
705
|
});
|
|
653
706
|
}
|
|
707
|
+
if (session && session.child.exitCode !== null && session.child.exitCode !== undefined) {
|
|
708
|
+
throw await buildRunnerEarlyExitError({ session, port, logPath });
|
|
709
|
+
}
|
|
654
710
|
if (device.kind === 'device') {
|
|
655
711
|
endpoints = await resolveRunnerCommandEndpoints(device, port, attemptDeadline?.remainingMs());
|
|
656
712
|
}
|
|
@@ -688,7 +744,7 @@ async function waitForRunner(
|
|
|
688
744
|
baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
|
|
689
745
|
maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
|
|
690
746
|
jitter: 0.2,
|
|
691
|
-
shouldRetry:
|
|
747
|
+
shouldRetry: shouldRetryRunnerConnectError,
|
|
692
748
|
},
|
|
693
749
|
{ deadline, phase: 'ios_runner_connect' },
|
|
694
750
|
);
|