agent-device 0.3.0 → 0.3.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 +26 -2
- package/dist/src/274.js +1 -0
- package/dist/src/bin.js +27 -22
- package/dist/src/daemon.js +15 -10
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +149 -0
- package/package.json +2 -2
- package/skills/agent-device/SKILL.md +8 -1
- package/src/cli.ts +13 -0
- package/src/core/__tests__/capabilities.test.ts +2 -0
- package/src/core/capabilities.ts +2 -0
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +5 -0
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
- package/src/daemon/handlers/__tests__/session.test.ts +122 -0
- package/src/daemon/handlers/find.ts +23 -3
- package/src/daemon/handlers/session.ts +175 -10
- package/src/daemon-client.ts +1 -24
- package/src/daemon.ts +1 -24
- package/src/platforms/__tests__/boot-diagnostics.test.ts +59 -0
- package/src/platforms/android/__tests__/index.test.ts +17 -0
- package/src/platforms/android/devices.ts +167 -42
- package/src/platforms/android/index.ts +101 -14
- package/src/platforms/boot-diagnostics.ts +128 -0
- package/src/platforms/ios/index.ts +161 -2
- package/src/platforms/ios/runner-client.ts +19 -1
- package/src/utils/__tests__/exec.test.ts +16 -0
- package/src/utils/__tests__/finders.test.ts +34 -0
- package/src/utils/__tests__/retry.test.ts +44 -0
- package/src/utils/args.ts +9 -1
- package/src/utils/exec.ts +39 -0
- package/src/utils/finders.ts +27 -9
- package/src/utils/retry.ts +143 -13
- package/src/utils/version.ts +26 -0
- package/dist/src/861.js +0 -1
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
import { runCmd } from '../../utils/exec.ts';
|
|
2
|
+
import type { ExecResult } from '../../utils/exec.ts';
|
|
2
3
|
import { AppError } from '../../utils/errors.ts';
|
|
3
4
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
5
|
+
import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
|
|
6
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
4
7
|
|
|
5
8
|
const ALIASES: Record<string, string> = {
|
|
6
9
|
settings: 'com.apple.Preferences',
|
|
7
10
|
};
|
|
8
11
|
|
|
12
|
+
const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(
|
|
13
|
+
process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS,
|
|
14
|
+
TIMEOUT_PROFILES.ios_boot.totalMs,
|
|
15
|
+
5_000,
|
|
16
|
+
);
|
|
17
|
+
const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
|
|
18
|
+
|
|
9
19
|
export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
|
|
10
20
|
const trimmed = app.trim();
|
|
11
21
|
if (trimmed.includes('.')) return trimmed;
|
|
@@ -83,6 +93,42 @@ export async function closeIosApp(device: DeviceInfo, app: string): Promise<void
|
|
|
83
93
|
]);
|
|
84
94
|
}
|
|
85
95
|
|
|
96
|
+
export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> {
|
|
97
|
+
ensureSimulator(device, 'reinstall');
|
|
98
|
+
const bundleId = await resolveIosApp(device, app);
|
|
99
|
+
await ensureBootedSimulator(device);
|
|
100
|
+
const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], {
|
|
101
|
+
allowFailure: true,
|
|
102
|
+
});
|
|
103
|
+
if (result.exitCode !== 0) {
|
|
104
|
+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
105
|
+
if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) {
|
|
106
|
+
throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, {
|
|
107
|
+
stdout: result.stdout,
|
|
108
|
+
stderr: result.stderr,
|
|
109
|
+
exitCode: result.exitCode,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { bundleId };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function installIosApp(device: DeviceInfo, appPath: string): Promise<void> {
|
|
117
|
+
ensureSimulator(device, 'reinstall');
|
|
118
|
+
await ensureBootedSimulator(device);
|
|
119
|
+
await runCmd('xcrun', ['simctl', 'install', device.id, appPath]);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function reinstallIosApp(
|
|
123
|
+
device: DeviceInfo,
|
|
124
|
+
app: string,
|
|
125
|
+
appPath: string,
|
|
126
|
+
): Promise<{ bundleId: string }> {
|
|
127
|
+
const { bundleId } = await uninstallIosApp(device, app);
|
|
128
|
+
await installIosApp(device, appPath);
|
|
129
|
+
return { bundleId };
|
|
130
|
+
}
|
|
131
|
+
|
|
86
132
|
export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
|
|
87
133
|
if (device.kind === 'simulator') {
|
|
88
134
|
await ensureBootedSimulator(device);
|
|
@@ -207,13 +253,119 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
|
|
|
207
253
|
if (device.kind !== 'simulator') return;
|
|
208
254
|
const state = await getSimulatorState(device.id);
|
|
209
255
|
if (state === 'Booted') return;
|
|
210
|
-
|
|
211
|
-
|
|
256
|
+
const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
|
|
257
|
+
let bootResult: ExecResult | undefined;
|
|
258
|
+
let bootStatusResult: ExecResult | undefined;
|
|
259
|
+
try {
|
|
260
|
+
await retryWithPolicy(
|
|
261
|
+
async ({ deadline: attemptDeadline }) => {
|
|
262
|
+
if (attemptDeadline?.isExpired()) {
|
|
263
|
+
throw new AppError('COMMAND_FAILED', 'iOS simulator boot deadline exceeded', {
|
|
264
|
+
timeoutMs: IOS_BOOT_TIMEOUT_MS,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? IOS_BOOT_TIMEOUT_MS);
|
|
268
|
+
bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], {
|
|
269
|
+
allowFailure: true,
|
|
270
|
+
timeoutMs: remainingMs,
|
|
271
|
+
});
|
|
272
|
+
const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
|
|
273
|
+
const bootAlreadyDone =
|
|
274
|
+
bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
|
|
275
|
+
if (bootResult.exitCode !== 0 && !bootAlreadyDone) {
|
|
276
|
+
throw new AppError('COMMAND_FAILED', 'simctl boot failed', {
|
|
277
|
+
stdout: bootResult.stdout,
|
|
278
|
+
stderr: bootResult.stderr,
|
|
279
|
+
exitCode: bootResult.exitCode,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
|
|
283
|
+
allowFailure: true,
|
|
284
|
+
timeoutMs: remainingMs,
|
|
285
|
+
});
|
|
286
|
+
if (bootStatusResult.exitCode !== 0) {
|
|
287
|
+
throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
|
|
288
|
+
stdout: bootStatusResult.stdout,
|
|
289
|
+
stderr: bootStatusResult.stderr,
|
|
290
|
+
exitCode: bootStatusResult.exitCode,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
const nextState = await getSimulatorState(device.id);
|
|
294
|
+
if (nextState !== 'Booted') {
|
|
295
|
+
throw new AppError('COMMAND_FAILED', 'Simulator is still booting', {
|
|
296
|
+
state: nextState,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
{
|
|
301
|
+
maxAttempts: 3,
|
|
302
|
+
baseDelayMs: 500,
|
|
303
|
+
maxDelayMs: 2000,
|
|
304
|
+
jitter: 0.2,
|
|
305
|
+
shouldRetry: (error) => {
|
|
306
|
+
const reason = classifyBootFailure({
|
|
307
|
+
error,
|
|
308
|
+
stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
|
|
309
|
+
stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
|
|
310
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
311
|
+
});
|
|
312
|
+
return reason !== 'IOS_BOOT_TIMEOUT' && reason !== 'CI_RESOURCE_STARVATION_SUSPECTED';
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
{
|
|
316
|
+
deadline,
|
|
317
|
+
phase: 'boot',
|
|
318
|
+
classifyReason: (error) =>
|
|
319
|
+
classifyBootFailure({
|
|
320
|
+
error,
|
|
321
|
+
stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
|
|
322
|
+
stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
|
|
323
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
324
|
+
}),
|
|
325
|
+
onEvent: (event: RetryTelemetryEvent) => {
|
|
326
|
+
if (!RETRY_LOGS_ENABLED) return;
|
|
327
|
+
process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
|
|
328
|
+
},
|
|
329
|
+
},
|
|
330
|
+
);
|
|
331
|
+
} catch (error) {
|
|
332
|
+
const bootStdout = bootResult?.stdout;
|
|
333
|
+
const bootStderr = bootResult?.stderr;
|
|
334
|
+
const bootExitCode = bootResult?.exitCode;
|
|
335
|
+
const bootstatusStdout = bootStatusResult?.stdout;
|
|
336
|
+
const bootstatusStderr = bootStatusResult?.stderr;
|
|
337
|
+
const bootstatusExitCode = bootStatusResult?.exitCode;
|
|
338
|
+
const reason = classifyBootFailure({
|
|
339
|
+
error,
|
|
340
|
+
stdout: bootstatusStdout ?? bootStdout,
|
|
341
|
+
stderr: bootstatusStderr ?? bootStderr,
|
|
342
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
343
|
+
});
|
|
344
|
+
throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
|
|
345
|
+
platform: 'ios',
|
|
346
|
+
deviceId: device.id,
|
|
347
|
+
timeoutMs: IOS_BOOT_TIMEOUT_MS,
|
|
348
|
+
elapsedMs: deadline.elapsedMs(),
|
|
349
|
+
reason,
|
|
350
|
+
hint: bootFailureHint(reason),
|
|
351
|
+
boot: bootResult
|
|
352
|
+
? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr }
|
|
353
|
+
: undefined,
|
|
354
|
+
bootstatus: bootStatusResult
|
|
355
|
+
? {
|
|
356
|
+
exitCode: bootstatusExitCode,
|
|
357
|
+
stdout: bootstatusStdout,
|
|
358
|
+
stderr: bootstatusStderr,
|
|
359
|
+
}
|
|
360
|
+
: undefined,
|
|
361
|
+
});
|
|
362
|
+
}
|
|
212
363
|
}
|
|
213
364
|
|
|
214
365
|
async function getSimulatorState(udid: string): Promise<string | null> {
|
|
215
366
|
const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], {
|
|
216
367
|
allowFailure: true,
|
|
368
|
+
timeoutMs: TIMEOUT_PROFILES.ios_boot.operationMs,
|
|
217
369
|
});
|
|
218
370
|
if (result.exitCode !== 0) return null;
|
|
219
371
|
try {
|
|
@@ -229,3 +381,10 @@ async function getSimulatorState(udid: string): Promise<string | null> {
|
|
|
229
381
|
}
|
|
230
382
|
return null;
|
|
231
383
|
}
|
|
384
|
+
|
|
385
|
+
function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
|
|
386
|
+
if (!raw) return fallback;
|
|
387
|
+
const parsed = Number(raw);
|
|
388
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
389
|
+
return Math.max(min, Math.floor(parsed));
|
|
390
|
+
}
|
|
@@ -7,6 +7,7 @@ import { runCmd, runCmdStreaming, runCmdBackground, type ExecResult, type ExecBa
|
|
|
7
7
|
import { withRetry } from '../../utils/retry.ts';
|
|
8
8
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
9
9
|
import net from 'node:net';
|
|
10
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
10
11
|
|
|
11
12
|
export type RunnerCommand = {
|
|
12
13
|
command:
|
|
@@ -204,7 +205,10 @@ export async function stopIosRunnerSession(deviceId: string): Promise<void> {
|
|
|
204
205
|
}
|
|
205
206
|
|
|
206
207
|
async function ensureBooted(udid: string): Promise<void> {
|
|
207
|
-
await runCmd('xcrun', ['simctl', 'bootstatus', udid, '-b'], {
|
|
208
|
+
await runCmd('xcrun', ['simctl', 'bootstatus', udid, '-b'], {
|
|
209
|
+
allowFailure: true,
|
|
210
|
+
timeoutMs: RUNNER_STARTUP_TIMEOUT_MS,
|
|
211
|
+
});
|
|
208
212
|
}
|
|
209
213
|
|
|
210
214
|
async function ensureRunnerSession(
|
|
@@ -449,6 +453,12 @@ async function waitForRunner(
|
|
|
449
453
|
port,
|
|
450
454
|
logPath,
|
|
451
455
|
lastError: lastError ? String(lastError) : undefined,
|
|
456
|
+
reason: classifyBootFailure({
|
|
457
|
+
error: lastError,
|
|
458
|
+
message: 'Runner did not accept connection',
|
|
459
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
460
|
+
}),
|
|
461
|
+
hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'),
|
|
452
462
|
});
|
|
453
463
|
}
|
|
454
464
|
|
|
@@ -478,11 +488,19 @@ async function postCommandViaSimulator(
|
|
|
478
488
|
);
|
|
479
489
|
const body = result.stdout as string;
|
|
480
490
|
if (result.exitCode !== 0) {
|
|
491
|
+
const reason = classifyBootFailure({
|
|
492
|
+
message: 'Runner did not accept connection (simctl spawn)',
|
|
493
|
+
stdout: result.stdout,
|
|
494
|
+
stderr: result.stderr,
|
|
495
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
496
|
+
});
|
|
481
497
|
throw new AppError('COMMAND_FAILED', 'Runner did not accept connection (simctl spawn)', {
|
|
482
498
|
port,
|
|
483
499
|
stdout: result.stdout,
|
|
484
500
|
stderr: result.stderr,
|
|
485
501
|
exitCode: result.exitCode,
|
|
502
|
+
reason,
|
|
503
|
+
hint: bootFailureHint(reason),
|
|
486
504
|
});
|
|
487
505
|
}
|
|
488
506
|
return { status: 200, body };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { runCmd } from '../exec.ts';
|
|
4
|
+
|
|
5
|
+
test('runCmd enforces timeoutMs and rejects with COMMAND_FAILED', async () => {
|
|
6
|
+
await assert.rejects(
|
|
7
|
+
runCmd(process.execPath, ['-e', 'setTimeout(() => {}, 10_000)'], { timeoutMs: 100 }),
|
|
8
|
+
(error: unknown) => {
|
|
9
|
+
const err = error as { code?: string; message?: string; details?: Record<string, unknown> };
|
|
10
|
+
return err?.code === 'COMMAND_FAILED' &&
|
|
11
|
+
typeof err?.message === 'string' &&
|
|
12
|
+
err.message.includes('timed out') &&
|
|
13
|
+
err.details?.timeoutMs === 100;
|
|
14
|
+
},
|
|
15
|
+
);
|
|
16
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { findBestMatchesByLocator, findNodeByLocator } from '../finders.ts';
|
|
4
|
+
import type { SnapshotNode } from '../snapshot.ts';
|
|
5
|
+
|
|
6
|
+
function makeNode(ref: string, label?: string, identifier?: string): SnapshotNode {
|
|
7
|
+
return {
|
|
8
|
+
index: Number(ref.replace('e', '')) || 0,
|
|
9
|
+
ref,
|
|
10
|
+
type: 'android.widget.TextView',
|
|
11
|
+
label,
|
|
12
|
+
identifier,
|
|
13
|
+
rect: { x: 0, y: 0, width: 100, height: 20 },
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
test('findBestMatchesByLocator returns all best-scored matches', () => {
|
|
18
|
+
const nodes: SnapshotNode[] = [
|
|
19
|
+
makeNode('e1', 'Continue'),
|
|
20
|
+
makeNode('e2', 'Continue'),
|
|
21
|
+
makeNode('e3', 'Continue later'),
|
|
22
|
+
];
|
|
23
|
+
const result = findBestMatchesByLocator(nodes, 'label', 'Continue', { requireRect: true });
|
|
24
|
+
assert.equal(result.score, 2);
|
|
25
|
+
assert.equal(result.matches.length, 2);
|
|
26
|
+
assert.equal(result.matches[0]?.ref, 'e1');
|
|
27
|
+
assert.equal(result.matches[1]?.ref, 'e2');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('findNodeByLocator preserves first best match behavior', () => {
|
|
31
|
+
const nodes: SnapshotNode[] = [makeNode('e1', 'Continue'), makeNode('e2', 'Continue')];
|
|
32
|
+
const match = findNodeByLocator(nodes, 'label', 'Continue', { requireRect: true });
|
|
33
|
+
assert.equal(match?.ref, 'e1');
|
|
34
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { Deadline, retryWithPolicy } from '../retry.ts';
|
|
4
|
+
|
|
5
|
+
test('Deadline tracks remaining and expiration', async () => {
|
|
6
|
+
const deadline = Deadline.fromTimeoutMs(25);
|
|
7
|
+
assert.equal(deadline.isExpired(), false);
|
|
8
|
+
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
9
|
+
assert.equal(deadline.isExpired(), true);
|
|
10
|
+
assert.equal(deadline.remainingMs(), 0);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test('retryWithPolicy retries until success', async () => {
|
|
14
|
+
let attempts = 0;
|
|
15
|
+
const result = await retryWithPolicy(
|
|
16
|
+
async () => {
|
|
17
|
+
attempts += 1;
|
|
18
|
+
if (attempts < 3) {
|
|
19
|
+
throw new Error('transient');
|
|
20
|
+
}
|
|
21
|
+
return 'ok';
|
|
22
|
+
},
|
|
23
|
+
{ maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 },
|
|
24
|
+
);
|
|
25
|
+
assert.equal(result, 'ok');
|
|
26
|
+
assert.equal(attempts, 3);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('retryWithPolicy emits telemetry events', async () => {
|
|
30
|
+
const events: string[] = [];
|
|
31
|
+
await retryWithPolicy(
|
|
32
|
+
async ({ attempt }) => {
|
|
33
|
+
if (attempt === 1) throw new Error('transient');
|
|
34
|
+
return 'ok';
|
|
35
|
+
},
|
|
36
|
+
{ maxAttempts: 2, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 },
|
|
37
|
+
{
|
|
38
|
+
phase: 'boot',
|
|
39
|
+
classifyReason: () => 'ANDROID_BOOT_TIMEOUT',
|
|
40
|
+
onEvent: (event) => events.push(event.event),
|
|
41
|
+
},
|
|
42
|
+
);
|
|
43
|
+
assert.deepEqual(events, ['attempt_failed', 'retry_scheduled', 'succeeded']);
|
|
44
|
+
});
|
package/src/utils/args.ts
CHANGED
|
@@ -25,11 +25,12 @@ export type ParsedArgs = {
|
|
|
25
25
|
noRecord?: boolean;
|
|
26
26
|
replayUpdate?: boolean;
|
|
27
27
|
help: boolean;
|
|
28
|
+
version: boolean;
|
|
28
29
|
};
|
|
29
30
|
};
|
|
30
31
|
|
|
31
32
|
export function parseArgs(argv: string[]): ParsedArgs {
|
|
32
|
-
const flags: ParsedArgs['flags'] = { json: false, help: false };
|
|
33
|
+
const flags: ParsedArgs['flags'] = { json: false, help: false, version: false };
|
|
33
34
|
const positionals: string[] = [];
|
|
34
35
|
|
|
35
36
|
for (let i = 0; i < argv.length; i += 1) {
|
|
@@ -42,6 +43,10 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
|
|
42
43
|
flags.help = true;
|
|
43
44
|
continue;
|
|
44
45
|
}
|
|
46
|
+
if (arg === '--version' || arg === '-V') {
|
|
47
|
+
flags.version = true;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
45
50
|
if (arg === '--verbose' || arg === '-v') {
|
|
46
51
|
flags.verbose = true;
|
|
47
52
|
continue;
|
|
@@ -168,8 +173,10 @@ export function usage(): string {
|
|
|
168
173
|
CLI to control iOS and Android devices for AI agents.
|
|
169
174
|
|
|
170
175
|
Commands:
|
|
176
|
+
boot Ensure target device/simulator is booted and ready
|
|
171
177
|
open [app] Boot device/simulator; optionally launch app
|
|
172
178
|
close [app] Close app or just end session
|
|
179
|
+
reinstall <app> <path> Uninstall + install app from binary path
|
|
173
180
|
snapshot [-i] [-c] [-d <depth>] [-s <scope>] [--raw] [--backend ax|xctest]
|
|
174
181
|
Capture accessibility tree
|
|
175
182
|
-i Interactive elements only
|
|
@@ -229,5 +236,6 @@ Flags:
|
|
|
229
236
|
--update, -u Replay: update selectors and rewrite replay file in place
|
|
230
237
|
--user-installed Apps: list user-installed packages (Android only)
|
|
231
238
|
--all Apps: list all packages (Android only)
|
|
239
|
+
--version, -V Print version and exit
|
|
232
240
|
`;
|
|
233
241
|
}
|
package/src/utils/exec.ts
CHANGED
|
@@ -14,6 +14,7 @@ export type ExecOptions = {
|
|
|
14
14
|
allowFailure?: boolean;
|
|
15
15
|
binaryStdout?: boolean;
|
|
16
16
|
stdin?: string | Buffer;
|
|
17
|
+
timeoutMs?: number;
|
|
17
18
|
};
|
|
18
19
|
|
|
19
20
|
export type ExecStreamOptions = ExecOptions & {
|
|
@@ -41,6 +42,14 @@ export async function runCmd(
|
|
|
41
42
|
let stdout = '';
|
|
42
43
|
let stdoutBuffer: Buffer | undefined = options.binaryStdout ? Buffer.alloc(0) : undefined;
|
|
43
44
|
let stderr = '';
|
|
45
|
+
let didTimeout = false;
|
|
46
|
+
const timeoutMs = normalizeTimeoutMs(options.timeoutMs);
|
|
47
|
+
const timeoutHandle = timeoutMs
|
|
48
|
+
? setTimeout(() => {
|
|
49
|
+
didTimeout = true;
|
|
50
|
+
child.kill('SIGKILL');
|
|
51
|
+
}, timeoutMs)
|
|
52
|
+
: null;
|
|
44
53
|
|
|
45
54
|
if (!options.binaryStdout) child.stdout.setEncoding('utf8');
|
|
46
55
|
child.stderr.setEncoding('utf8');
|
|
@@ -66,6 +75,7 @@ export async function runCmd(
|
|
|
66
75
|
});
|
|
67
76
|
|
|
68
77
|
child.on('error', (err) => {
|
|
78
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
69
79
|
const code = (err as NodeJS.ErrnoException).code;
|
|
70
80
|
if (code === 'ENOENT') {
|
|
71
81
|
reject(new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, err));
|
|
@@ -75,7 +85,21 @@ export async function runCmd(
|
|
|
75
85
|
});
|
|
76
86
|
|
|
77
87
|
child.on('close', (code) => {
|
|
88
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
78
89
|
const exitCode = code ?? 1;
|
|
90
|
+
if (didTimeout && timeoutMs) {
|
|
91
|
+
reject(
|
|
92
|
+
new AppError('COMMAND_FAILED', `${cmd} timed out after ${timeoutMs}ms`, {
|
|
93
|
+
cmd,
|
|
94
|
+
args,
|
|
95
|
+
stdout,
|
|
96
|
+
stderr,
|
|
97
|
+
exitCode,
|
|
98
|
+
timeoutMs,
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
79
103
|
if (exitCode !== 0 && !options.allowFailure) {
|
|
80
104
|
reject(
|
|
81
105
|
new AppError('COMMAND_FAILED', `${cmd} exited with code ${exitCode}`, {
|
|
@@ -110,10 +134,18 @@ export function runCmdSync(cmd: string, args: string[], options: ExecOptions = {
|
|
|
110
134
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
111
135
|
encoding: options.binaryStdout ? undefined : 'utf8',
|
|
112
136
|
input: options.stdin,
|
|
137
|
+
timeout: normalizeTimeoutMs(options.timeoutMs),
|
|
113
138
|
});
|
|
114
139
|
|
|
115
140
|
if (result.error) {
|
|
116
141
|
const code = (result.error as NodeJS.ErrnoException).code;
|
|
142
|
+
if (code === 'ETIMEDOUT') {
|
|
143
|
+
throw new AppError('COMMAND_FAILED', `${cmd} timed out after ${normalizeTimeoutMs(options.timeoutMs)}ms`, {
|
|
144
|
+
cmd,
|
|
145
|
+
args,
|
|
146
|
+
timeoutMs: normalizeTimeoutMs(options.timeoutMs),
|
|
147
|
+
}, result.error);
|
|
148
|
+
}
|
|
117
149
|
if (code === 'ENOENT') {
|
|
118
150
|
throw new AppError('TOOL_MISSING', `${cmd} not found in PATH`, { cmd }, result.error);
|
|
119
151
|
}
|
|
@@ -298,3 +330,10 @@ function resolveWhichArgs(cmd: string): { shell: string; args: string[] } {
|
|
|
298
330
|
}
|
|
299
331
|
return { shell: 'bash', args: ['-lc', `command -v ${cmd}`] };
|
|
300
332
|
}
|
|
333
|
+
|
|
334
|
+
function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
335
|
+
if (!Number.isFinite(value)) return undefined;
|
|
336
|
+
const timeout = Math.floor(value as number);
|
|
337
|
+
if (timeout <= 0) return undefined;
|
|
338
|
+
return timeout;
|
|
339
|
+
}
|
package/src/utils/finders.ts
CHANGED
|
@@ -6,28 +6,46 @@ export type FindMatchOptions = {
|
|
|
6
6
|
requireRect?: boolean;
|
|
7
7
|
};
|
|
8
8
|
|
|
9
|
+
export type FindBestMatches = {
|
|
10
|
+
matches: SnapshotNode[];
|
|
11
|
+
score: number;
|
|
12
|
+
};
|
|
13
|
+
|
|
9
14
|
export function findNodeByLocator(
|
|
10
15
|
nodes: SnapshotNode[],
|
|
11
16
|
locator: FindLocator,
|
|
12
17
|
query: string,
|
|
13
18
|
options: FindMatchOptions = {},
|
|
14
19
|
): SnapshotNode | null {
|
|
20
|
+
const best = findBestMatchesByLocator(nodes, locator, query, options);
|
|
21
|
+
return best.matches[0] ?? null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function findBestMatchesByLocator(
|
|
25
|
+
nodes: SnapshotNode[],
|
|
26
|
+
locator: FindLocator,
|
|
27
|
+
query: string,
|
|
28
|
+
options: FindMatchOptions = {},
|
|
29
|
+
): FindBestMatches {
|
|
15
30
|
const normalizedQuery = normalizeText(query);
|
|
16
|
-
if (!normalizedQuery) return
|
|
17
|
-
let
|
|
31
|
+
if (!normalizedQuery) return { matches: [], score: 0 };
|
|
32
|
+
let bestScore = 0;
|
|
33
|
+
const matches: SnapshotNode[] = [];
|
|
18
34
|
for (const node of nodes) {
|
|
19
35
|
if (options.requireRect && !node.rect) continue;
|
|
20
36
|
const score = matchNode(node, locator, normalizedQuery);
|
|
21
37
|
if (score <= 0) continue;
|
|
22
|
-
if (
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
38
|
+
if (score > bestScore) {
|
|
39
|
+
bestScore = score;
|
|
40
|
+
matches.length = 0;
|
|
41
|
+
matches.push(node);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (score === bestScore) {
|
|
45
|
+
matches.push(node);
|
|
28
46
|
}
|
|
29
47
|
}
|
|
30
|
-
return
|
|
48
|
+
return { matches, score: bestScore };
|
|
31
49
|
}
|
|
32
50
|
|
|
33
51
|
function matchNode(node: SnapshotNode, locator: FindLocator, query: string): number {
|