agent-device 0.4.2 → 0.5.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 +2 -9
- package/dist/src/797.js +1 -1
- package/dist/src/bin.js +5 -5
- package/dist/src/daemon.js +16 -20
- package/package.json +7 -9
- package/skills/agent-device/SKILL.md +3 -6
- package/skills/agent-device/references/permissions.md +3 -15
- package/skills/agent-device/references/snapshot-refs.md +1 -4
- package/dist/bin/axsnapshot +0 -0
- package/ios-runner/AXSnapshot/Package.swift +0 -18
- package/ios-runner/AXSnapshot/Sources/AXSnapshot/main.swift +0 -444
- package/src/__tests__/cli-close.test.ts +0 -155
- package/src/__tests__/cli-help.test.ts +0 -102
- package/src/bin.ts +0 -3
- package/src/cli.ts +0 -305
- package/src/core/__tests__/capabilities.test.ts +0 -75
- package/src/core/__tests__/dispatch-open.test.ts +0 -25
- package/src/core/__tests__/open-target.test.ts +0 -55
- package/src/core/capabilities.ts +0 -57
- package/src/core/dispatch.ts +0 -382
- package/src/core/open-target.ts +0 -27
- package/src/daemon/__tests__/device-ready.test.ts +0 -52
- package/src/daemon/__tests__/is-predicates.test.ts +0 -68
- package/src/daemon/__tests__/selectors.test.ts +0 -261
- package/src/daemon/__tests__/session-routing.test.ts +0 -108
- package/src/daemon/__tests__/session-selector.test.ts +0 -64
- package/src/daemon/__tests__/session-store.test.ts +0 -142
- package/src/daemon/__tests__/snapshot-processing.test.ts +0 -47
- package/src/daemon/action-utils.ts +0 -29
- package/src/daemon/context.ts +0 -48
- package/src/daemon/device-ready.ts +0 -155
- package/src/daemon/handlers/__tests__/find.test.ts +0 -99
- package/src/daemon/handlers/__tests__/interaction.test.ts +0 -22
- package/src/daemon/handlers/__tests__/replay-heal.test.ts +0 -509
- package/src/daemon/handlers/__tests__/session-reinstall.test.ts +0 -219
- package/src/daemon/handlers/__tests__/session.test.ts +0 -820
- package/src/daemon/handlers/__tests__/snapshot-handler.test.ts +0 -92
- package/src/daemon/handlers/__tests__/snapshot.test.ts +0 -128
- package/src/daemon/handlers/find.ts +0 -324
- package/src/daemon/handlers/interaction.ts +0 -550
- package/src/daemon/handlers/parse-utils.ts +0 -8
- package/src/daemon/handlers/record-trace.ts +0 -154
- package/src/daemon/handlers/session.ts +0 -1137
- package/src/daemon/handlers/snapshot.ts +0 -439
- package/src/daemon/is-predicates.ts +0 -46
- package/src/daemon/selectors.ts +0 -540
- package/src/daemon/session-routing.ts +0 -22
- package/src/daemon/session-selector.ts +0 -39
- package/src/daemon/session-store.ts +0 -296
- package/src/daemon/snapshot-processing.ts +0 -131
- package/src/daemon/types.ts +0 -56
- package/src/daemon-client.ts +0 -272
- package/src/daemon.ts +0 -295
- package/src/platforms/__tests__/boot-diagnostics.test.ts +0 -59
- package/src/platforms/android/__tests__/index.test.ts +0 -274
- package/src/platforms/android/devices.ts +0 -196
- package/src/platforms/android/index.ts +0 -784
- package/src/platforms/android/ui-hierarchy.ts +0 -312
- package/src/platforms/boot-diagnostics.ts +0 -128
- package/src/platforms/ios/__tests__/index.test.ts +0 -312
- package/src/platforms/ios/__tests__/runner-client.test.ts +0 -155
- package/src/platforms/ios/apps.ts +0 -358
- package/src/platforms/ios/ax-snapshot.ts +0 -207
- package/src/platforms/ios/config.ts +0 -28
- package/src/platforms/ios/devicectl.ts +0 -134
- package/src/platforms/ios/devices.ts +0 -100
- package/src/platforms/ios/index.ts +0 -20
- package/src/platforms/ios/runner-client.ts +0 -994
- package/src/platforms/ios/simulator.ts +0 -164
- package/src/utils/__tests__/args.test.ts +0 -239
- package/src/utils/__tests__/daemon-client.test.ts +0 -95
- package/src/utils/__tests__/exec.test.ts +0 -16
- package/src/utils/__tests__/finders.test.ts +0 -34
- package/src/utils/__tests__/keyed-lock.test.ts +0 -55
- package/src/utils/__tests__/process-identity.test.ts +0 -33
- package/src/utils/__tests__/retry.test.ts +0 -44
- package/src/utils/args.ts +0 -239
- package/src/utils/command-schema.ts +0 -622
- package/src/utils/device.ts +0 -84
- package/src/utils/errors.ts +0 -35
- package/src/utils/exec.ts +0 -339
- package/src/utils/finders.ts +0 -101
- package/src/utils/interactive.ts +0 -4
- package/src/utils/interactors.ts +0 -173
- package/src/utils/keyed-lock.ts +0 -14
- package/src/utils/output.ts +0 -204
- package/src/utils/process-identity.ts +0 -100
- package/src/utils/retry.ts +0 -180
- package/src/utils/snapshot.ts +0 -64
- package/src/utils/timeouts.ts +0 -9
- package/src/utils/version.ts +0 -26
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
import test from 'node:test';
|
|
2
|
-
import assert from 'node:assert/strict';
|
|
3
|
-
import { promises as fs } from 'node:fs';
|
|
4
|
-
import os from 'node:os';
|
|
5
|
-
import path from 'node:path';
|
|
6
|
-
import { inferAndroidAppName, listAndroidApps, openAndroidApp, parseAndroidLaunchComponent, swipeAndroid } from '../index.ts';
|
|
7
|
-
import type { DeviceInfo } from '../../../utils/device.ts';
|
|
8
|
-
import { AppError } from '../../../utils/errors.ts';
|
|
9
|
-
import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
|
|
10
|
-
|
|
11
|
-
test('parseUiHierarchy reads double-quoted Android node attributes', () => {
|
|
12
|
-
const xml =
|
|
13
|
-
'<hierarchy><node class="android.widget.TextView" text="Hello" content-desc="Greeting" resource-id="com.demo:id/title" bounds="[10,20][110,60]" clickable="true" enabled="true"/></hierarchy>';
|
|
14
|
-
|
|
15
|
-
const result = parseUiHierarchy(xml, 800, { raw: true });
|
|
16
|
-
assert.equal(result.nodes.length, 1);
|
|
17
|
-
assert.equal(result.nodes[0].value, 'Hello');
|
|
18
|
-
assert.equal(result.nodes[0].label, 'Hello');
|
|
19
|
-
assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
|
|
20
|
-
assert.deepEqual(result.nodes[0].rect, { x: 10, y: 20, width: 100, height: 40 });
|
|
21
|
-
assert.equal(result.nodes[0].hittable, true);
|
|
22
|
-
assert.equal(result.nodes[0].enabled, true);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
test('parseUiHierarchy reads single-quoted Android node attributes', () => {
|
|
26
|
-
const xml =
|
|
27
|
-
"<hierarchy><node class='android.widget.TextView' text='Hello' content-desc='Greeting' resource-id='com.demo:id/title' bounds='[10,20][110,60]' clickable='true' enabled='true'/></hierarchy>";
|
|
28
|
-
|
|
29
|
-
const result = parseUiHierarchy(xml, 800, { raw: true });
|
|
30
|
-
assert.equal(result.nodes.length, 1);
|
|
31
|
-
assert.equal(result.nodes[0].value, 'Hello');
|
|
32
|
-
assert.equal(result.nodes[0].label, 'Hello');
|
|
33
|
-
assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
|
|
34
|
-
assert.deepEqual(result.nodes[0].rect, { x: 10, y: 20, width: 100, height: 40 });
|
|
35
|
-
assert.equal(result.nodes[0].hittable, true);
|
|
36
|
-
assert.equal(result.nodes[0].enabled, true);
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
test('parseUiHierarchy supports mixed quote styles in one node', () => {
|
|
40
|
-
const xml =
|
|
41
|
-
'<hierarchy><node class="android.widget.TextView" text=\'Hello\' content-desc="Greeting" resource-id=\'com.demo:id/title\' bounds="[10,20][110,60]"/></hierarchy>';
|
|
42
|
-
|
|
43
|
-
const result = parseUiHierarchy(xml, 800, { raw: true });
|
|
44
|
-
assert.equal(result.nodes.length, 1);
|
|
45
|
-
assert.equal(result.nodes[0].value, 'Hello');
|
|
46
|
-
assert.equal(result.nodes[0].label, 'Hello');
|
|
47
|
-
assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
|
|
48
|
-
});
|
|
49
|
-
|
|
50
|
-
test('findBounds supports single and double quoted attributes', () => {
|
|
51
|
-
const xml = [
|
|
52
|
-
'<hierarchy>',
|
|
53
|
-
'<node text="Nothing" content-desc="Irrelevant" bounds="[0,0][10,10]"/>',
|
|
54
|
-
"<node text='Target from single quote' content-desc='Alt single' bounds='[100,200][300,500]'/>",
|
|
55
|
-
'<node text="Target from double quote" content-desc="Alt double" bounds="[50,50][150,250]"/>',
|
|
56
|
-
'</hierarchy>',
|
|
57
|
-
].join('');
|
|
58
|
-
|
|
59
|
-
assert.deepEqual(findBounds(xml, 'single quote'), { x: 200, y: 350 });
|
|
60
|
-
assert.deepEqual(findBounds(xml, 'alt double'), { x: 100, y: 150 });
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test('parseUiHierarchy ignores attribute-name prefix spoofing', () => {
|
|
64
|
-
const xml =
|
|
65
|
-
"<hierarchy><node class='android.widget.TextView' hint-text='Spoofed' text='Actual' bounds='[10,20][110,60]'/></hierarchy>";
|
|
66
|
-
|
|
67
|
-
const result = parseUiHierarchy(xml, 800, { raw: true });
|
|
68
|
-
assert.equal(result.nodes.length, 1);
|
|
69
|
-
assert.equal(result.nodes[0].value, 'Actual');
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('findBounds ignores bounds-like fragments inside other attribute values', () => {
|
|
73
|
-
const xml = [
|
|
74
|
-
'<hierarchy>',
|
|
75
|
-
"<node text='Target' content-desc=\"metadata bounds='[900,900][1000,1000]'\" bounds='[100,200][300,500]'/>",
|
|
76
|
-
'</hierarchy>',
|
|
77
|
-
].join('');
|
|
78
|
-
|
|
79
|
-
assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('parseAndroidLaunchComponent extracts final resolved component', () => {
|
|
83
|
-
const stdout = [
|
|
84
|
-
'priority=0 preferredOrder=0 match=0x108000 specificIndex=-1 isDefault=true',
|
|
85
|
-
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
86
|
-
].join('\n');
|
|
87
|
-
assert.equal(
|
|
88
|
-
parseAndroidLaunchComponent(stdout),
|
|
89
|
-
'com.boatsgroup.boattrader/com.boatsgroup.boattrader.MainActivity',
|
|
90
|
-
);
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('parseAndroidLaunchComponent returns null when no component is present', () => {
|
|
94
|
-
const stdout = 'No activity found';
|
|
95
|
-
assert.equal(parseAndroidLaunchComponent(stdout), null);
|
|
96
|
-
});
|
|
97
|
-
|
|
98
|
-
test('inferAndroidAppName derives readable names from package ids', () => {
|
|
99
|
-
assert.equal(inferAndroidAppName('com.android.settings'), 'Settings');
|
|
100
|
-
assert.equal(inferAndroidAppName('com.google.android.apps.maps'), 'Maps');
|
|
101
|
-
assert.equal(inferAndroidAppName('org.mozilla.firefox'), 'Firefox');
|
|
102
|
-
assert.equal(inferAndroidAppName('com.facebook.katana'), 'Katana');
|
|
103
|
-
assert.equal(inferAndroidAppName('single'), 'Single');
|
|
104
|
-
assert.equal(inferAndroidAppName('com.android.app.services'), 'Services');
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('listAndroidApps returns launchable apps with inferred names', async () => {
|
|
108
|
-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-apps-all-'));
|
|
109
|
-
const adbPath = path.join(tmpDir, 'adb');
|
|
110
|
-
await fs.writeFile(
|
|
111
|
-
adbPath,
|
|
112
|
-
[
|
|
113
|
-
'#!/bin/sh',
|
|
114
|
-
'if [ "$1" = "-s" ]; then',
|
|
115
|
-
' shift',
|
|
116
|
-
' shift',
|
|
117
|
-
'fi',
|
|
118
|
-
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "package" ] && [ "$4" = "query-activities" ]; then',
|
|
119
|
-
' echo "com.google.android.apps.maps/.MainActivity"',
|
|
120
|
-
' echo "org.mozilla.firefox/.App"',
|
|
121
|
-
' echo "com.android.settings/.Settings"',
|
|
122
|
-
' exit 0',
|
|
123
|
-
'fi',
|
|
124
|
-
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ] && [ "$5" = "-3" ]; then',
|
|
125
|
-
' echo "package:com.google.android.apps.maps"',
|
|
126
|
-
' echo "package:com.example.serviceonly"',
|
|
127
|
-
' echo "package:org.mozilla.firefox"',
|
|
128
|
-
' exit 0',
|
|
129
|
-
'fi',
|
|
130
|
-
'echo "unexpected args: $@" >&2',
|
|
131
|
-
'exit 1',
|
|
132
|
-
'',
|
|
133
|
-
].join('\n'),
|
|
134
|
-
'utf8',
|
|
135
|
-
);
|
|
136
|
-
await fs.chmod(adbPath, 0o755);
|
|
137
|
-
|
|
138
|
-
const previousPath = process.env.PATH;
|
|
139
|
-
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
|
|
140
|
-
|
|
141
|
-
const device: DeviceInfo = {
|
|
142
|
-
platform: 'android',
|
|
143
|
-
id: 'emulator-5554',
|
|
144
|
-
name: 'Pixel',
|
|
145
|
-
kind: 'emulator',
|
|
146
|
-
booted: true,
|
|
147
|
-
};
|
|
148
|
-
|
|
149
|
-
try {
|
|
150
|
-
const apps = await listAndroidApps(device, 'all');
|
|
151
|
-
assert.deepEqual(apps, [
|
|
152
|
-
{ package: 'com.android.settings', name: 'Settings' },
|
|
153
|
-
{ package: 'com.google.android.apps.maps', name: 'Maps' },
|
|
154
|
-
{ package: 'org.mozilla.firefox', name: 'Firefox' },
|
|
155
|
-
]);
|
|
156
|
-
} finally {
|
|
157
|
-
process.env.PATH = previousPath;
|
|
158
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
159
|
-
}
|
|
160
|
-
});
|
|
161
|
-
|
|
162
|
-
test('listAndroidApps user-installed excludes non-launchable packages', async () => {
|
|
163
|
-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-apps-user-'));
|
|
164
|
-
const adbPath = path.join(tmpDir, 'adb');
|
|
165
|
-
await fs.writeFile(
|
|
166
|
-
adbPath,
|
|
167
|
-
[
|
|
168
|
-
'#!/bin/sh',
|
|
169
|
-
'if [ "$1" = "-s" ]; then',
|
|
170
|
-
' shift',
|
|
171
|
-
' shift',
|
|
172
|
-
'fi',
|
|
173
|
-
'if [ "$1" = "shell" ] && [ "$2" = "cmd" ] && [ "$3" = "package" ] && [ "$4" = "query-activities" ]; then',
|
|
174
|
-
' echo "com.google.android.apps.maps/.MainActivity"',
|
|
175
|
-
' echo "org.mozilla.firefox/.App"',
|
|
176
|
-
' exit 0',
|
|
177
|
-
'fi',
|
|
178
|
-
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ] && [ "$5" = "-3" ]; then',
|
|
179
|
-
' echo "package:com.google.android.apps.maps"',
|
|
180
|
-
' echo "package:com.example.serviceonly"',
|
|
181
|
-
' echo "package:org.mozilla.firefox"',
|
|
182
|
-
' exit 0',
|
|
183
|
-
'fi',
|
|
184
|
-
'echo "unexpected args: $@" >&2',
|
|
185
|
-
'exit 1',
|
|
186
|
-
'',
|
|
187
|
-
].join('\n'),
|
|
188
|
-
'utf8',
|
|
189
|
-
);
|
|
190
|
-
await fs.chmod(adbPath, 0o755);
|
|
191
|
-
|
|
192
|
-
const previousPath = process.env.PATH;
|
|
193
|
-
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
|
|
194
|
-
|
|
195
|
-
const device: DeviceInfo = {
|
|
196
|
-
platform: 'android',
|
|
197
|
-
id: 'emulator-5554',
|
|
198
|
-
name: 'Pixel',
|
|
199
|
-
kind: 'emulator',
|
|
200
|
-
booted: true,
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
const apps = await listAndroidApps(device, 'user-installed');
|
|
205
|
-
assert.deepEqual(apps, [
|
|
206
|
-
{ package: 'com.google.android.apps.maps', name: 'Maps' },
|
|
207
|
-
{ package: 'org.mozilla.firefox', name: 'Firefox' },
|
|
208
|
-
]);
|
|
209
|
-
} finally {
|
|
210
|
-
process.env.PATH = previousPath;
|
|
211
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
test('openAndroidApp rejects activity override for deep link URLs', async () => {
|
|
216
|
-
const device: DeviceInfo = {
|
|
217
|
-
platform: 'android',
|
|
218
|
-
id: 'emulator-5554',
|
|
219
|
-
name: 'Pixel',
|
|
220
|
-
kind: 'emulator',
|
|
221
|
-
booted: true,
|
|
222
|
-
};
|
|
223
|
-
|
|
224
|
-
await assert.rejects(
|
|
225
|
-
() => openAndroidApp(device, ' https://example.com/path ', '.MainActivity'),
|
|
226
|
-
(error: unknown) => {
|
|
227
|
-
assert.equal(error instanceof AppError, true);
|
|
228
|
-
assert.equal((error as AppError).code, 'INVALID_ARGS');
|
|
229
|
-
return true;
|
|
230
|
-
},
|
|
231
|
-
);
|
|
232
|
-
});
|
|
233
|
-
|
|
234
|
-
test('swipeAndroid invokes adb input swipe with duration', async () => {
|
|
235
|
-
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-swipe-test-'));
|
|
236
|
-
const adbPath = path.join(tmpDir, 'adb');
|
|
237
|
-
const argsLogPath = path.join(tmpDir, 'args.log');
|
|
238
|
-
await fs.writeFile(
|
|
239
|
-
adbPath,
|
|
240
|
-
'#!/bin/sh\nprintf "%s\\n" "$@" > "$AGENT_DEVICE_TEST_ARGS_FILE"\nexit 0\n',
|
|
241
|
-
'utf8',
|
|
242
|
-
);
|
|
243
|
-
await fs.chmod(adbPath, 0o755);
|
|
244
|
-
|
|
245
|
-
const previousPath = process.env.PATH;
|
|
246
|
-
const previousArgsFile = process.env.AGENT_DEVICE_TEST_ARGS_FILE;
|
|
247
|
-
process.env.PATH = `${tmpDir}${path.delimiter}${previousPath ?? ''}`;
|
|
248
|
-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = argsLogPath;
|
|
249
|
-
|
|
250
|
-
const device: DeviceInfo = {
|
|
251
|
-
platform: 'android',
|
|
252
|
-
id: 'emulator-5554',
|
|
253
|
-
name: 'Pixel',
|
|
254
|
-
kind: 'emulator',
|
|
255
|
-
booted: true,
|
|
256
|
-
};
|
|
257
|
-
|
|
258
|
-
try {
|
|
259
|
-
await swipeAndroid(device, 10, 20, 30, 40, 250);
|
|
260
|
-
const args = (await fs.readFile(argsLogPath, 'utf8'))
|
|
261
|
-
.trim()
|
|
262
|
-
.split('\n')
|
|
263
|
-
.filter(Boolean);
|
|
264
|
-
assert.deepEqual(args, ['-s', 'emulator-5554', 'shell', 'input', 'swipe', '10', '20', '30', '40', '250']);
|
|
265
|
-
} finally {
|
|
266
|
-
process.env.PATH = previousPath;
|
|
267
|
-
if (previousArgsFile === undefined) {
|
|
268
|
-
delete process.env.AGENT_DEVICE_TEST_ARGS_FILE;
|
|
269
|
-
} else {
|
|
270
|
-
process.env.AGENT_DEVICE_TEST_ARGS_FILE = previousArgsFile;
|
|
271
|
-
}
|
|
272
|
-
await fs.rm(tmpDir, { recursive: true, force: true });
|
|
273
|
-
}
|
|
274
|
-
});
|
|
@@ -1,196 +0,0 @@
|
|
|
1
|
-
import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
2
|
-
import type { ExecResult } from '../../utils/exec.ts';
|
|
3
|
-
import { AppError, asAppError } from '../../utils/errors.ts';
|
|
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';
|
|
7
|
-
|
|
8
|
-
const EMULATOR_SERIAL_PREFIX = 'emulator-';
|
|
9
|
-
const ANDROID_BOOT_POLL_MS = 1000;
|
|
10
|
-
const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
|
|
11
|
-
|
|
12
|
-
function adbArgs(serial: string, args: string[]): string[] {
|
|
13
|
-
return ['-s', serial, ...args];
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function isEmulatorSerial(serial: string): boolean {
|
|
17
|
-
return serial.startsWith(EMULATOR_SERIAL_PREFIX);
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
async function readAndroidBootProp(
|
|
21
|
-
serial: string,
|
|
22
|
-
timeoutMs = TIMEOUT_PROFILES.android_boot.operationMs,
|
|
23
|
-
): Promise<ExecResult> {
|
|
24
|
-
return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), {
|
|
25
|
-
allowFailure: true,
|
|
26
|
-
timeoutMs,
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
async function resolveAndroidDeviceName(serial: string, rawModel: string): Promise<string> {
|
|
31
|
-
const modelName = rawModel.replace(/_/g, ' ').trim();
|
|
32
|
-
if (!isEmulatorSerial(serial)) return modelName || serial;
|
|
33
|
-
const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), {
|
|
34
|
-
allowFailure: true,
|
|
35
|
-
timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
|
|
36
|
-
});
|
|
37
|
-
const avdName = avd.stdout.trim();
|
|
38
|
-
if (avd.exitCode === 0 && avdName) {
|
|
39
|
-
return avdName.replace(/_/g, ' ');
|
|
40
|
-
}
|
|
41
|
-
return modelName || serial;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export async function listAndroidDevices(): Promise<DeviceInfo[]> {
|
|
45
|
-
const adbAvailable = await whichCmd('adb');
|
|
46
|
-
if (!adbAvailable) {
|
|
47
|
-
throw new AppError('TOOL_MISSING', 'adb not found in PATH');
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const result = await runCmd('adb', ['devices', '-l'], {
|
|
51
|
-
timeoutMs: TIMEOUT_PROFILES.android_boot.operationMs,
|
|
52
|
-
});
|
|
53
|
-
const lines = result.stdout.split('\n').map((l: string) => l.trim());
|
|
54
|
-
const entries = lines
|
|
55
|
-
.filter((line) => line.length > 0 && !line.startsWith('List of devices'))
|
|
56
|
-
.map((line) => line.split(/\s+/))
|
|
57
|
-
.filter((parts) => parts[1] === 'device')
|
|
58
|
-
.map((parts) => ({
|
|
59
|
-
serial: parts[0],
|
|
60
|
-
rawModel: (parts.find((p: string) => p.startsWith('model:')) ?? '').replace('model:', ''),
|
|
61
|
-
}));
|
|
62
|
-
|
|
63
|
-
const devices = await Promise.all(entries.map(async ({ serial, rawModel }) => {
|
|
64
|
-
const [name, booted] = await Promise.all([
|
|
65
|
-
resolveAndroidDeviceName(serial, rawModel),
|
|
66
|
-
isAndroidBooted(serial),
|
|
67
|
-
]);
|
|
68
|
-
return {
|
|
69
|
-
platform: 'android',
|
|
70
|
-
id: serial,
|
|
71
|
-
name,
|
|
72
|
-
kind: isEmulatorSerial(serial) ? 'emulator' : 'device',
|
|
73
|
-
booted,
|
|
74
|
-
} satisfies DeviceInfo;
|
|
75
|
-
}));
|
|
76
|
-
|
|
77
|
-
return devices;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
export async function isAndroidBooted(serial: string): Promise<boolean> {
|
|
81
|
-
try {
|
|
82
|
-
const result = await readAndroidBootProp(serial);
|
|
83
|
-
return result.stdout.trim() === '1';
|
|
84
|
-
} catch {
|
|
85
|
-
return false;
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
|
|
90
|
-
const timeoutBudget = timeoutMs;
|
|
91
|
-
const deadline = Deadline.fromTimeoutMs(timeoutBudget);
|
|
92
|
-
const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / ANDROID_BOOT_POLL_MS));
|
|
93
|
-
let lastBootResult: ExecResult | undefined;
|
|
94
|
-
let timedOut = false;
|
|
95
|
-
try {
|
|
96
|
-
await retryWithPolicy(
|
|
97
|
-
async ({ deadline: attemptDeadline }) => {
|
|
98
|
-
if (attemptDeadline?.isExpired()) {
|
|
99
|
-
timedOut = true;
|
|
100
|
-
throw new AppError('COMMAND_FAILED', 'Android boot deadline exceeded', {
|
|
101
|
-
serial,
|
|
102
|
-
timeoutMs,
|
|
103
|
-
elapsedMs: deadline.elapsedMs(),
|
|
104
|
-
message: 'timeout',
|
|
105
|
-
});
|
|
106
|
-
}
|
|
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
|
-
);
|
|
112
|
-
lastBootResult = result;
|
|
113
|
-
if (result.stdout.trim() === '1') return;
|
|
114
|
-
throw new AppError('COMMAND_FAILED', 'Android device is still booting', {
|
|
115
|
-
serial,
|
|
116
|
-
stdout: result.stdout,
|
|
117
|
-
stderr: result.stderr,
|
|
118
|
-
exitCode: result.exitCode,
|
|
119
|
-
});
|
|
120
|
-
},
|
|
121
|
-
{
|
|
122
|
-
maxAttempts,
|
|
123
|
-
baseDelayMs: ANDROID_BOOT_POLL_MS,
|
|
124
|
-
maxDelayMs: ANDROID_BOOT_POLL_MS,
|
|
125
|
-
jitter: 0,
|
|
126
|
-
shouldRetry: (error) => {
|
|
127
|
-
const reason = classifyBootFailure({
|
|
128
|
-
error,
|
|
129
|
-
stdout: lastBootResult?.stdout,
|
|
130
|
-
stderr: lastBootResult?.stderr,
|
|
131
|
-
context: { platform: 'android', phase: 'boot' },
|
|
132
|
-
});
|
|
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`);
|
|
149
|
-
},
|
|
150
|
-
},
|
|
151
|
-
);
|
|
152
|
-
} catch (error) {
|
|
153
|
-
const appErr = asAppError(error);
|
|
154
|
-
const stdout = lastBootResult?.stdout;
|
|
155
|
-
const stderr = lastBootResult?.stderr;
|
|
156
|
-
const exitCode = lastBootResult?.exitCode;
|
|
157
|
-
let reason = classifyBootFailure({
|
|
158
|
-
error,
|
|
159
|
-
stdout,
|
|
160
|
-
stderr,
|
|
161
|
-
context: { platform: 'android', phase: 'boot' },
|
|
162
|
-
});
|
|
163
|
-
if (reason === 'BOOT_COMMAND_FAILED' && appErr.message === 'Android device is still booting') {
|
|
164
|
-
reason = 'ANDROID_BOOT_TIMEOUT';
|
|
165
|
-
}
|
|
166
|
-
const baseDetails = {
|
|
167
|
-
serial,
|
|
168
|
-
timeoutMs: timeoutBudget,
|
|
169
|
-
elapsedMs: deadline.elapsedMs(),
|
|
170
|
-
reason,
|
|
171
|
-
hint: bootFailureHint(reason),
|
|
172
|
-
stdout,
|
|
173
|
-
stderr,
|
|
174
|
-
exitCode,
|
|
175
|
-
};
|
|
176
|
-
if (timedOut || reason === 'ANDROID_BOOT_TIMEOUT') {
|
|
177
|
-
throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails);
|
|
178
|
-
}
|
|
179
|
-
if (appErr.code === 'TOOL_MISSING') {
|
|
180
|
-
throw new AppError('TOOL_MISSING', appErr.message, {
|
|
181
|
-
...baseDetails,
|
|
182
|
-
...(appErr.details ?? {}),
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
if (reason === 'ADB_TRANSPORT_UNAVAILABLE') {
|
|
186
|
-
throw new AppError('COMMAND_FAILED', appErr.message, {
|
|
187
|
-
...baseDetails,
|
|
188
|
-
...(appErr.details ?? {}),
|
|
189
|
-
});
|
|
190
|
-
}
|
|
191
|
-
throw new AppError(appErr.code, appErr.message, {
|
|
192
|
-
...baseDetails,
|
|
193
|
-
...(appErr.details ?? {}),
|
|
194
|
-
}, appErr.cause);
|
|
195
|
-
}
|
|
196
|
-
}
|