agent-device 0.3.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -16
- package/dist/src/bin.js +35 -96
- package/dist/src/daemon.js +16 -15
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +24 -0
- package/ios-runner/README.md +1 -1
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +32 -14
- package/skills/agent-device/references/permissions.md +15 -1
- package/skills/agent-device/references/session-management.md +2 -0
- package/skills/agent-device/references/snapshot-refs.md +2 -0
- package/skills/agent-device/references/video-recording.md +2 -0
- package/src/cli.ts +7 -3
- package/src/core/__tests__/capabilities.test.ts +11 -6
- package/src/core/__tests__/open-target.test.ts +16 -0
- package/src/core/capabilities.ts +26 -20
- package/src/core/dispatch.ts +110 -31
- package/src/core/open-target.ts +13 -0
- package/src/daemon/__tests__/app-state.test.ts +138 -0
- package/src/daemon/__tests__/session-store.test.ts +24 -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 +226 -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 +96 -26
- package/src/daemon/handlers/snapshot.ts +21 -3
- package/src/daemon/session-store.ts +11 -0
- package/src/daemon-client.ts +14 -6
- package/src/daemon.ts +1 -1
- package/src/platforms/android/__tests__/index.test.ts +67 -1
- package/src/platforms/android/index.ts +41 -0
- package/src/platforms/ios/__tests__/index.test.ts +24 -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 +70 -5
- package/src/platforms/ios/runner-client.ts +329 -42
- package/src/utils/__tests__/args.test.ts +175 -0
- package/src/utils/args.ts +174 -212
- package/src/utils/command-schema.ts +591 -0
- package/src/utils/interactors.ts +13 -3
|
@@ -4,7 +4,7 @@ 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
9
|
import net from 'node:net';
|
|
10
10
|
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
@@ -13,6 +13,7 @@ export type RunnerCommand = {
|
|
|
13
13
|
command:
|
|
14
14
|
| 'tap'
|
|
15
15
|
| 'longPress'
|
|
16
|
+
| 'drag'
|
|
16
17
|
| 'type'
|
|
17
18
|
| 'swipe'
|
|
18
19
|
| 'findText'
|
|
@@ -29,6 +30,8 @@ export type RunnerCommand = {
|
|
|
29
30
|
action?: 'get' | 'accept' | 'dismiss';
|
|
30
31
|
x?: number;
|
|
31
32
|
y?: number;
|
|
33
|
+
x2?: number;
|
|
34
|
+
y2?: number;
|
|
32
35
|
durationMs?: number;
|
|
33
36
|
direction?: 'up' | 'down' | 'left' | 'right';
|
|
34
37
|
scale?: number;
|
|
@@ -62,8 +65,34 @@ const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
|
|
|
62
65
|
15_000,
|
|
63
66
|
1_000,
|
|
64
67
|
);
|
|
68
|
+
const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
|
|
69
|
+
process.env.AGENT_DEVICE_RUNNER_CONNECT_ATTEMPT_INTERVAL_MS,
|
|
70
|
+
250,
|
|
71
|
+
50,
|
|
72
|
+
);
|
|
73
|
+
const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs(
|
|
74
|
+
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
|
|
75
|
+
100,
|
|
76
|
+
10,
|
|
77
|
+
);
|
|
78
|
+
const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
|
|
79
|
+
process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
|
|
80
|
+
500,
|
|
81
|
+
10,
|
|
82
|
+
);
|
|
83
|
+
const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
|
84
|
+
process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
|
|
85
|
+
1_000,
|
|
86
|
+
50,
|
|
87
|
+
);
|
|
88
|
+
const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
|
|
89
|
+
process.env.AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS,
|
|
90
|
+
10_000,
|
|
91
|
+
500,
|
|
92
|
+
);
|
|
65
93
|
const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
|
|
66
94
|
const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
|
|
95
|
+
const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner');
|
|
67
96
|
|
|
68
97
|
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
|
|
69
98
|
if (!raw) return fallback;
|
|
@@ -89,6 +118,7 @@ export async function runIosRunnerCommand(
|
|
|
89
118
|
command: RunnerCommand,
|
|
90
119
|
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
|
|
91
120
|
): Promise<Record<string, unknown>> {
|
|
121
|
+
validateRunnerDevice(device);
|
|
92
122
|
if (isReadOnlyRunnerCommand(command.command)) {
|
|
93
123
|
return withRetry(
|
|
94
124
|
() => executeRunnerCommand(device, command, options),
|
|
@@ -103,10 +133,6 @@ async function executeRunnerCommand(
|
|
|
103
133
|
command: RunnerCommand,
|
|
104
134
|
options: { verbose?: boolean; logPath?: string; traceLogPath?: string } = {},
|
|
105
135
|
): Promise<Record<string, unknown>> {
|
|
106
|
-
if (device.kind !== 'simulator') {
|
|
107
|
-
throw new AppError('UNSUPPORTED_OPERATION', 'iOS runner only supports simulators in v1');
|
|
108
|
-
}
|
|
109
|
-
|
|
110
136
|
try {
|
|
111
137
|
const session = await ensureRunnerSession(device, options);
|
|
112
138
|
const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS : RUNNER_STARTUP_TIMEOUT_MS;
|
|
@@ -218,8 +244,8 @@ async function ensureRunnerSession(
|
|
|
218
244
|
const existing = runnerSessions.get(device.id);
|
|
219
245
|
if (existing) return existing;
|
|
220
246
|
|
|
221
|
-
await
|
|
222
|
-
const xctestrun = await ensureXctestrun(device
|
|
247
|
+
await ensureBootedIfNeeded(device);
|
|
248
|
+
const xctestrun = await ensureXctestrun(device, options);
|
|
223
249
|
const port = await getFreePort();
|
|
224
250
|
const { xctestrunPath, jsonPath } = await prepareXctestrunWithEnv(
|
|
225
251
|
xctestrun,
|
|
@@ -236,12 +262,12 @@ async function ensureRunnerSession(
|
|
|
236
262
|
'NO',
|
|
237
263
|
'-test-timeouts-enabled',
|
|
238
264
|
'NO',
|
|
239
|
-
|
|
265
|
+
resolveRunnerMaxConcurrentDestinationsFlag(device),
|
|
240
266
|
'1',
|
|
241
267
|
'-xctestrun',
|
|
242
268
|
xctestrunPath,
|
|
243
269
|
'-destination',
|
|
244
|
-
|
|
270
|
+
resolveRunnerDestination(device),
|
|
245
271
|
],
|
|
246
272
|
{
|
|
247
273
|
allowFailure: true,
|
|
@@ -289,12 +315,12 @@ async function killRunnerProcessTree(
|
|
|
289
315
|
|
|
290
316
|
|
|
291
317
|
async function ensureXctestrun(
|
|
292
|
-
|
|
318
|
+
device: DeviceInfo,
|
|
293
319
|
options: { verbose?: boolean; logPath?: string; traceLogPath?: string },
|
|
294
320
|
): Promise<string> {
|
|
295
|
-
const
|
|
296
|
-
const derived = path.join(base, 'derived');
|
|
321
|
+
const derived = resolveRunnerDerivedPath(device.kind);
|
|
297
322
|
if (shouldCleanDerived()) {
|
|
323
|
+
assertSafeDerivedCleanup(derived);
|
|
298
324
|
try {
|
|
299
325
|
fs.rmSync(derived, { recursive: true, force: true });
|
|
300
326
|
} catch {
|
|
@@ -311,6 +337,8 @@ async function ensureXctestrun(
|
|
|
311
337
|
throw new AppError('COMMAND_FAILED', 'iOS runner project not found', { projectPath });
|
|
312
338
|
}
|
|
313
339
|
|
|
340
|
+
const signingBuildSettings = resolveRunnerSigningBuildSettings(process.env, device.kind === 'device');
|
|
341
|
+
const provisioningArgs = device.kind === 'device' ? ['-allowProvisioningUpdates'] : [];
|
|
314
342
|
try {
|
|
315
343
|
await runCmdStreaming(
|
|
316
344
|
'xcodebuild',
|
|
@@ -322,12 +350,14 @@ async function ensureXctestrun(
|
|
|
322
350
|
'AgentDeviceRunner',
|
|
323
351
|
'-parallel-testing-enabled',
|
|
324
352
|
'NO',
|
|
325
|
-
|
|
353
|
+
resolveRunnerMaxConcurrentDestinationsFlag(device),
|
|
326
354
|
'1',
|
|
327
355
|
'-destination',
|
|
328
|
-
|
|
356
|
+
resolveRunnerBuildDestination(device),
|
|
329
357
|
'-derivedDataPath',
|
|
330
358
|
derived,
|
|
359
|
+
...provisioningArgs,
|
|
360
|
+
...signingBuildSettings,
|
|
331
361
|
],
|
|
332
362
|
{
|
|
333
363
|
onStdoutChunk: (chunk) => {
|
|
@@ -340,10 +370,12 @@ async function ensureXctestrun(
|
|
|
340
370
|
);
|
|
341
371
|
} catch (err) {
|
|
342
372
|
const appErr = err instanceof AppError ? err : new AppError('COMMAND_FAILED', String(err));
|
|
373
|
+
const hint = resolveSigningFailureHint(appErr);
|
|
343
374
|
throw new AppError('COMMAND_FAILED', 'xcodebuild build-for-testing failed', {
|
|
344
375
|
error: appErr.message,
|
|
345
376
|
details: appErr.details,
|
|
346
377
|
logPath: options.logPath,
|
|
378
|
+
hint,
|
|
347
379
|
});
|
|
348
380
|
}
|
|
349
381
|
|
|
@@ -354,6 +386,92 @@ async function ensureXctestrun(
|
|
|
354
386
|
return built;
|
|
355
387
|
}
|
|
356
388
|
|
|
389
|
+
function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
|
|
390
|
+
const override = process.env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
|
|
391
|
+
if (override) {
|
|
392
|
+
return path.resolve(override);
|
|
393
|
+
}
|
|
394
|
+
return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export function resolveRunnerDestination(device: DeviceInfo): string {
|
|
398
|
+
if (device.platform !== 'ios') {
|
|
399
|
+
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
400
|
+
}
|
|
401
|
+
if (device.kind === 'simulator') {
|
|
402
|
+
return `platform=iOS Simulator,id=${device.id}`;
|
|
403
|
+
}
|
|
404
|
+
return `platform=iOS,id=${device.id}`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function resolveRunnerBuildDestination(device: DeviceInfo): string {
|
|
408
|
+
if (device.platform !== 'ios') {
|
|
409
|
+
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
410
|
+
}
|
|
411
|
+
if (device.kind === 'simulator') {
|
|
412
|
+
return `platform=iOS Simulator,id=${device.id}`;
|
|
413
|
+
}
|
|
414
|
+
return 'generic/platform=iOS';
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function ensureBootedIfNeeded(device: DeviceInfo): Promise<void> {
|
|
418
|
+
if (device.kind !== 'simulator') {
|
|
419
|
+
return Promise.resolve();
|
|
420
|
+
}
|
|
421
|
+
return ensureBooted(device.id);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function validateRunnerDevice(device: DeviceInfo): void {
|
|
425
|
+
if (device.platform !== 'ios') {
|
|
426
|
+
throw new AppError('UNSUPPORTED_PLATFORM', `Unsupported platform for iOS runner: ${device.platform}`);
|
|
427
|
+
}
|
|
428
|
+
if (device.kind !== 'simulator' && device.kind !== 'device') {
|
|
429
|
+
throw new AppError('UNSUPPORTED_OPERATION', `Unsupported iOS device kind for runner: ${device.kind}`);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export function resolveRunnerMaxConcurrentDestinationsFlag(device: DeviceInfo): string {
|
|
434
|
+
return device.kind === 'device'
|
|
435
|
+
? '-maximum-concurrent-test-device-destinations'
|
|
436
|
+
: '-maximum-concurrent-test-simulator-destinations';
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export function resolveRunnerSigningBuildSettings(
|
|
440
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
441
|
+
forDevice = false,
|
|
442
|
+
): string[] {
|
|
443
|
+
if (!forDevice) {
|
|
444
|
+
return [];
|
|
445
|
+
}
|
|
446
|
+
const teamId = env.AGENT_DEVICE_IOS_TEAM_ID?.trim() || '';
|
|
447
|
+
const configuredIdentity = env.AGENT_DEVICE_IOS_SIGNING_IDENTITY?.trim() || '';
|
|
448
|
+
const profile = env.AGENT_DEVICE_IOS_PROVISIONING_PROFILE?.trim() || '';
|
|
449
|
+
const args = ['CODE_SIGN_STYLE=Automatic'];
|
|
450
|
+
if (teamId) {
|
|
451
|
+
args.push(`DEVELOPMENT_TEAM=${teamId}`);
|
|
452
|
+
}
|
|
453
|
+
if (configuredIdentity) {
|
|
454
|
+
args.push(`CODE_SIGN_IDENTITY=${configuredIdentity}`);
|
|
455
|
+
}
|
|
456
|
+
if (profile) args.push(`PROVISIONING_PROFILE_SPECIFIER=${profile}`);
|
|
457
|
+
return args;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
function resolveSigningFailureHint(error: AppError): string | undefined {
|
|
461
|
+
const details = error.details ? JSON.stringify(error.details) : '';
|
|
462
|
+
const combined = `${error.message}\n${details}`.toLowerCase();
|
|
463
|
+
if (combined.includes('requires a development team')) {
|
|
464
|
+
return 'Configure signing in Xcode or set AGENT_DEVICE_IOS_TEAM_ID for physical-device runs.';
|
|
465
|
+
}
|
|
466
|
+
if (combined.includes('no profiles for') || combined.includes('provisioning profile')) {
|
|
467
|
+
return 'Install/select a valid iOS provisioning profile, or set AGENT_DEVICE_IOS_PROVISIONING_PROFILE.';
|
|
468
|
+
}
|
|
469
|
+
if (combined.includes('code signing')) {
|
|
470
|
+
return 'Enable Automatic Signing in Xcode or provide AGENT_DEVICE_IOS_TEAM_ID and optional AGENT_DEVICE_IOS_SIGNING_IDENTITY.';
|
|
471
|
+
}
|
|
472
|
+
return undefined;
|
|
473
|
+
}
|
|
474
|
+
|
|
357
475
|
function findXctestrun(root: string): string | null {
|
|
358
476
|
if (!fs.existsSync(root)) return null;
|
|
359
477
|
const candidates: { path: string; mtimeMs: number }[] = [];
|
|
@@ -417,9 +535,54 @@ function isReadOnlyRunnerCommand(command: RunnerCommand['command']): boolean {
|
|
|
417
535
|
}
|
|
418
536
|
|
|
419
537
|
function shouldCleanDerived(): boolean {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
538
|
+
return isEnvTruthy(process.env.AGENT_DEVICE_IOS_CLEAN_DERIVED);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export function assertSafeDerivedCleanup(
|
|
542
|
+
derivedPath: string,
|
|
543
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
544
|
+
): void {
|
|
545
|
+
const override = env.AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH?.trim();
|
|
546
|
+
if (!override) {
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
if (isCleanupOverrideAllowed(env)) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
throw new AppError(
|
|
553
|
+
'COMMAND_FAILED',
|
|
554
|
+
'Refusing to clean AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH automatically',
|
|
555
|
+
{
|
|
556
|
+
derivedPath,
|
|
557
|
+
hint: 'Unset AGENT_DEVICE_IOS_CLEAN_DERIVED, or set AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1 if you trust this path.',
|
|
558
|
+
},
|
|
559
|
+
);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function isCleanupOverrideAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
563
|
+
return isEnvTruthy(env.AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function buildRunnerConnectError(params: {
|
|
567
|
+
port: number;
|
|
568
|
+
endpoints: string[];
|
|
569
|
+
logPath?: string;
|
|
570
|
+
lastError: unknown;
|
|
571
|
+
}): AppError {
|
|
572
|
+
const { port, endpoints, logPath, lastError } = params;
|
|
573
|
+
const message = 'Runner did not accept connection';
|
|
574
|
+
return new AppError('COMMAND_FAILED', message, {
|
|
575
|
+
port,
|
|
576
|
+
endpoints,
|
|
577
|
+
logPath,
|
|
578
|
+
lastError: lastError ? String(lastError) : undefined,
|
|
579
|
+
reason: classifyBootFailure({
|
|
580
|
+
error: lastError,
|
|
581
|
+
message,
|
|
582
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
583
|
+
}),
|
|
584
|
+
hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
|
|
585
|
+
});
|
|
423
586
|
}
|
|
424
587
|
|
|
425
588
|
async function waitForRunner(
|
|
@@ -429,43 +592,167 @@ async function waitForRunner(
|
|
|
429
592
|
logPath?: string,
|
|
430
593
|
timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
|
|
431
594
|
): Promise<Response> {
|
|
432
|
-
const
|
|
595
|
+
const deadline = Deadline.fromTimeoutMs(timeoutMs);
|
|
596
|
+
let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
|
|
433
597
|
let lastError: unknown = null;
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
598
|
+
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / RUNNER_CONNECT_ATTEMPT_INTERVAL_MS));
|
|
599
|
+
try {
|
|
600
|
+
return await retryWithPolicy(
|
|
601
|
+
async ({ deadline: attemptDeadline }) => {
|
|
602
|
+
if (attemptDeadline?.isExpired()) {
|
|
603
|
+
throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
|
|
604
|
+
port,
|
|
605
|
+
timeoutMs,
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
if (device.kind === 'device') {
|
|
609
|
+
endpoints = await resolveRunnerCommandEndpoints(device, port, attemptDeadline?.remainingMs());
|
|
610
|
+
}
|
|
611
|
+
for (const endpoint of endpoints) {
|
|
612
|
+
try {
|
|
613
|
+
const remainingMs = attemptDeadline?.remainingMs() ?? timeoutMs;
|
|
614
|
+
if (remainingMs <= 0) {
|
|
615
|
+
throw new AppError('COMMAND_FAILED', 'Runner connection deadline exceeded', {
|
|
616
|
+
port,
|
|
617
|
+
timeoutMs,
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
const response = await fetchWithTimeout(
|
|
621
|
+
endpoint,
|
|
622
|
+
{
|
|
623
|
+
method: 'POST',
|
|
624
|
+
headers: { 'Content-Type': 'application/json' },
|
|
625
|
+
body: JSON.stringify(command),
|
|
626
|
+
},
|
|
627
|
+
Math.min(RUNNER_CONNECT_REQUEST_TIMEOUT_MS, remainingMs),
|
|
628
|
+
);
|
|
629
|
+
return response;
|
|
630
|
+
} catch (err) {
|
|
631
|
+
lastError = err;
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
throw new AppError('COMMAND_FAILED', 'Runner endpoint probe failed', {
|
|
635
|
+
port,
|
|
636
|
+
endpoints,
|
|
637
|
+
lastError: lastError ? String(lastError) : undefined,
|
|
638
|
+
});
|
|
639
|
+
},
|
|
640
|
+
{
|
|
641
|
+
maxAttempts,
|
|
642
|
+
baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
|
|
643
|
+
maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
|
|
644
|
+
jitter: 0.2,
|
|
645
|
+
shouldRetry: () => true,
|
|
646
|
+
},
|
|
647
|
+
{ deadline, phase: 'ios_runner_connect' },
|
|
648
|
+
);
|
|
649
|
+
} catch (error) {
|
|
650
|
+
if (!lastError) {
|
|
651
|
+
lastError = error;
|
|
445
652
|
}
|
|
446
653
|
}
|
|
654
|
+
|
|
447
655
|
if (device.kind === 'simulator') {
|
|
448
|
-
const
|
|
656
|
+
const remainingMs = deadline.remainingMs();
|
|
657
|
+
if (remainingMs <= 0) {
|
|
658
|
+
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
|
|
659
|
+
}
|
|
660
|
+
const simResponse = await postCommandViaSimulator(device.id, port, command, remainingMs);
|
|
449
661
|
return new Response(simResponse.body, { status: simResponse.status });
|
|
450
662
|
}
|
|
451
663
|
|
|
452
|
-
throw
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
664
|
+
throw buildRunnerConnectError({ port, endpoints, logPath, lastError });
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function resolveRunnerCommandEndpoints(
|
|
668
|
+
device: DeviceInfo,
|
|
669
|
+
port: number,
|
|
670
|
+
timeoutBudgetMs?: number,
|
|
671
|
+
): Promise<string[]> {
|
|
672
|
+
const endpoints = [`http://127.0.0.1:${port}/command`];
|
|
673
|
+
if (device.kind !== 'device') {
|
|
674
|
+
return endpoints;
|
|
675
|
+
}
|
|
676
|
+
const tunnelIp = await resolveDeviceTunnelIp(device.id, timeoutBudgetMs);
|
|
677
|
+
if (tunnelIp) {
|
|
678
|
+
endpoints.unshift(`http://[${tunnelIp}]:${port}/command`);
|
|
679
|
+
}
|
|
680
|
+
return endpoints;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
async function fetchWithTimeout(
|
|
684
|
+
url: string,
|
|
685
|
+
init: RequestInit,
|
|
686
|
+
timeoutMs: number,
|
|
687
|
+
): Promise<Response> {
|
|
688
|
+
const controller = new AbortController();
|
|
689
|
+
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
690
|
+
try {
|
|
691
|
+
return await fetch(url, { ...init, signal: controller.signal });
|
|
692
|
+
} finally {
|
|
693
|
+
clearTimeout(timeout);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function resolveDeviceTunnelIp(deviceId: string, timeoutBudgetMs?: number): Promise<string | null> {
|
|
698
|
+
if (typeof timeoutBudgetMs === 'number' && timeoutBudgetMs <= 0) {
|
|
699
|
+
return null;
|
|
700
|
+
}
|
|
701
|
+
const timeoutMs = typeof timeoutBudgetMs === 'number'
|
|
702
|
+
? Math.max(1, Math.min(RUNNER_DEVICE_INFO_TIMEOUT_MS, timeoutBudgetMs))
|
|
703
|
+
: RUNNER_DEVICE_INFO_TIMEOUT_MS;
|
|
704
|
+
const jsonPath = path.join(
|
|
705
|
+
os.tmpdir(),
|
|
706
|
+
`agent-device-devicectl-info-${process.pid}-${Date.now()}.json`,
|
|
707
|
+
);
|
|
708
|
+
try {
|
|
709
|
+
const devicectlTimeoutSeconds = Math.max(1, Math.ceil(timeoutMs / 1000));
|
|
710
|
+
const result = await runCmd(
|
|
711
|
+
'xcrun',
|
|
712
|
+
[
|
|
713
|
+
'devicectl',
|
|
714
|
+
'device',
|
|
715
|
+
'info',
|
|
716
|
+
'details',
|
|
717
|
+
'--device',
|
|
718
|
+
deviceId,
|
|
719
|
+
'--json-output',
|
|
720
|
+
jsonPath,
|
|
721
|
+
'--timeout',
|
|
722
|
+
String(devicectlTimeoutSeconds),
|
|
723
|
+
],
|
|
724
|
+
{ allowFailure: true, timeoutMs },
|
|
725
|
+
);
|
|
726
|
+
if (result.exitCode !== 0 || !fs.existsSync(jsonPath)) {
|
|
727
|
+
return null;
|
|
728
|
+
}
|
|
729
|
+
const payload = JSON.parse(fs.readFileSync(jsonPath, 'utf8')) as {
|
|
730
|
+
info?: { outcome?: string };
|
|
731
|
+
result?: {
|
|
732
|
+
connectionProperties?: { tunnelIPAddress?: string };
|
|
733
|
+
device?: { connectionProperties?: { tunnelIPAddress?: string } };
|
|
734
|
+
};
|
|
735
|
+
};
|
|
736
|
+
if (payload.info?.outcome && payload.info.outcome !== 'success') {
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
const ip = (
|
|
740
|
+
payload.result?.connectionProperties?.tunnelIPAddress
|
|
741
|
+
?? payload.result?.device?.connectionProperties?.tunnelIPAddress
|
|
742
|
+
)?.trim();
|
|
743
|
+
return ip && ip.length > 0 ? ip : null;
|
|
744
|
+
} catch {
|
|
745
|
+
return null;
|
|
746
|
+
} finally {
|
|
747
|
+
cleanupTempFile(jsonPath);
|
|
748
|
+
}
|
|
463
749
|
}
|
|
464
750
|
|
|
465
751
|
async function postCommandViaSimulator(
|
|
466
752
|
udid: string,
|
|
467
753
|
port: number,
|
|
468
754
|
command: RunnerCommand,
|
|
755
|
+
timeoutMs: number,
|
|
469
756
|
): Promise<{ status: number; body: string }> {
|
|
470
757
|
const payload = JSON.stringify(command);
|
|
471
758
|
const result = await runCmd(
|
|
@@ -484,7 +771,7 @@ async function postCommandViaSimulator(
|
|
|
484
771
|
payload,
|
|
485
772
|
`http://127.0.0.1:${port}/command`,
|
|
486
773
|
],
|
|
487
|
-
{ allowFailure: true },
|
|
774
|
+
{ allowFailure: true, timeoutMs },
|
|
488
775
|
);
|
|
489
776
|
const body = result.stdout as string;
|
|
490
777
|
if (result.exitCode !== 0) {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseArgs, usage } from '../args.ts';
|
|
4
|
+
import { AppError } from '../errors.ts';
|
|
5
|
+
import { getCliCommandNames, getSchemaCapabilityKeys } from '../command-schema.ts';
|
|
6
|
+
import { listCapabilityCommands } from '../../core/capabilities.ts';
|
|
7
|
+
|
|
8
|
+
test('parseArgs recognizes --relaunch', () => {
|
|
9
|
+
const parsed = parseArgs(['open', 'settings', '--relaunch']);
|
|
10
|
+
assert.equal(parsed.command, 'open');
|
|
11
|
+
assert.deepEqual(parsed.positionals, ['settings']);
|
|
12
|
+
assert.equal(parsed.flags.relaunch, true);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
test('parseArgs recognizes press series flags', () => {
|
|
16
|
+
const parsed = parseArgs([
|
|
17
|
+
'press',
|
|
18
|
+
'300',
|
|
19
|
+
'500',
|
|
20
|
+
'--count',
|
|
21
|
+
'12',
|
|
22
|
+
'--interval-ms=45',
|
|
23
|
+
'--hold-ms',
|
|
24
|
+
'120',
|
|
25
|
+
'--jitter-px',
|
|
26
|
+
'3',
|
|
27
|
+
]);
|
|
28
|
+
assert.equal(parsed.command, 'press');
|
|
29
|
+
assert.deepEqual(parsed.positionals, ['300', '500']);
|
|
30
|
+
assert.equal(parsed.flags.count, 12);
|
|
31
|
+
assert.equal(parsed.flags.intervalMs, 45);
|
|
32
|
+
assert.equal(parsed.flags.holdMs, 120);
|
|
33
|
+
assert.equal(parsed.flags.jitterPx, 3);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('parseArgs recognizes swipe positional + pattern flags', () => {
|
|
37
|
+
const parsed = parseArgs([
|
|
38
|
+
'swipe',
|
|
39
|
+
'540',
|
|
40
|
+
'1500',
|
|
41
|
+
'540',
|
|
42
|
+
'500',
|
|
43
|
+
'120',
|
|
44
|
+
'--count',
|
|
45
|
+
'8',
|
|
46
|
+
'--pause-ms',
|
|
47
|
+
'30',
|
|
48
|
+
'--pattern',
|
|
49
|
+
'ping-pong',
|
|
50
|
+
]);
|
|
51
|
+
assert.equal(parsed.command, 'swipe');
|
|
52
|
+
assert.deepEqual(parsed.positionals, ['540', '1500', '540', '500', '120']);
|
|
53
|
+
assert.equal(parsed.flags.count, 8);
|
|
54
|
+
assert.equal(parsed.flags.pauseMs, 30);
|
|
55
|
+
assert.equal(parsed.flags.pattern, 'ping-pong');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('parseArgs rejects invalid swipe pattern', () => {
|
|
59
|
+
assert.throws(
|
|
60
|
+
() => parseArgs(['swipe', '0', '0', '10', '10', '--pattern', 'diagonal']),
|
|
61
|
+
/Invalid pattern/,
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('usage includes --relaunch flag', () => {
|
|
66
|
+
assert.match(usage(), /--relaunch/);
|
|
67
|
+
assert.match(usage(), /pinch <scale> \[x\] \[y\]/);
|
|
68
|
+
assert.match(usage(), /--metadata/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('every capability command has a parser schema entry', () => {
|
|
72
|
+
const schemaCommands = new Set(getCliCommandNames());
|
|
73
|
+
for (const command of listCapabilityCommands()) {
|
|
74
|
+
assert.equal(schemaCommands.has(command), true, `Missing schema for command: ${command}`);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('schema capability mappings match capability source-of-truth', () => {
|
|
79
|
+
assert.deepEqual(getSchemaCapabilityKeys(), listCapabilityCommands());
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('compat mode warns and strips unsupported pilot-command flags', () => {
|
|
83
|
+
const parsed = parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: false });
|
|
84
|
+
assert.equal(parsed.command, 'press');
|
|
85
|
+
assert.equal(parsed.flags.snapshotDepth, undefined);
|
|
86
|
+
assert.equal(parsed.warnings.length, 1);
|
|
87
|
+
assert.match(parsed.warnings[0], /not supported for command press/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('strict mode rejects unsupported pilot-command flags', () => {
|
|
91
|
+
assert.throws(
|
|
92
|
+
() => parseArgs(['press', '10', '20', '--depth', '2'], { strictFlags: true }),
|
|
93
|
+
(error) =>
|
|
94
|
+
error instanceof AppError &&
|
|
95
|
+
error.code === 'INVALID_ARGS' &&
|
|
96
|
+
error.message.includes('not supported for command press'),
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('snapshot command accepts command-specific flags', () => {
|
|
101
|
+
const parsed = parseArgs(['snapshot', '-i', '-c', '--depth', '3', '-s', 'Login'], { strictFlags: true });
|
|
102
|
+
assert.equal(parsed.command, 'snapshot');
|
|
103
|
+
assert.equal(parsed.flags.snapshotInteractiveOnly, true);
|
|
104
|
+
assert.equal(parsed.flags.snapshotCompact, true);
|
|
105
|
+
assert.equal(parsed.flags.snapshotDepth, 3);
|
|
106
|
+
assert.equal(parsed.flags.snapshotScope, 'Login');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('unknown short flags are rejected', () => {
|
|
110
|
+
assert.throws(
|
|
111
|
+
() => parseArgs(['press', '10', '20', '-x'], { strictFlags: true }),
|
|
112
|
+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Unknown flag: -x',
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('negative numeric positionals are accepted without -- separator', () => {
|
|
117
|
+
const typed = parseArgs(['type', '-123'], { strictFlags: true });
|
|
118
|
+
assert.equal(typed.command, 'type');
|
|
119
|
+
assert.deepEqual(typed.positionals, ['-123']);
|
|
120
|
+
|
|
121
|
+
const typedMulti = parseArgs(['type', '-123', '-456'], { strictFlags: true });
|
|
122
|
+
assert.equal(typedMulti.command, 'type');
|
|
123
|
+
assert.deepEqual(typedMulti.positionals, ['-123', '-456']);
|
|
124
|
+
|
|
125
|
+
const pressed = parseArgs(['press', '-10', '20'], { strictFlags: true });
|
|
126
|
+
assert.equal(pressed.command, 'press');
|
|
127
|
+
assert.deepEqual(pressed.positionals, ['-10', '20']);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test('command-specific flags without command fail in strict mode', () => {
|
|
131
|
+
assert.throws(
|
|
132
|
+
() => parseArgs(['--depth', '3'], { strictFlags: true }),
|
|
133
|
+
(error) =>
|
|
134
|
+
error instanceof AppError &&
|
|
135
|
+
error.code === 'INVALID_ARGS' &&
|
|
136
|
+
error.message.includes('requires a command that supports it'),
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('command-specific flags without command warn and strip in compat mode', () => {
|
|
141
|
+
const parsed = parseArgs(['--depth', '3'], { strictFlags: false });
|
|
142
|
+
assert.equal(parsed.command, null);
|
|
143
|
+
assert.equal(parsed.flags.snapshotDepth, undefined);
|
|
144
|
+
assert.equal(parsed.warnings.length, 1);
|
|
145
|
+
assert.match(parsed.warnings[0], /requires a command that supports/);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test('all commands participate in strict command-flag validation', () => {
|
|
149
|
+
assert.throws(
|
|
150
|
+
() => parseArgs(['open', 'Settings', '--depth', '1'], { strictFlags: true }),
|
|
151
|
+
(error) =>
|
|
152
|
+
error instanceof AppError &&
|
|
153
|
+
error.code === 'INVALID_ARGS' &&
|
|
154
|
+
error.message.includes('not supported for command open'),
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('invalid enum/range errors are deterministic', () => {
|
|
159
|
+
assert.throws(
|
|
160
|
+
() => parseArgs(['snapshot', '--backend', 'foo'], { strictFlags: true }),
|
|
161
|
+
(error) =>
|
|
162
|
+
error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid backend: foo',
|
|
163
|
+
);
|
|
164
|
+
assert.throws(
|
|
165
|
+
() => parseArgs(['snapshot', '--depth', '-1'], { strictFlags: true }),
|
|
166
|
+
(error) => error instanceof AppError && error.code === 'INVALID_ARGS' && error.message === 'Invalid depth: -1',
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('usage includes swipe and press series options', () => {
|
|
171
|
+
const help = usage();
|
|
172
|
+
assert.match(help, /swipe <x1> <y1> <x2> <y2>/);
|
|
173
|
+
assert.match(help, /--pattern one-way\|ping-pong/);
|
|
174
|
+
assert.match(help, /--interval-ms/);
|
|
175
|
+
});
|