agent-device 0.3.1 → 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 +12 -3
- package/dist/src/274.js +1 -1
- package/dist/src/bin.js +25 -22
- package/dist/src/daemon.js +15 -11
- package/package.json +1 -1
- package/skills/agent-device/SKILL.md +8 -1
- package/src/cli.ts +7 -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/platforms/__tests__/boot-diagnostics.test.ts +37 -8
- package/src/platforms/android/__tests__/index.test.ts +17 -0
- package/src/platforms/android/devices.ts +47 -14
- package/src/platforms/android/index.ts +101 -14
- package/src/platforms/boot-diagnostics.ts +78 -17
- package/src/platforms/ios/index.ts +76 -9
- 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 +17 -0
- package/src/utils/args.ts +2 -0
- package/src/utils/exec.ts +39 -0
- package/src/utils/finders.ts +27 -9
- package/src/utils/retry.ts +72 -2
|
@@ -14,6 +14,39 @@ import { pruneGroupNodes } from '../snapshot-processing.ts';
|
|
|
14
14
|
import { buildSelectorChainForNode, parseSelectorChain, resolveSelectorChain, splitSelectorFromArgs } from '../selectors.ts';
|
|
15
15
|
import { inferFillText, uniqueStrings } from '../action-utils.ts';
|
|
16
16
|
|
|
17
|
+
type ReinstallOps = {
|
|
18
|
+
ios: (device: DeviceInfo, app: string, appPath: string) => Promise<{ bundleId: string }>;
|
|
19
|
+
android: (device: DeviceInfo, app: string, appPath: string) => Promise<{ package: string }>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
function hasExplicitDeviceSelector(flags: DaemonRequest['flags'] | undefined): boolean {
|
|
23
|
+
return Boolean(flags?.platform || flags?.device || flags?.udid || flags?.serial);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function resolveCommandDevice(params: {
|
|
27
|
+
session: SessionState | undefined;
|
|
28
|
+
flags: DaemonRequest['flags'] | undefined;
|
|
29
|
+
ensureReadyFn: typeof ensureDeviceReady;
|
|
30
|
+
ensureReady?: boolean;
|
|
31
|
+
}): Promise<DeviceInfo> {
|
|
32
|
+
const device = params.session?.device ?? (await resolveTargetDevice(params.flags ?? {}));
|
|
33
|
+
if (params.ensureReady !== false) {
|
|
34
|
+
await params.ensureReadyFn(device);
|
|
35
|
+
}
|
|
36
|
+
return device;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const defaultReinstallOps: ReinstallOps = {
|
|
40
|
+
ios: async (device, app, appPath) => {
|
|
41
|
+
const { reinstallIosApp } = await import('../../platforms/ios/index.ts');
|
|
42
|
+
return await reinstallIosApp(device, app, appPath);
|
|
43
|
+
},
|
|
44
|
+
android: async (device, app, appPath) => {
|
|
45
|
+
const { reinstallAndroidApp } = await import('../../platforms/android/index.ts');
|
|
46
|
+
return await reinstallAndroidApp(device, app, appPath);
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
17
50
|
export async function handleSessionCommands(params: {
|
|
18
51
|
req: DaemonRequest;
|
|
19
52
|
sessionName: string;
|
|
@@ -21,9 +54,21 @@ export async function handleSessionCommands(params: {
|
|
|
21
54
|
sessionStore: SessionStore;
|
|
22
55
|
invoke: (req: DaemonRequest) => Promise<DaemonResponse>;
|
|
23
56
|
dispatch?: typeof dispatchCommand;
|
|
57
|
+
ensureReady?: typeof ensureDeviceReady;
|
|
58
|
+
reinstallOps?: ReinstallOps;
|
|
24
59
|
}): Promise<DaemonResponse | null> {
|
|
25
|
-
const {
|
|
60
|
+
const {
|
|
61
|
+
req,
|
|
62
|
+
sessionName,
|
|
63
|
+
logPath,
|
|
64
|
+
sessionStore,
|
|
65
|
+
invoke,
|
|
66
|
+
dispatch: dispatchOverride,
|
|
67
|
+
ensureReady: ensureReadyOverride,
|
|
68
|
+
reinstallOps = defaultReinstallOps,
|
|
69
|
+
} = params;
|
|
26
70
|
const dispatch = dispatchOverride ?? dispatchCommand;
|
|
71
|
+
const ensureReady = ensureReadyOverride ?? ensureDeviceReady;
|
|
27
72
|
const command = req.command;
|
|
28
73
|
|
|
29
74
|
if (command === 'session_list') {
|
|
@@ -72,7 +117,7 @@ export async function handleSessionCommands(params: {
|
|
|
72
117
|
if (command === 'apps') {
|
|
73
118
|
const session = sessionStore.get(sessionName);
|
|
74
119
|
const flags = req.flags ?? {};
|
|
75
|
-
if (!session && !flags
|
|
120
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
76
121
|
return {
|
|
77
122
|
ok: false,
|
|
78
123
|
error: {
|
|
@@ -81,8 +126,7 @@ export async function handleSessionCommands(params: {
|
|
|
81
126
|
},
|
|
82
127
|
};
|
|
83
128
|
}
|
|
84
|
-
const device = session
|
|
85
|
-
await ensureDeviceReady(device);
|
|
129
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
|
|
86
130
|
if (!isCommandSupportedOnDevice('apps', device)) {
|
|
87
131
|
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'apps is not supported on this device' } };
|
|
88
132
|
}
|
|
@@ -106,11 +150,39 @@ export async function handleSessionCommands(params: {
|
|
|
106
150
|
return { ok: true, data: { apps } };
|
|
107
151
|
}
|
|
108
152
|
|
|
109
|
-
if (command === '
|
|
153
|
+
if (command === 'boot') {
|
|
110
154
|
const session = sessionStore.get(sessionName);
|
|
111
155
|
const flags = req.flags ?? {};
|
|
156
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
error: {
|
|
160
|
+
code: 'INVALID_ARGS',
|
|
161
|
+
message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
}
|
|
112
165
|
const device = session?.device ?? (await resolveTargetDevice(flags));
|
|
113
|
-
|
|
166
|
+
if (!isCommandSupportedOnDevice('boot', device)) {
|
|
167
|
+
return { ok: false, error: { code: 'UNSUPPORTED_OPERATION', message: 'boot is not supported on this device' } };
|
|
168
|
+
}
|
|
169
|
+
await ensureReady(device);
|
|
170
|
+
return {
|
|
171
|
+
ok: true,
|
|
172
|
+
data: {
|
|
173
|
+
platform: device.platform,
|
|
174
|
+
device: device.name,
|
|
175
|
+
id: device.id,
|
|
176
|
+
kind: device.kind,
|
|
177
|
+
booted: true,
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (command === 'appstate') {
|
|
183
|
+
const session = sessionStore.get(sessionName);
|
|
184
|
+
const flags = req.flags ?? {};
|
|
185
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: true });
|
|
114
186
|
if (device.platform === 'ios') {
|
|
115
187
|
if (session?.appBundleId) {
|
|
116
188
|
return {
|
|
@@ -151,6 +223,62 @@ export async function handleSessionCommands(params: {
|
|
|
151
223
|
};
|
|
152
224
|
}
|
|
153
225
|
|
|
226
|
+
if (command === 'reinstall') {
|
|
227
|
+
const session = sessionStore.get(sessionName);
|
|
228
|
+
const flags = req.flags ?? {};
|
|
229
|
+
if (!session && !hasExplicitDeviceSelector(flags)) {
|
|
230
|
+
return {
|
|
231
|
+
ok: false,
|
|
232
|
+
error: {
|
|
233
|
+
code: 'INVALID_ARGS',
|
|
234
|
+
message: 'reinstall requires an active session or an explicit device selector (e.g. --platform ios).',
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
const app = req.positionals?.[0]?.trim();
|
|
239
|
+
const appPathInput = req.positionals?.[1]?.trim();
|
|
240
|
+
if (!app || !appPathInput) {
|
|
241
|
+
return {
|
|
242
|
+
ok: false,
|
|
243
|
+
error: { code: 'INVALID_ARGS', message: 'reinstall requires: reinstall <app> <path-to-app-binary>' },
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
const appPath = SessionStore.expandHome(appPathInput);
|
|
247
|
+
if (!fs.existsSync(appPath)) {
|
|
248
|
+
return {
|
|
249
|
+
ok: false,
|
|
250
|
+
error: { code: 'INVALID_ARGS', message: `App binary not found: ${appPath}` },
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const device = await resolveCommandDevice({ session, flags, ensureReadyFn: ensureReady, ensureReady: false });
|
|
254
|
+
if (!isCommandSupportedOnDevice('reinstall', device)) {
|
|
255
|
+
return {
|
|
256
|
+
ok: false,
|
|
257
|
+
error: { code: 'UNSUPPORTED_OPERATION', message: 'reinstall is not supported on this device' },
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
let reinstallData:
|
|
261
|
+
| { platform: 'ios'; appId: string; bundleId: string }
|
|
262
|
+
| { platform: 'android'; appId: string; package: string };
|
|
263
|
+
if (device.platform === 'ios') {
|
|
264
|
+
const iosResult = await reinstallOps.ios(device, app, appPath);
|
|
265
|
+
reinstallData = { platform: 'ios', appId: iosResult.bundleId, bundleId: iosResult.bundleId };
|
|
266
|
+
} else {
|
|
267
|
+
const androidResult = await reinstallOps.android(device, app, appPath);
|
|
268
|
+
reinstallData = { platform: 'android', appId: androidResult.package, package: androidResult.package };
|
|
269
|
+
}
|
|
270
|
+
const result = { app, appPath, ...reinstallData };
|
|
271
|
+
if (session) {
|
|
272
|
+
sessionStore.recordAction(session, {
|
|
273
|
+
command,
|
|
274
|
+
positionals: req.positionals ?? [],
|
|
275
|
+
flags: req.flags ?? {},
|
|
276
|
+
result,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return { ok: true, data: result };
|
|
280
|
+
}
|
|
281
|
+
|
|
154
282
|
if (command === 'open') {
|
|
155
283
|
if (sessionStore.has(sessionName)) {
|
|
156
284
|
const session = sessionStore.get(sessionName);
|
|
@@ -193,7 +321,6 @@ export async function handleSessionCommands(params: {
|
|
|
193
321
|
return { ok: true, data: { session: sessionName, appName, appBundleId } };
|
|
194
322
|
}
|
|
195
323
|
const device = await resolveTargetDevice(req.flags ?? {});
|
|
196
|
-
await ensureDeviceReady(device);
|
|
197
324
|
const inUse = sessionStore.toArray().find((s) => s.device.id === device.id);
|
|
198
325
|
if (inUse) {
|
|
199
326
|
return {
|
|
@@ -269,7 +396,9 @@ export async function handleSessionCommands(params: {
|
|
|
269
396
|
flags: action.flags ?? {},
|
|
270
397
|
});
|
|
271
398
|
if (response.ok) continue;
|
|
272
|
-
if (!shouldUpdate)
|
|
399
|
+
if (!shouldUpdate) {
|
|
400
|
+
return withReplayFailureContext(response, action, index, resolved);
|
|
401
|
+
}
|
|
273
402
|
const nextAction = await healReplayAction({
|
|
274
403
|
action,
|
|
275
404
|
sessionName,
|
|
@@ -278,7 +407,7 @@ export async function handleSessionCommands(params: {
|
|
|
278
407
|
dispatch,
|
|
279
408
|
});
|
|
280
409
|
if (!nextAction) {
|
|
281
|
-
return response;
|
|
410
|
+
return withReplayFailureContext(response, action, index, resolved);
|
|
282
411
|
}
|
|
283
412
|
actions[index] = nextAction;
|
|
284
413
|
response = await invoke({
|
|
@@ -289,7 +418,7 @@ export async function handleSessionCommands(params: {
|
|
|
289
418
|
flags: nextAction.flags ?? {},
|
|
290
419
|
});
|
|
291
420
|
if (!response.ok) {
|
|
292
|
-
return response;
|
|
421
|
+
return withReplayFailureContext(response, nextAction, index, resolved);
|
|
293
422
|
}
|
|
294
423
|
healed += 1;
|
|
295
424
|
}
|
|
@@ -334,6 +463,42 @@ export async function handleSessionCommands(params: {
|
|
|
334
463
|
return null;
|
|
335
464
|
}
|
|
336
465
|
|
|
466
|
+
function withReplayFailureContext(
|
|
467
|
+
response: DaemonResponse,
|
|
468
|
+
action: SessionAction,
|
|
469
|
+
index: number,
|
|
470
|
+
replayPath: string,
|
|
471
|
+
): DaemonResponse {
|
|
472
|
+
if (response.ok) return response;
|
|
473
|
+
const step = index + 1;
|
|
474
|
+
const summary = formatReplayActionSummary(action);
|
|
475
|
+
const details = {
|
|
476
|
+
...(response.error.details ?? {}),
|
|
477
|
+
replayPath,
|
|
478
|
+
step,
|
|
479
|
+
action: action.command,
|
|
480
|
+
positionals: action.positionals ?? [],
|
|
481
|
+
};
|
|
482
|
+
return {
|
|
483
|
+
ok: false,
|
|
484
|
+
error: {
|
|
485
|
+
code: response.error.code,
|
|
486
|
+
message: `Replay failed at step ${step} (${summary}): ${response.error.message}`,
|
|
487
|
+
details,
|
|
488
|
+
},
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function formatReplayActionSummary(action: SessionAction): string {
|
|
493
|
+
const values = (action.positionals ?? []).map((value) => {
|
|
494
|
+
const trimmed = value.trim();
|
|
495
|
+
if (/^-?\d+(\.\d+)?$/.test(trimmed)) return trimmed;
|
|
496
|
+
if (trimmed.startsWith('@')) return trimmed;
|
|
497
|
+
return JSON.stringify(trimmed);
|
|
498
|
+
});
|
|
499
|
+
return [action.command, ...values].join(' ');
|
|
500
|
+
}
|
|
501
|
+
|
|
337
502
|
async function healReplayAction(params: {
|
|
338
503
|
action: SessionAction;
|
|
339
504
|
sessionName: string;
|
|
@@ -1,23 +1,38 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { classifyBootFailure } from '../boot-diagnostics.ts';
|
|
3
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
4
4
|
import { AppError } from '../../utils/errors.ts';
|
|
5
5
|
|
|
6
6
|
test('classifyBootFailure maps timeout errors', () => {
|
|
7
|
-
const reason = classifyBootFailure({
|
|
8
|
-
|
|
7
|
+
const reason = classifyBootFailure({
|
|
8
|
+
message: 'bootstatus timed out after 120s',
|
|
9
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
10
|
+
});
|
|
11
|
+
assert.equal(reason, 'IOS_BOOT_TIMEOUT');
|
|
9
12
|
});
|
|
10
13
|
|
|
11
14
|
test('classifyBootFailure maps adb offline errors', () => {
|
|
12
|
-
const reason = classifyBootFailure({
|
|
13
|
-
|
|
15
|
+
const reason = classifyBootFailure({
|
|
16
|
+
stderr: 'error: device offline',
|
|
17
|
+
context: { platform: 'android', phase: 'transport' },
|
|
18
|
+
});
|
|
19
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
14
20
|
});
|
|
15
21
|
|
|
16
|
-
test('classifyBootFailure maps tool missing from AppError code', () => {
|
|
22
|
+
test('classifyBootFailure maps tool missing from AppError code (android)', () => {
|
|
17
23
|
const reason = classifyBootFailure({
|
|
18
24
|
error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
|
|
25
|
+
context: { platform: 'android', phase: 'transport' },
|
|
26
|
+
});
|
|
27
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('classifyBootFailure maps tool missing from AppError code (ios)', () => {
|
|
31
|
+
const reason = classifyBootFailure({
|
|
32
|
+
error: new AppError('TOOL_MISSING', 'xcrun not found in PATH'),
|
|
33
|
+
context: { platform: 'ios', phase: 'boot' },
|
|
19
34
|
});
|
|
20
|
-
assert.equal(reason, '
|
|
35
|
+
assert.equal(reason, 'IOS_TOOL_MISSING');
|
|
21
36
|
});
|
|
22
37
|
|
|
23
38
|
test('classifyBootFailure reads stderr from AppError details', () => {
|
|
@@ -25,6 +40,20 @@ test('classifyBootFailure reads stderr from AppError details', () => {
|
|
|
25
40
|
error: new AppError('COMMAND_FAILED', 'adb failed', {
|
|
26
41
|
stderr: 'error: device unauthorized',
|
|
27
42
|
}),
|
|
43
|
+
context: { platform: 'android', phase: 'transport' },
|
|
44
|
+
});
|
|
45
|
+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('bootFailureHint returns actionable guidance', () => {
|
|
49
|
+
const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
|
|
50
|
+
assert.equal(hint.includes('xcodebuild logs'), true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test('connect phase does not classify non-timeout errors as connect timeout', () => {
|
|
54
|
+
const reason = classifyBootFailure({
|
|
55
|
+
message: 'Runner returned malformed JSON payload',
|
|
56
|
+
context: { platform: 'ios', phase: 'connect' },
|
|
28
57
|
});
|
|
29
|
-
assert.equal(reason, '
|
|
58
|
+
assert.equal(reason, 'BOOT_COMMAND_FAILED');
|
|
30
59
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { parseAndroidLaunchComponent } from '../index.ts';
|
|
3
4
|
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
|
|
4
5
|
|
|
5
6
|
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
|
|
@@ -72,3 +73,19 @@ test('findBounds ignores bounds-like fragments inside other attribute values', (
|
|
|
72
73
|
|
|
73
74
|
assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
|
|
74
75
|
});
|
|
76
|
+
|
|
77
|
+
test('parseAndroidLaunchComponent extracts final resolved component', () => {
|
|
78
|
+
const stdout = [
|
|
79
|
+
'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
|
|
80
|
+
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
81
|
+
].join('\n');
|
|
82
|
+
assert.equal(
|
|
83
|
+
parseAndroidLaunchComponent(stdout),
|
|
84
|
+
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('parseAndroidLaunchComponent returns null when no component is present', () => {
|
|
89
|
+
const stdout = 'No activity found';
|
|
90
|
+
assert.equal(parseAndroidLaunchComponent(stdout), null);
|
|
91
|
+
});
|
|
@@ -2,11 +2,12 @@ import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
|
2
2
|
import type { ExecResult } from '../../utils/exec.ts';
|
|
3
3
|
import { AppError, asAppError } from '../../utils/errors.ts';
|
|
4
4
|
import type { DeviceInfo } from '../../utils/device.ts';
|
|
5
|
-
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
|
|
6
|
-
import { classifyBootFailure } from '../boot-diagnostics.ts';
|
|
5
|
+
import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
|
|
6
|
+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
|
|
7
7
|
|
|
8
8
|
const EMULATOR_SERIAL_PREFIX = 'emulator-';
|
|
9
9
|
const ANDROID_BOOT_POLL_MS = 1000;
|
|
10
|
+
const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
|
|
10
11
|
|
|
11
12
|
function adbArgs(serial: string, args: string[]): string[] {
|
|
12
13
|
return ['-s', serial, ...args];
|
|
@@ -16,9 +17,13 @@ function isEmulatorSerial(serial: string): boolean {
|
|
|
16
17
|
return serial.startsWith(EMULATOR_SERIAL_PREFIX);
|
|
17
18
|
}
|
|
18
19
|
|
|
19
|
-
async function readAndroidBootProp(
|
|
20
|
+
async function readAndroidBootProp(
|
|
21
|
+
serial: string,
|
|
22
|
+
timeoutMs = TIMEOUT_PROFILES.android_boot.operationMs,
|
|
23
|
+
): Promise<ExecResult> {
|
|
20
24
|
return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), {
|
|
21
25
|
allowFailure: true,
|
|
26
|
+
timeoutMs,
|
|
22
27
|
});
|
|
23
28
|
}
|
|
24
29
|
|
|
@@ -27,6 +32,7 @@ async function resolveAndroidDeviceName(serial: string, rawModel: string): Promi
|
|
|
27
32
|
if (!isEmulatorSerial(serial)) return modelName || serial;
|
|
28
33
|
const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), {
|
|
29
34
|
allowFailure: true,
|
|
35
|
+
timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
|
|
30
36
|
});
|
|
31
37
|
const avdName = avd.stdout.trim();
|
|
32
38
|
if (avd.exitCode === 0 && avdName) {
|
|
@@ -41,7 +47,9 @@ export async function listAndroidDevices(): Promise<DeviceInfo[]> {
|
|
|
41
47
|
throw new AppError('TOOL_MISSING', 'adb not found in PATH');
|
|
42
48
|
}
|
|
43
49
|
|
|
44
|
-
const result = await runCmd('adb', ['devices', '-l']
|
|
50
|
+
const result = await runCmd('adb', ['devices', '-l'], {
|
|
51
|
+
timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
|
|
52
|
+
});
|
|
45
53
|
const lines = result.stdout.split('\n').map((l: string) => l.trim());
|
|
46
54
|
const entries = lines
|
|
47
55
|
.filter((line) => line.length > 0 && !line.startsWith('List of devices'))
|
|
@@ -79,8 +87,9 @@ export async function isAndroidBooted(serial: string): Promise<boolean> {
|
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
90
|
+
const timeoutBudget = timeoutMs;
|
|
91
|
+
const deadline = Deadline.fromTimeoutMs(timeoutBudget);
|
|
92
|
+
const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / ANDROID_BOOT_POLL_MS));
|
|
84
93
|
let lastBootResult: ExecResult | undefined;
|
|
85
94
|
let timedOut = false;
|
|
86
95
|
try {
|
|
@@ -95,7 +104,11 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
|
|
|
95
104
|
message: 'timeout',
|
|
96
105
|
});
|
|
97
106
|
}
|
|
98
|
-
const
|
|
107
|
+
const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? timeoutBudget);
|
|
108
|
+
const result = await readAndroidBootProp(
|
|
109
|
+
serial,
|
|
110
|
+
Math.min(remainingMs, TIMEOUT_PROFILES.android_boot.operationMs),
|
|
111
|
+
);
|
|
99
112
|
lastBootResult = result;
|
|
100
113
|
if (result.stdout.trim() === '1') return;
|
|
101
114
|
throw new AppError('COMMAND_FAILED', 'Android device is still booting', {
|
|
@@ -115,41 +128,61 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
|
|
|
115
128
|
error,
|
|
116
129
|
stdout: lastBootResult?.stdout,
|
|
117
130
|
stderr: lastBootResult?.stderr,
|
|
131
|
+
context: { platform: 'android', phase: 'boot' },
|
|
118
132
|
});
|
|
119
|
-
return reason !== '
|
|
133
|
+
return reason !== 'ADB_TRANSPORT_UNAVAILABLE' && reason !== 'ANDROID_BOOT_TIMEOUT';
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
deadline,
|
|
138
|
+
phase: 'boot',
|
|
139
|
+
classifyReason: (error) =>
|
|
140
|
+
classifyBootFailure({
|
|
141
|
+
error,
|
|
142
|
+
stdout: lastBootResult?.stdout,
|
|
143
|
+
stderr: lastBootResult?.stderr,
|
|
144
|
+
context: { platform: 'android', phase: 'boot' },
|
|
145
|
+
}),
|
|
146
|
+
onEvent: (event: RetryTelemetryEvent) => {
|
|
147
|
+
if (!RETRY_LOGS_ENABLED) return;
|
|
148
|
+
process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
|
|
120
149
|
},
|
|
121
150
|
},
|
|
122
|
-
{ deadline },
|
|
123
151
|
);
|
|
124
152
|
} catch (error) {
|
|
125
153
|
const appErr = asAppError(error);
|
|
126
154
|
const stdout = lastBootResult?.stdout;
|
|
127
155
|
const stderr = lastBootResult?.stderr;
|
|
128
156
|
const exitCode = lastBootResult?.exitCode;
|
|
129
|
-
|
|
157
|
+
let reason = classifyBootFailure({
|
|
130
158
|
error,
|
|
131
159
|
stdout,
|
|
132
160
|
stderr,
|
|
161
|
+
context: { platform: 'android', phase: 'boot' },
|
|
133
162
|
});
|
|
163
|
+
if (reason === 'BOOT_COMMAND_FAILED' && appErr.message === 'Android device is still booting') {
|
|
164
|
+
reason = 'ANDROID_BOOT_TIMEOUT';
|
|
165
|
+
}
|
|
134
166
|
const baseDetails = {
|
|
135
167
|
serial,
|
|
136
|
-
timeoutMs,
|
|
168
|
+
timeoutMs: timeoutBudget,
|
|
137
169
|
elapsedMs: deadline.elapsedMs(),
|
|
138
170
|
reason,
|
|
171
|
+
hint: bootFailureHint(reason),
|
|
139
172
|
stdout,
|
|
140
173
|
stderr,
|
|
141
174
|
exitCode,
|
|
142
175
|
};
|
|
143
|
-
if (timedOut || reason === '
|
|
176
|
+
if (timedOut || reason === 'ANDROID_BOOT_TIMEOUT') {
|
|
144
177
|
throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails);
|
|
145
178
|
}
|
|
146
|
-
if (appErr.code === 'TOOL_MISSING'
|
|
179
|
+
if (appErr.code === 'TOOL_MISSING') {
|
|
147
180
|
throw new AppError('TOOL_MISSING', appErr.message, {
|
|
148
181
|
...baseDetails,
|
|
149
182
|
...(appErr.details ?? {}),
|
|
150
183
|
});
|
|
151
184
|
}
|
|
152
|
-
if (reason === '
|
|
185
|
+
if (reason === 'ADB_TRANSPORT_UNAVAILABLE') {
|
|
153
186
|
throw new AppError('COMMAND_FAILED', appErr.message, {
|
|
154
187
|
...baseDetails,
|
|
155
188
|
...(appErr.details ?? {}),
|
|
@@ -187,22 +187,70 @@ export async function openAndroidApp(
|
|
|
187
187
|
);
|
|
188
188
|
return;
|
|
189
189
|
}
|
|
190
|
-
|
|
190
|
+
try {
|
|
191
|
+
await runCmd(
|
|
192
|
+
'adb',
|
|
193
|
+
adbArgs(device, [
|
|
194
|
+
'shell',
|
|
195
|
+
'am',
|
|
196
|
+
'start',
|
|
197
|
+
'-a',
|
|
198
|
+
'android.intent.action.MAIN',
|
|
199
|
+
'-c',
|
|
200
|
+
'android.intent.category.DEFAULT',
|
|
201
|
+
'-c',
|
|
202
|
+
'android.intent.category.LAUNCHER',
|
|
203
|
+
'-p',
|
|
204
|
+
resolved.value,
|
|
205
|
+
]),
|
|
206
|
+
);
|
|
207
|
+
return;
|
|
208
|
+
} catch (initialError) {
|
|
209
|
+
const component = await resolveAndroidLaunchComponent(device, resolved.value);
|
|
210
|
+
if (!component) throw initialError;
|
|
211
|
+
await runCmd(
|
|
212
|
+
'adb',
|
|
213
|
+
adbArgs(device, [
|
|
214
|
+
'shell',
|
|
215
|
+
'am',
|
|
216
|
+
'start',
|
|
217
|
+
'-a',
|
|
218
|
+
'android.intent.action.MAIN',
|
|
219
|
+
'-c',
|
|
220
|
+
'android.intent.category.DEFAULT',
|
|
221
|
+
'-c',
|
|
222
|
+
'android.intent.category.LAUNCHER',
|
|
223
|
+
'-n',
|
|
224
|
+
component,
|
|
225
|
+
]),
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
async function resolveAndroidLaunchComponent(
|
|
231
|
+
device: DeviceInfo,
|
|
232
|
+
packageName: string,
|
|
233
|
+
): Promise<string | null> {
|
|
234
|
+
const result = await runCmd(
|
|
191
235
|
'adb',
|
|
192
|
-
adbArgs(device, [
|
|
193
|
-
|
|
194
|
-
'am',
|
|
195
|
-
'start',
|
|
196
|
-
'-a',
|
|
197
|
-
'android.intent.action.MAIN',
|
|
198
|
-
'-c',
|
|
199
|
-
'android.intent.category.DEFAULT',
|
|
200
|
-
'-c',
|
|
201
|
-
'android.intent.category.LAUNCHER',
|
|
202
|
-
'-p',
|
|
203
|
-
resolved.value,
|
|
204
|
-
]),
|
|
236
|
+
adbArgs(device, ['shell', 'cmd', 'package', 'resolve-activity', '--brief', packageName]),
|
|
237
|
+
{ allowFailure: true },
|
|
205
238
|
);
|
|
239
|
+
if (result.exitCode !== 0) return null;
|
|
240
|
+
return parseAndroidLaunchComponent(result.stdout);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export function parseAndroidLaunchComponent(stdout: string): string | null {
|
|
244
|
+
const lines = stdout
|
|
245
|
+
.split('\n')
|
|
246
|
+
.map((line: string) => line.trim())
|
|
247
|
+
.filter(Boolean);
|
|
248
|
+
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
249
|
+
const line = lines[index];
|
|
250
|
+
if (!line.includes('/')) continue;
|
|
251
|
+
return line.split(/\s+/)[0];
|
|
252
|
+
}
|
|
253
|
+
return null;
|
|
206
254
|
}
|
|
207
255
|
|
|
208
256
|
export async function openAndroidDevice(device: DeviceInfo): Promise<void> {
|
|
@@ -224,6 +272,45 @@ export async function closeAndroidApp(device: DeviceInfo, app: string): Promise<
|
|
|
224
272
|
await runCmd('adb', adbArgs(device, ['shell', 'am', 'force-stop', resolved.value]));
|
|
225
273
|
}
|
|
226
274
|
|
|
275
|
+
export async function uninstallAndroidApp(
|
|
276
|
+
device: DeviceInfo,
|
|
277
|
+
app: string,
|
|
278
|
+
): Promise<{ package: string }> {
|
|
279
|
+
const resolved = await resolveAndroidApp(device, app);
|
|
280
|
+
if (resolved.type === 'intent') {
|
|
281
|
+
throw new AppError('INVALID_ARGS', 'reinstall requires a package name, not an intent');
|
|
282
|
+
}
|
|
283
|
+
const result = await runCmd('adb', adbArgs(device, ['uninstall', resolved.value]), { allowFailure: true });
|
|
284
|
+
if (result.exitCode !== 0) {
|
|
285
|
+
const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
|
|
286
|
+
if (!output.includes('unknown package') && !output.includes('not installed')) {
|
|
287
|
+
throw new AppError('COMMAND_FAILED', `adb uninstall failed for ${resolved.value}`, {
|
|
288
|
+
stdout: result.stdout,
|
|
289
|
+
stderr: result.stderr,
|
|
290
|
+
exitCode: result.exitCode,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
return { package: resolved.value };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export async function installAndroidApp(device: DeviceInfo, appPath: string): Promise<void> {
|
|
298
|
+
await runCmd('adb', adbArgs(device, ['install', appPath]));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
export async function reinstallAndroidApp(
|
|
302
|
+
device: DeviceInfo,
|
|
303
|
+
app: string,
|
|
304
|
+
appPath: string,
|
|
305
|
+
): Promise<{ package: string }> {
|
|
306
|
+
if (!device.booted) {
|
|
307
|
+
await waitForAndroidBoot(device.id);
|
|
308
|
+
}
|
|
309
|
+
const { package: pkg } = await uninstallAndroidApp(device, app);
|
|
310
|
+
await installAndroidApp(device, appPath);
|
|
311
|
+
return { package: pkg };
|
|
312
|
+
}
|
|
313
|
+
|
|
227
314
|
export async function pressAndroid(device: DeviceInfo, x: number, y: number): Promise<void> {
|
|
228
315
|
await runCmd('adb', adbArgs(device, ['shell', 'input', 'tap', String(x), String(y)]));
|
|
229
316
|
}
|