agent-device 0.3.5 → 0.4.1
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 +47 -14
- package/dist/src/797.js +1 -0
- package/dist/src/bin.js +44 -95
- package/dist/src/daemon.js +18 -17
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
- package/ios-runner/README.md +1 -1
- package/package.json +2 -2
- package/skills/agent-device/SKILL.md +25 -12
- package/skills/agent-device/references/permissions.md +15 -1
- package/skills/agent-device/references/session-management.md +3 -0
- package/skills/agent-device/references/snapshot-refs.md +2 -0
- package/skills/agent-device/references/video-recording.md +2 -0
- package/src/__tests__/cli-help.test.ts +102 -0
- package/src/cli.ts +42 -8
- package/src/core/__tests__/capabilities.test.ts +11 -6
- package/src/core/capabilities.ts +26 -20
- package/src/core/dispatch.ts +109 -31
- package/src/daemon/__tests__/app-state.test.ts +138 -0
- package/src/daemon/__tests__/session-store.test.ts +23 -0
- package/src/daemon/app-state.ts +37 -38
- package/src/daemon/context.ts +12 -0
- package/src/daemon/handlers/__tests__/interaction.test.ts +22 -0
- package/src/daemon/handlers/__tests__/session.test.ts +8 -5
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +92 -0
- package/src/daemon/handlers/interaction.ts +37 -0
- package/src/daemon/handlers/record-trace.ts +1 -1
- package/src/daemon/handlers/session.ts +3 -3
- package/src/daemon/handlers/snapshot.ts +230 -187
- package/src/daemon/session-store.ts +16 -4
- package/src/daemon/types.ts +2 -1
- package/src/daemon-client.ts +42 -13
- package/src/daemon.ts +99 -9
- package/src/platforms/android/__tests__/index.test.ts +46 -1
- package/src/platforms/android/index.ts +23 -0
- package/src/platforms/ios/__tests__/runner-client.test.ts +113 -0
- package/src/platforms/ios/devices.ts +40 -18
- package/src/platforms/ios/index.ts +2 -2
- package/src/platforms/ios/runner-client.ts +418 -93
- package/src/utils/__tests__/args.test.ts +208 -1
- package/src/utils/__tests__/daemon-client.test.ts +78 -0
- package/src/utils/__tests__/keyed-lock.test.ts +55 -0
- package/src/utils/__tests__/process-identity.test.ts +33 -0
- package/src/utils/args.ts +202 -215
- package/src/utils/command-schema.ts +629 -0
- package/src/utils/interactors.ts +11 -1
- package/src/utils/keyed-lock.ts +14 -0
- package/src/utils/process-identity.ts +100 -0
- package/dist/src/274.js +0 -1
|
@@ -4,8 +4,10 @@ import path from 'node:path';
|
|
|
4
4
|
import { fileURLToPath } from 'node:url';
|
|
5
5
|
import { AppError } from '../../utils/errors.ts';
|
|
6
6
|
import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBackgroundResult } from '../../utils/exec.ts';
|
|
7
|
-
import { withRetry } from '../../utils/retry.ts';
|
|
7
|
+
import { Deadline, isEnvTruthy, retryWithPolicy, withRetry } from '../../utils/retry.ts';
|
|
8
8
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
9
|
+
import { withKeyedLock } from '../../utils/keyed-lock.ts';
|
|
10
|
+
import { isProcessAlive } from '../../utils/process-identity.ts';
|
|
9
11
|
import net from 'node:net';
|
|
10
12
|
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
11
13
|
|
|
@@ -13,6 +15,7 @@ export type RunnerCommand = {
|
|
|
13
15
|
command:
|
|
14
16
|
| 'tap'
|
|
15
17
|
| 'longPress'
|
|
18
|
+
| 'drag'
|
|
16
19
|
| 'type'
|
|
17
20
|
| 'swipe'
|
|
18
21
|
| 'findText'
|
|
@@ -29,6 +32,8 @@ export type RunnerCommand = {
|
|
|
29
32
|
action?: 'get' | 'accept' | 'dismiss';
|
|
30
33
|
x?: number;
|
|
31
34
|
y?: number;
|
|
35
|
+
x2?: number;
|
|
36
|
+
y2?: number;
|
|
32
37
|
durationMs?: number;
|
|
33
38
|
direction?: 'up' | 'down' | 'left' | 'right';
|
|
34
39
|
scale?: number;
|
|
@@ -52,6 +57,7 @@ export type RunnerSession = {
|
|
|
52
57
|
};
|
|
53
58
|
|
|
54
59
|
const runnerSessions = new Map<string, RunnerSession>();
|
|
60
|
+
const runnerSessionLocks = new Map<string, Promise<unknown>>();
|
|
55
61
|
const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
|
|
56
62
|
process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
|
|
57
63
|
120_000,
|
|
@@ -62,8 +68,34 @@ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
62
68
|
15_000,
|
|
63
69
|
1_000,
|
|
64
70
|
);
|
|
71
|
+
const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
|
|
72
|
+
process.env.AGENT_DEVICE_RUNNER_CONNECT_ATTEMPT_INTERVAL_MS,
|
|
73
|
+
250,
|
|
74
|
+
50,
|
|
75
|
+
);
|
|
76
|
+
const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs(
|
|
77
|
+
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
|
|
78
|
+
100,
|
|
79
|
+
10,
|
|
80
|
+
);
|
|
81
|
+
const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
|
|
82
|
+
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
|
|
83
|
+
500,
|
|
84
|
+
10,
|
|
85
|
+
);
|
|
86
|
+
const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
|
87
|
+
process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
|
|
88
|
+
1_000,
|
|
89
|
+
50,
|
|
90
|
+
);
|
|
91
|
+
const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
|
|
92
|
+
process.env.AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS,
|
|
93
|
+
10_000,
|
|
94
|
+
500,
|
|
95
|
+
);
|
|
65
96
|
const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
|
|
66
97
|
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
98
|
+
const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner');
|
|
67
99
|
|
|
68
100
|
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
|
|
69
101
|
if (!raw) return fallback;
|
|
@@ -89,6 +121,7 @@ export async function runIosRunnerCommand(
|
|
|
89
121
|
command: RunnerCommand,
|
|
90
122
|
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
|
|
91
123
|
): Promise<Record<string, unknown>> {
|
|
124
|
+
validateRunnerDevice(device);
|
|
92
125
|
if (isReadOnlyRunnerCommand(command.command)) {
|
|
93
126
|
return withRetry(
|
|
94
127
|
() => executeRunnerCommand(device, command, options),
|
|
@@ -98,17 +131,18 @@ export async function runIosRunnerCommand(
|
|
|
98
131
|
return executeRunnerCommand(device, command, options);
|
|
99
132
|
}
|
|
100
133
|
|
|
134
|
+
function withRunnerSessionLock<T>(deviceId: string, task: () => Promise<T>): Promise<T> {
|
|
135
|
+
return withKeyedLock(runnerSessionLocks, deviceId, task);
|
|
136
|
+
}
|
|
137
|
+
|
|
101
138
|
async function executeRunnerCommand(
|
|
102
139
|
device: DeviceInfo,
|
|
103
140
|
command: RunnerCommand,
|
|
104
141
|
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
|
|
105
142
|
): Promise<Record<string, unknown>> {
|
|
106
|
-
|
|
107
|
-
throw new AppError('UNSUPPORTED_OPERATION', 'iOS runner only supports simulators in v1');
|
|
108
|
-
}
|
|
109
|
-
|
|
143
|
+
let session: RunnerSession | undefined;
|
|
110
144
|
try {
|
|
111
|
-
|
|
145
|
+
session = await ensureRunnerSession(device, options);
|
|
112
146
|
const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
|
|
113
147
|
return await executeRunnerCommandWithSession(
|
|
114
148
|
device,
|
|
@@ -124,8 +158,12 @@ async function executeRunnerCommand(
|
|
|
124
158
|
typeof appErr.message === 'string' &&
|
|
125
159
|
appErr.message.includes('Runner did not accept connection')
|
|
126
160
|
) {
|
|
127
|
-
|
|
128
|
-
|
|
161
|
+
if (session) {
|
|
162
|
+
await stopRunnerSession(session);
|
|
163
|
+
} else {
|
|
164
|
+
await stopIosRunnerSession(device.id);
|
|
165
|
+
}
|
|
166
|
+
session = await ensureRunnerSession(device, options);
|
|
129
167
|
const response = await waitForRunner(
|
|
130
168
|
session.device,
|
|
131
169
|
session.port,
|
|
@@ -178,7 +216,27 @@ async function parseRunnerResponse(
|
|
|
178
216
|
}
|
|
179
217
|
|
|
180
218
|
export async function stopIosRunnerSession(deviceId: string): Promise<void> {
|
|
181
|
-
|
|
219
|
+
await withRunnerSessionLock(deviceId, async () => {
|
|
220
|
+
await stopRunnerSessionInternal(deviceId);
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function stopAllIosRunnerSessions(): Promise<void> {
|
|
225
|
+
// Shutdown cleanup drains the sessions known at invocation time; daemon shutdown closes intake.
|
|
226
|
+
const pending = Array.from(runnerSessions.keys());
|
|
227
|
+
await Promise.allSettled(pending.map(async (deviceId) => {
|
|
228
|
+
await stopIosRunnerSession(deviceId);
|
|
229
|
+
}));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function stopRunnerSession(session: RunnerSession): Promise<void> {
|
|
233
|
+
await withRunnerSessionLock(session.deviceId, async () => {
|
|
234
|
+
await stopRunnerSessionInternal(session.deviceId, session);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function stopRunnerSessionInternal(deviceId: string, sessionOverride?: RunnerSession): Promise<void> {
|
|
239
|
+
const session = sessionOverride ?? runnerSessions.get(deviceId);
|
|
182
240
|
if (!session) return;
|
|
183
241
|
try {
|
|
184
242
|
await waitForRunner(session.device, session.port, {
|
|
@@ -201,7 +259,9 @@ export async function stopIosRunnerSession(deviceId: string): Promise<void> {
|
|
|
201
259
|
await killRunnerProcessTree(session.child.pid, 'SIGKILL');
|
|
202
260
|
cleanupTempFile(session.xctestrunPath);
|
|
203
261
|
cleanupTempFile(session.jsonPath);
|
|
204
|
-
runnerSessions.
|
|
262
|
+
if (runnerSessions.get(deviceId) === session) {
|
|
263
|
+
runnerSessions.delete(deviceId);
|
|
264
|
+
}
|
|
205
265
|
}
|
|
206
266
|
|
|
207
267
|
async function ensureBooted(udid: string): Promise<void> {
|
|
@@ -215,58 +275,70 @@ async function ensureRunnerSession(
|
|
|
215
275
|
device: DeviceInfo,
|
|
216
276
|
options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
|
|
217
277
|
): Promise<RunnerSession> {
|
|
218
|
-
|
|
219
|
-
|
|
278
|
+
return await withRunnerSessionLock(device.id, async () => {
|
|
279
|
+
const existing = runnerSessions.get(device.id);
|
|
280
|
+
if (existing) {
|
|
281
|
+
if (isRunnerProcessAlive(existing.child.pid)) {
|
|
282
|
+
return existing;
|
|
283
|
+
}
|
|
284
|
+
await stopRunnerSessionInternal(device.id, existing);
|
|
285
|
+
}
|
|
220
286
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
287
|
+
await ensureBootedIfNeeded(device);
|
|
288
|
+
const xctestrun = await ensureXctestrun(device, options);
|
|
289
|
+
const port = await getFreePort();
|
|
290
|
+
const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv(
|
|
291
|
+
xctestrun,
|
|
292
|
+
{ AGENT_DEVICE_RUNNER_PORT: String(port) },
|
|
293
|
+
`session-${device.id}-${port}`,
|
|
294
|
+
);
|
|
295
|
+
const { child, wait: testPromise } = runCmdBackground(
|
|
296
|
+
'xcodebuild',
|
|
297
|
+
[
|
|
298
|
+
'test-without-building',
|
|
299
|
+
'-only-testing',
|
|
300
|
+
'AgentDeviceRunnerUITests/RunnerTests/testCommand',
|
|
301
|
+
'-parallel-testing-enabled',
|
|
302
|
+
'NO',
|
|
303
|
+
'-test-timeouts-enabled',
|
|
304
|
+
'NO',
|
|
305
|
+
resolveRunnerMaxConcurrentDestinationsFlag(device),
|
|
306
|
+
'1',
|
|
307
|
+
'-xctestrun',
|
|
308
|
+
xctestrunPath,
|
|
309
|
+
'-destination',
|
|
310
|
+
resolveRunnerDestination(device),
|
|
311
|
+
],
|
|
312
|
+
{
|
|
313
|
+
allowFailure: true,
|
|
314
|
+
env: { ...process.env, AGENT_DEVICE_RUNNER_PORT: String(port) },
|
|
315
|
+
},
|
|
316
|
+
);
|
|
317
|
+
child.stdout?.on('data', (chunk: string) => {
|
|
318
|
+
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
319
|
+
});
|
|
320
|
+
child.stderr?.on('data', (chunk: string) => {
|
|
321
|
+
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const session: RunnerSession = {
|
|
325
|
+
device,
|
|
326
|
+
deviceId: device.id,
|
|
327
|
+
port,
|
|
242
328
|
xctestrunPath,
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
);
|
|
251
|
-
child.stdout?.on('data', (chunk: string) => {
|
|
252
|
-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
253
|
-
});
|
|
254
|
-
child.stderr?.on('data', (chunk: string) => {
|
|
255
|
-
logChunk(chunk, options.logPath, options.traceLogPath, options.verbose);
|
|
329
|
+
jsonPath,
|
|
330
|
+
testPromise,
|
|
331
|
+
child,
|
|
332
|
+
ready: false,
|
|
333
|
+
};
|
|
334
|
+
runnerSessions.set(device.id, session);
|
|
335
|
+
return session;
|
|
256
336
|
});
|
|
337
|
+
}
|
|
257
338
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
port,
|
|
262
|
-
xctestrunPath,
|
|
263
|
-
jsonPath,
|
|
264
|
-
testPromise,
|
|
265
|
-
child,
|
|
266
|
-
ready: false,
|
|
267
|
-
};
|
|
268
|
-
runnerSessions.set(device.id, session);
|
|
269
|
-
return session;
|
|
339
|
+
function isRunnerProcessAlive(pid: number | undefined): boolean {
|
|
340
|
+
if (!pid) return false;
|
|
341
|
+
return isProcessAlive(pid);
|
|
270
342
|
}
|
|
271
343
|
|
|
272
344
|
async function killRunnerProcessTree(
|
|
@@ -289,11 +361,12 @@ async function killRunnerProcessTree(
|
|
|
289
361
|
|
|
290
362
|
|
|
291
363
|
async function ensureXctestrun(
|
|
292
|
-
|
|
364
|
+
device: DeviceInfo,
|
|
293
365
|
options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
|
|
294
366
|
): Promise<string> {
|
|
295
|
-
const derived = resolveRunnerDerivedPath();
|
|
367
|
+
const derived = resolveRunnerDerivedPath(device.kind);
|
|
296
368
|
if (shouldCleanDerived()) {
|
|
369
|
+
assertSafeDerivedCleanup(derived);
|
|
297
370
|
try {
|
|
298
371
|
fs.rmSync(derived, { recursive: true, force: true });
|
|
299
372
|
} catch {
|
|
@@ -310,6 +383,8 @@ async function ensureXctestrun(
|
|
|
310
383
|
throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath });
|
|
311
384
|
}
|
|
312
385
|
|
|
386
|
+
const signingBuildSettings = resolveRunnerSigningBuildSettings(process.env, device.kind === 'device');
|
|
387
|
+
const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : [];
|
|
313
388
|
try {
|
|
314
389
|
await runCmdStreaming(
|
|
315
390
|
'xcodebuild',
|
|
@@ -321,12 +396,14 @@ async function ensureXctestrun(
|
|
|
321
396
|
'AgentDeviceRunner',
|
|
322
397
|
'-parallel-testing-enabled',
|
|
323
398
|
'NO',
|
|
324
|
-
|
|
399
|
+
resolveRunnerMaxConcurrentDestinationsFlag(device),
|
|
325
400
|
'1',
|
|
326
401
|
'-destination',
|
|
327
|
-
|
|
402
|
+
resolveRunnerBuildDestination(device),
|
|
328
403
|
'-derivedDataPath',
|
|
329
404
|
derived,
|
|
405
|
+
...provisioningArgs,
|
|
406
|
+
...signingBuildSettings,
|
|
330
407
|
],
|
|
331
408
|
{
|
|
332
409
|
onStdoutChunk: (chunk) => {
|
|
@@ -339,10 +416,12 @@ async function ensureXctestrun(
|
|
|
339
416
|
);
|
|
340
417
|
} catch (err) {
|
|
341
418
|
const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
|
|
419
|
+
const hint = resolveSigningFailureHint(appErr);
|
|
342
420
|
throw new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', {
|
|
343
421
|
error: appErr.message,
|
|
344
422
|
details: appErr.details,
|
|
345
423
|
logPath: options.logPath,
|
|
424
|
+
hint,
|
|
346
425
|
});
|
|
347
426
|
}
|
|
348
427
|
|
|
@@ -353,13 +432,90 @@ async function ensureXctestrun(
|
|
|
353
432
|
return built;
|
|
354
433
|
}
|
|
355
434
|
|
|
356
|
-
function resolveRunnerDerivedPath(): string {
|
|
435
|
+
function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
|
|
357
436
|
const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
|
|
358
437
|
if (override) {
|
|
359
438
|
return path.resolve(override);
|
|
360
439
|
}
|
|
361
|
-
|
|
362
|
-
|
|
440
|
+
return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export function resolveRunnerDestination(device: DeviceInfo): string {
|
|
444
|
+
if (device.platform !== 'ios') {
|
|
445
|
+
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
446
|
+
}
|
|
447
|
+
if (device.kind === 'simulator') {
|
|
448
|
+
return `platform=iOS Simulator,id=${device.id}`;
|
|
449
|
+
}
|
|
450
|
+
return `platform=iOS,id=${device.id}`;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function resolveRunnerBuildDestination(device: DeviceInfo): string {
|
|
454
|
+
if (device.platform !== 'ios') {
|
|
455
|
+
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
456
|
+
}
|
|
457
|
+
if (device.kind === 'simulator') {
|
|
458
|
+
return `platform=iOS Simulator,id=${device.id}`;
|
|
459
|
+
}
|
|
460
|
+
return 'generic/platform=iOS';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function ensureBootedIfNeeded(device: DeviceInfo): Promise<void> {
|
|
464
|
+
if (device.kind !== 'simulator') {
|
|
465
|
+
return Promise.resolve();
|
|
466
|
+
}
|
|
467
|
+
return ensureBooted(device.id);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function validateRunnerDevice(device: DeviceInfo): void {
|
|
471
|
+
if (device.platform !== 'ios') {
|
|
472
|
+
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
473
|
+
}
|
|
474
|
+
if (device.kind !== 'simulator' && device.kind !== 'device') {
|
|
475
|
+
throw new AppError('UNSUPPORTED_OPERATION', `Unsupported iOS device kind for runner: ${device.kind}`);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string {
|
|
480
|
+
return device.kind === 'device'
|
|
481
|
+
? '-maximum-concurrent-test-device-destinations'
|
|
482
|
+
: '-maximum-concurrent-test-simulator-destinations';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
export function resolveRunnerSigningBuildSettings(
|
|
486
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
487
|
+
forDevice = false,
|
|
488
|
+
): string[] {
|
|
489
|
+
if (!forDevice) {
|
|
490
|
+
return [];
|
|
491
|
+
}
|
|
492
|
+
const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || '';
|
|
493
|
+
const configuredIdentity = env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || '';
|
|
494
|
+
const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || '';
|
|
495
|
+
const args = ['CODE_SIGN_STYLE=Automatic'];
|
|
496
|
+
if (teamId) {
|
|
497
|
+
args.push(`DEVELOPMENT_TEAM=${teamId}`);
|
|
498
|
+
}
|
|
499
|
+
if (configuredIdentity) {
|
|
500
|
+
args.push(`CODE_SIGN_IDENTITY=${configuredIdentity}`);
|
|
501
|
+
}
|
|
502
|
+
if (profile) args.push(`PROVISIONING_PROFILE_SPECIFIER=${profile}`);
|
|
503
|
+
return args;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
function resolveSigningFailureHint(error: AppError): string | undefined {
|
|
507
|
+
const details = error.details ? JSON.stringify(error.details) : '';
|
|
508
|
+
const combined = `${error.message}\n${details}`.toLowerCase();
|
|
509
|
+
if (combined.includes('requires a development team')) {
|
|
510
|
+
return 'Configure signing in Xcode or set AGENT_DEVICE_IOS_TEAM_ID for physical-device runs.';
|
|
511
|
+
}
|
|
512
|
+
if (combined.includes('no profiles for') || combined.includes('provisioning profile')) {
|
|
513
|
+
return 'Install/select a valid iOS provisioning profile, or set AGENT_DEVICE_IOS_PROVISIONING_PROFILE.';
|
|
514
|
+
}
|
|
515
|
+
if (combined.includes('code signing')) {
|
|
516
|
+
return 'Enable Automatic Signing in Xcode or provide AGENT_DEVICE_IOS_TEAM_ID and optional AGENT_DEVICE_IOS_SIGNING_IDENTITY.';
|
|
517
|
+
}
|
|
518
|
+
return undefined;
|
|
363
519
|
}
|
|
364
520
|
|
|
365
521
|
function findXctestrun(root: string): string | null {
|
|
@@ -425,9 +581,54 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
|
|
|
425
581
|
}
|
|
426
582
|
|
|
427
583
|
function shouldCleanDerived(): boolean {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
584
|
+
return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
export function assertSafeDerivedCleanup(
|
|
588
|
+
derivedPath: string,
|
|
589
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
590
|
+
): void {
|
|
591
|
+
const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
|
|
592
|
+
if (!override) {
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (isCleanupOverrideAllowed(env)) {
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
throw new AppError(
|
|
599
|
+
'COMMAND_FAILED',
|
|
600
|
+
'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically',
|
|
601
|
+
{
|
|
602
|
+
derivedPath,
|
|
603
|
+
hint: 'Unset AGENT_DEVICE_IOS_CLEAN_DERIVED, or set AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1 if you trust this path.',
|
|
604
|
+
},
|
|
605
|
+
);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
function isCleanupOverrideAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
609
|
+
return isEnvTruthy(env.AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function buildRunnerConnectError(params: {
|
|
613
|
+
port: number;
|
|
614
|
+
endpoints: string[];
|
|
615
|
+
logPath?: string;
|
|
616
|
+
lastError: unknown;
|
|
617
|
+
}): AppError {
|
|
618
|
+
const { port, endpoints, logPath, lastError } = params;
|
|
619
|
+
const message = 'Runner did not accept connection';
|
|
620
|
+
return new AppError('COMMAND_FAILED', message, {
|
|
621
|
+
port,
|
|
622
|
+
endpoints,
|
|
623
|
+
logPath,
|
|
624
|
+
lastError: lastError ? String(lastError) : undefined,
|
|
625
|
+
reason: classifyBootFailure({
|
|
626
|
+
error: lastError,
|
|
627
|
+
message,
|
|
628
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
629
|
+
}),
|
|
630
|
+
hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
|
|
631
|
+
});
|
|
431
632
|
}
|
|
432
633
|
|
|
433
634
|
async function waitForRunner(
|
|
@@ -437,43 +638,167 @@ async function waitForRunner(
|
|
|
437
638
|
logPath?: string,
|
|
438
639
|
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
|
|
439
640
|
): Promise<Response> {
|
|
440
|
-
const
|
|
641
|
+
const deadline = Deadline.fromTimeoutMs(timeoutMs);
|
|
642
|
+
let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
|
|
441
643
|
let lastError: unknown = null;
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
644
|
+
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS));
|
|
645
|
+
try {
|
|
646
|
+
return await retryWithPolicy(
|
|
647
|
+
async ({ deadline: attemptDeadline }) => {
|
|
648
|
+
if (attemptDeadline?.isExpired()) {
|
|
649
|
+
throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
|
|
650
|
+
port,
|
|
651
|
+
timeoutMs,
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
if (device.kind === 'device') {
|
|
655
|
+
endpoints = await resolveRunnerCommandEndpoints(device, port, attemptDeadline?.remainingMs());
|
|
656
|
+
}
|
|
657
|
+
for (const endpoint of endpoints) {
|
|
658
|
+
try {
|
|
659
|
+
const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs;
|
|
660
|
+
if (remainingMs <= 0) {
|
|
661
|
+
throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
|
|
662
|
+
port,
|
|
663
|
+
timeoutMs,
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
const response = await fetchWithTimeout(
|
|
667
|
+
endpoint,
|
|
668
|
+
{
|
|
669
|
+
method: 'POST',
|
|
670
|
+
headers: { 'Content-Type': 'application/json' },
|
|
671
|
+
body: JSON.stringify(command),
|
|
672
|
+
},
|
|
673
|
+
Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs),
|
|
674
|
+
);
|
|
675
|
+
return response;
|
|
676
|
+
} catch (err) {
|
|
677
|
+
lastError = err;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
throw new AppError('COMMAND_FAILED', 'Runner endpoint probe failed', {
|
|
681
|
+
port,
|
|
682
|
+
endpoints,
|
|
683
|
+
lastError: lastError ? String(lastError) : undefined,
|
|
684
|
+
});
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
maxAttempts,
|
|
688
|
+
baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
|
|
689
|
+
maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
|
|
690
|
+
jitter: 0.2,
|
|
691
|
+
shouldRetry: () => true,
|
|
692
|
+
},
|
|
693
|
+
{ deadline, phase: 'ios_runner_connect' },
|
|
694
|
+
);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
if (!lastError) {
|
|
697
|
+
lastError = error;
|
|
453
698
|
}
|
|
454
699
|
}
|
|
700
|
+
|
|
455
701
|
if (device.kind === 'simulator') {
|
|
456
|
-
const
|
|
702
|
+
const remainingMs = deadline.remainingMs();
|
|
703
|
+
if (remainingMs <= 0) {
|
|
704
|
+
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
|
|
705
|
+
}
|
|
706
|
+
const simResponse = await postCommandViaSimulator(device.id, port, command, remainingMs);
|
|
457
707
|
return new Response(simResponse.body, { status: simResponse.status });
|
|
458
708
|
}
|
|
459
709
|
|
|
460
|
-
throw
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
710
|
+
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function resolveRunnerCommandEndpoints(
|
|
714
|
+
device: DeviceInfo,
|
|
715
|
+
port: number,
|
|
716
|
+
timeoutBudgetMs?: number,
|
|
717
|
+
): Promise<string[]> {
|
|
718
|
+
const endpoints = [`http://127.0.0.1:${port}/command`];
|
|
719
|
+
if (device.kind !== 'device') {
|
|
720
|
+
return endpoints;
|
|
721
|
+
}
|
|
722
|
+
const tunnelIp = await resolveDeviceTunnelIp(device.id, timeoutBudgetMs);
|
|
723
|
+
if (tunnelIp) {
|
|
724
|
+
endpoints.unshift(`http://[${tunnelIp}]:${port}/command`);
|
|
725
|
+
}
|
|
726
|
+
return endpoints;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function fetchWithTimeout(
|
|
730
|
+
url: string,
|
|
731
|
+
init: RequestInit,
|
|
732
|
+
timeoutMs: number,
|
|
733
|
+
): Promise<Response> {
|
|
734
|
+
const controller = new AbortController();
|
|
735
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
736
|
+
try {
|
|
737
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
738
|
+
} finally {
|
|
739
|
+
clearTimeout(timeout);
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
async function resolveDeviceTunnelIp(deviceId: string, timeoutBudgetMs?: number): Promise<string | null> {
|
|
744
|
+
if (typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0) {
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
const timeoutMs = typeof timeoutBudgetMs === 'number'
|
|
748
|
+
? Math.max(1, Math.min(RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs))
|
|
749
|
+
: RUNNER_DEVICE_INFO_TIMEOUT_MS;
|
|
750
|
+
const jsonPath = path.join(
|
|
751
|
+
os.tmpdir(),
|
|
752
|
+
`agent-device-devicectl-info-${process.pid}-${Date.now()}.json`,
|
|
753
|
+
);
|
|
754
|
+
try {
|
|
755
|
+
const devicectlTimeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
756
|
+
const result = await runCmd(
|
|
757
|
+
'xcrun',
|
|
758
|
+
[
|
|
759
|
+
'devicectl',
|
|
760
|
+
'device',
|
|
761
|
+
'info',
|
|
762
|
+
'details',
|
|
763
|
+
'--device',
|
|
764
|
+
deviceId,
|
|
765
|
+
'--json-output',
|
|
766
|
+
jsonPath,
|
|
767
|
+
'--timeout',
|
|
768
|
+
String(devicectlTimeoutSeconds),
|
|
769
|
+
],
|
|
770
|
+
{ allowFailure: true, timeoutMs },
|
|
771
|
+
);
|
|
772
|
+
if (result.exitCode !== 0 || !fs.existsSync(jsonPath)) {
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
const payload = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as {
|
|
776
|
+
info?: { outcome?: string };
|
|
777
|
+
result?: {
|
|
778
|
+
connectionProperties?: { tunnelIPAddress?: string };
|
|
779
|
+
device?: { connectionProperties?: { tunnelIPAddress?: string } };
|
|
780
|
+
};
|
|
781
|
+
};
|
|
782
|
+
if (payload.info?.outcome && payload.info.outcome !== 'success') {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
const ip = (
|
|
786
|
+
payload.result?.connectionProperties?.tunnelIPAddress
|
|
787
|
+
?? payload.result?.device?.connectionProperties?.tunnelIPAddress
|
|
788
|
+
)?.trim();
|
|
789
|
+
return ip && ip.length > 0 ? ip : null;
|
|
790
|
+
} catch {
|
|
791
|
+
return null;
|
|
792
|
+
} finally {
|
|
793
|
+
cleanupTempFile(jsonPath);
|
|
794
|
+
}
|
|
471
795
|
}
|
|
472
796
|
|
|
473
797
|
async function postCommandViaSimulator(
|
|
474
798
|
udid: string,
|
|
475
799
|
port: number,
|
|
476
800
|
command: RunnerCommand,
|
|
801
|
+
timeoutMs: number,
|
|
477
802
|
): Promise<{ status: number; body: string }> {
|
|
478
803
|
const payload = JSON.stringify(command);
|
|
479
804
|
const result = await runCmd(
|
|
@@ -492,7 +817,7 @@ async function postCommandViaSimulator(
|
|
|
492
817
|
payload,
|
|
493
818
|
`http://127.0.0.1:${port}/command`,
|
|
494
819
|
],
|
|
495
|
-
{ allowFailure: true },
|
|
820
|
+
{ allowFailure: true, timeoutMs },
|
|
496
821
|
);
|
|
497
822
|
const body = result.stdout as string;
|
|
498
823
|
if (result.exitCode !== 0) {
|