agent-device 0.2.6 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,10 +9,12 @@ import XCTest
9
9
  import Network
10
10
 
11
11
  final class RunnerTests: XCTestCase {
12
+ private static let springboardBundleId = "com.apple.springboard"
12
13
  private var listener: NWListener?
13
14
  private var port: UInt16 = 0
14
15
  private var doneExpectation: XCTestExpectation?
15
16
  private let app = XCUIApplication()
17
+ private lazy var springboard = XCUIApplication(bundleIdentifier: Self.springboardBundleId)
16
18
  private var currentApp: XCUIApplication?
17
19
  private var currentBundleId: String?
18
20
  private let maxRequestBytes = 2 * 1024 * 1024
@@ -36,6 +38,15 @@ final class RunnerTests: XCTestCase {
36
38
  .secureTextField,
37
39
  .textView,
38
40
  ]
41
+ // Keep blocker actions narrow to avoid false positives from generic hittable containers.
42
+ private let actionableTypes: Set<XCUIElement.ElementType> = [
43
+ .button,
44
+ .cell,
45
+ .link,
46
+ .menuItem,
47
+ .checkBox,
48
+ .switch,
49
+ ]
39
50
 
40
51
  override func setUp() {
41
52
  continueAfterFailure = false
@@ -535,6 +546,10 @@ final class RunnerTests: XCTestCase {
535
546
  }
536
547
 
537
548
  private func snapshotFast(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
549
+ if let blocking = blockingSystemAlertSnapshot() {
550
+ return blocking
551
+ }
552
+
538
553
  var nodes: [SnapshotNode] = []
539
554
  var truncated = false
540
555
  let maxDepth = options.depth ?? Int.max
@@ -636,6 +651,10 @@ final class RunnerTests: XCTestCase {
636
651
  }
637
652
 
638
653
  private func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
654
+ if let blocking = blockingSystemAlertSnapshot() {
655
+ return blocking
656
+ }
657
+
639
658
  let root = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
640
659
  var nodes: [SnapshotNode] = []
641
660
  var truncated = false
@@ -688,6 +707,136 @@ final class RunnerTests: XCTestCase {
688
707
  return DataPayload(nodes: nodes, truncated: truncated)
689
708
  }
690
709
 
710
+ private func blockingSystemAlertSnapshot() -> DataPayload? {
711
+ guard let modal = firstBlockingSystemModal(in: springboard) else {
712
+ return nil
713
+ }
714
+ let actions = actionableElements(in: modal)
715
+ guard !actions.isEmpty else {
716
+ return nil
717
+ }
718
+
719
+ let title = preferredSystemModalTitle(modal)
720
+
721
+ var nodes: [SnapshotNode] = [
722
+ makeSnapshotNode(
723
+ element: modal,
724
+ index: 0,
725
+ type: "Alert",
726
+ labelOverride: title,
727
+ identifierOverride: modal.identifier,
728
+ depth: 0,
729
+ hittableOverride: true
730
+ )
731
+ ]
732
+
733
+ for action in actions {
734
+ nodes.append(
735
+ makeSnapshotNode(
736
+ element: action,
737
+ index: nodes.count,
738
+ type: elementTypeName(action.elementType),
739
+ depth: 1,
740
+ hittableOverride: true
741
+ )
742
+ )
743
+ }
744
+
745
+ return DataPayload(nodes: nodes, truncated: false)
746
+ }
747
+
748
+ private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
749
+ for alert in springboard.alerts.allElementsBoundByIndex {
750
+ if isBlockingSystemModal(alert, in: springboard) {
751
+ return alert
752
+ }
753
+ }
754
+
755
+ for sheet in springboard.sheets.allElementsBoundByIndex {
756
+ if isBlockingSystemModal(sheet, in: springboard) {
757
+ return sheet
758
+ }
759
+ }
760
+
761
+ return nil
762
+ }
763
+
764
+ private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
765
+ guard element.exists else { return false }
766
+ let frame = element.frame
767
+ if frame.isNull || frame.isEmpty { return false }
768
+
769
+ let viewport = springboard.frame
770
+ if viewport.isNull || viewport.isEmpty { return false }
771
+
772
+ let center = CGPoint(x: frame.midX, y: frame.midY)
773
+ if !viewport.contains(center) { return false }
774
+
775
+ return true
776
+ }
777
+
778
+ private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
779
+ var seen = Set<String>()
780
+ var actions: [XCUIElement] = []
781
+ let descendants = element.descendants(matching: .any).allElementsBoundByIndex
782
+ for candidate in descendants {
783
+ if !candidate.exists || !candidate.isHittable { continue }
784
+ if !actionableTypes.contains(candidate.elementType) { continue }
785
+ let frame = candidate.frame
786
+ if frame.isNull || frame.isEmpty { continue }
787
+ let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
788
+ if seen.contains(key) { continue }
789
+ seen.insert(key)
790
+ actions.append(candidate)
791
+ }
792
+ return actions
793
+ }
794
+
795
+ private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
796
+ let label = element.label
797
+ if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
798
+ return label
799
+ }
800
+ let identifier = element.identifier
801
+ if !identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
802
+ return identifier
803
+ }
804
+ return "System Alert"
805
+ }
806
+
807
+ private func makeSnapshotNode(
808
+ element: XCUIElement,
809
+ index: Int,
810
+ type: String,
811
+ labelOverride: String? = nil,
812
+ identifierOverride: String? = nil,
813
+ depth: Int,
814
+ hittableOverride: Bool? = nil
815
+ ) -> SnapshotNode {
816
+ let label = (labelOverride ?? element.label).trimmingCharacters(in: .whitespacesAndNewlines)
817
+ let identifier = (identifierOverride ?? element.identifier).trimmingCharacters(in: .whitespacesAndNewlines)
818
+ return SnapshotNode(
819
+ index: index,
820
+ type: type,
821
+ label: label.isEmpty ? nil : label,
822
+ identifier: identifier.isEmpty ? nil : identifier,
823
+ value: nil,
824
+ rect: snapshotRect(from: element.frame),
825
+ enabled: element.isEnabled,
826
+ hittable: hittableOverride ?? element.isHittable,
827
+ depth: depth
828
+ )
829
+ }
830
+
831
+ private func snapshotRect(from frame: CGRect) -> SnapshotRect {
832
+ return SnapshotRect(
833
+ x: Double(frame.origin.x),
834
+ y: Double(frame.origin.y),
835
+ width: Double(frame.size.width),
836
+ height: Double(frame.size.height)
837
+ )
838
+ }
839
+
691
840
  private func shouldInclude(
692
841
  element: XCUIElement,
693
842
  label: String,
@@ -704,7 +853,7 @@ final class RunnerTests: XCTestCase {
704
853
  if options.interactiveOnly {
705
854
  if interactiveTypes.contains(type) { return true }
706
855
  if element.isHittable && type != .other { return true }
707
- if hasContent && type != .other { return true }
856
+ if hasContent { return true }
708
857
  return false
709
858
  }
710
859
  if options.compact {
@@ -728,7 +877,7 @@ final class RunnerTests: XCTestCase {
728
877
  if options.interactiveOnly {
729
878
  if interactiveTypes.contains(type) { return true }
730
879
  if snapshotHittable(snapshot) && type != .other { return true }
731
- if hasContent && type != .other { return true }
880
+ if hasContent { return true }
732
881
  return false
733
882
  }
734
883
  if options.compact {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.2.6",
3
+ "version": "0.3.1",
4
4
  "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Callstack",
@@ -26,7 +26,7 @@
26
26
  "prepack": "pnpm build:node && pnpm build:axsnapshot",
27
27
  "typecheck": "tsc -p tsconfig.json",
28
28
  "test": "node --test",
29
- "test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts",
29
+ "test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
30
30
  "test:smoke": "node --test test/integration/smoke-*.test.ts",
31
31
  "test:integration": "node --test test/integration/*.test.ts"
32
32
  },
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseArgs, usage } from './utils/args.ts';
2
2
  import { asAppError, AppError } from './utils/errors.ts';
3
3
  import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
4
+ import { readVersion } from './utils/version.ts';
4
5
  import { pathToFileURL } from 'node:url';
5
6
  import { sendToDaemon } from './daemon-client.ts';
6
7
  import fs from 'node:fs';
@@ -10,6 +11,11 @@ import path from 'node:path';
10
11
  export async function runCli(argv: string[]): Promise<void> {
11
12
  const parsed = parseArgs(argv);
12
13
 
14
+ if (parsed.flags.version) {
15
+ process.stdout.write(`${readVersion()}\n`);
16
+ process.exit(0);
17
+ }
18
+
13
19
  if (parsed.flags.help || !parsed.command) {
14
20
  process.stdout.write(`${usage()}\n`);
15
21
  process.exit(parsed.flags.help ? 0 : 1);
@@ -2,10 +2,10 @@ import net from 'node:net';
2
2
  import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
- import { fileURLToPath } from 'node:url';
6
5
  import { AppError } from './utils/errors.ts';
7
6
  import type { CommandFlags } from './core/dispatch.ts';
8
7
  import { runCmdDetached } from './utils/exec.ts';
8
+ import { findProjectRoot, readVersion } from './utils/version.ts';
9
9
 
10
10
  export type DaemonRequest = {
11
11
  token: string;
@@ -134,18 +134,6 @@ async function sendRequest(info: DaemonInfo, req: DaemonRequest): Promise<Daemon
134
134
  });
135
135
  }
136
136
 
137
- function readVersion(): string {
138
- try {
139
- const root = findProjectRoot();
140
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
141
- version?: string;
142
- };
143
- return pkg.version ?? '0.0.0';
144
- } catch {
145
- return '0.0.0';
146
- }
147
- }
148
-
149
137
  function resolveRequestTimeoutMs(): number {
150
138
  const raw = process.env.AGENT_DEVICE_DAEMON_TIMEOUT_MS;
151
139
  if (!raw) return 60000;
@@ -153,14 +141,3 @@ function resolveRequestTimeoutMs(): number {
153
141
  if (!Number.isFinite(parsed)) return 60000;
154
142
  return Math.max(1000, Math.floor(parsed));
155
143
  }
156
-
157
- function findProjectRoot(): string {
158
- const start = path.dirname(fileURLToPath(import.meta.url));
159
- let current = start;
160
- for (let i = 0; i < 6; i += 1) {
161
- const pkgPath = path.join(current, 'package.json');
162
- if (fs.existsSync(pkgPath)) return current;
163
- current = path.dirname(current);
164
- }
165
- return start;
166
- }
package/src/daemon.ts CHANGED
@@ -3,10 +3,10 @@ import fs from 'node:fs';
3
3
  import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import crypto from 'node:crypto';
6
- import { fileURLToPath } from 'node:url';
7
6
  import { dispatchCommand, type CommandFlags } from './core/dispatch.ts';
8
7
  import { isCommandSupportedOnDevice } from './core/capabilities.ts';
9
8
  import { asAppError, AppError } from './utils/errors.ts';
9
+ import { readVersion } from './utils/version.ts';
10
10
  import { stopIosRunnerSession } from './platforms/ios/runner-client.ts';
11
11
  import type { DaemonRequest, DaemonResponse } from './daemon/types.ts';
12
12
  import { SessionStore } from './daemon/session-store.ts';
@@ -203,26 +203,3 @@ function start(): void {
203
203
  }
204
204
 
205
205
  start();
206
-
207
- function readVersion(): string {
208
- try {
209
- const root = findProjectRoot();
210
- const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')) as {
211
- version?: string;
212
- };
213
- return pkg.version ?? '0.0.0';
214
- } catch {
215
- return '0.0.0';
216
- }
217
- }
218
-
219
- function findProjectRoot(): string {
220
- const start = path.dirname(fileURLToPath(import.meta.url));
221
- let current = start;
222
- for (let i = 0; i < 6; i += 1) {
223
- const pkgPath = path.join(current, 'package.json');
224
- if (fs.existsSync(pkgPath)) return current;
225
- current = path.dirname(current);
226
- }
227
- return start;
228
- }
@@ -0,0 +1,30 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { classifyBootFailure } from '../boot-diagnostics.ts';
4
+ import { AppError } from '../../utils/errors.ts';
5
+
6
+ test('classifyBootFailure maps timeout errors', () => {
7
+ const reason = classifyBootFailure({ message: 'bootstatus timed out after 120s' });
8
+ assert.equal(reason, 'BOOT_TIMEOUT');
9
+ });
10
+
11
+ test('classifyBootFailure maps adb offline errors', () => {
12
+ const reason = classifyBootFailure({ stderr: 'error: device offline' });
13
+ assert.equal(reason, 'DEVICE_OFFLINE');
14
+ });
15
+
16
+ test('classifyBootFailure maps tool missing from AppError code', () => {
17
+ const reason = classifyBootFailure({
18
+ error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
19
+ });
20
+ assert.equal(reason, 'TOOL_MISSING');
21
+ });
22
+
23
+ test('classifyBootFailure reads stderr from AppError details', () => {
24
+ const reason = classifyBootFailure({
25
+ error: new AppError('COMMAND_FAILED', 'adb failed', {
26
+ stderr: 'error: device unauthorized',
27
+ }),
28
+ });
29
+ assert.equal(reason, 'PERMISSION_DENIED');
30
+ });
@@ -0,0 +1,74 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { findBounds, parseUiHierarchy } from '../ui-hierarchy.ts';
4
+
5
+ test('parseUiHierarchy reads double-quoted Android node attributes', () => {
6
+ const xml =
7
+ '<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>';
8
+
9
+ const result = parseUiHierarchy(xml, 800, { raw: true });
10
+ assert.equal(result.nodes.length, 1);
11
+ assert.equal(result.nodes[0].value, 'Hello');
12
+ assert.equal(result.nodes[0].label, 'Hello');
13
+ assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
14
+ assert.deepEqual(result.nodes[0].rect, { x: 10, y: 20, width: 100, height: 40 });
15
+ assert.equal(result.nodes[0].hittable, true);
16
+ assert.equal(result.nodes[0].enabled, true);
17
+ });
18
+
19
+ test('parseUiHierarchy reads single-quoted Android node attributes', () => {
20
+ const xml =
21
+ "<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>";
22
+
23
+ const result = parseUiHierarchy(xml, 800, { raw: true });
24
+ assert.equal(result.nodes.length, 1);
25
+ assert.equal(result.nodes[0].value, 'Hello');
26
+ assert.equal(result.nodes[0].label, 'Hello');
27
+ assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
28
+ assert.deepEqual(result.nodes[0].rect, { x: 10, y: 20, width: 100, height: 40 });
29
+ assert.equal(result.nodes[0].hittable, true);
30
+ assert.equal(result.nodes[0].enabled, true);
31
+ });
32
+
33
+ test('parseUiHierarchy supports mixed quote styles in one node', () => {
34
+ const xml =
35
+ '<hierarchy><node class="android.widget.TextView" text=\'Hello\' content-desc="Greeting" resource-id=\'com.demo:id/title\' bounds="[10,20][110,60]"/></hierarchy>';
36
+
37
+ const result = parseUiHierarchy(xml, 800, { raw: true });
38
+ assert.equal(result.nodes.length, 1);
39
+ assert.equal(result.nodes[0].value, 'Hello');
40
+ assert.equal(result.nodes[0].label, 'Hello');
41
+ assert.equal(result.nodes[0].identifier, 'com.demo:id/title');
42
+ });
43
+
44
+ test('findBounds supports single and double quoted attributes', () => {
45
+ const xml = [
46
+ '<hierarchy>',
47
+ '<node text="Nothing" content-desc="Irrelevant" bounds="[0,0][10,10]"/>',
48
+ "<node text='Target from single quote' content-desc='Alt single' bounds='[100,200][300,500]'/>",
49
+ '<node text="Target from double quote" content-desc="Alt double" bounds="[50,50][150,250]"/>',
50
+ '</hierarchy>',
51
+ ].join('');
52
+
53
+ assert.deepEqual(findBounds(xml, 'single quote'), { x: 200, y: 350 });
54
+ assert.deepEqual(findBounds(xml, 'alt double'), { x: 100, y: 150 });
55
+ });
56
+
57
+ test('parseUiHierarchy ignores attribute-name prefix spoofing', () => {
58
+ const xml =
59
+ "<hierarchy><node class='android.widget.TextView' hint-text='Spoofed' text='Actual' bounds='[10,20][110,60]'/></hierarchy>";
60
+
61
+ const result = parseUiHierarchy(xml, 800, { raw: true });
62
+ assert.equal(result.nodes.length, 1);
63
+ assert.equal(result.nodes[0].value, 'Actual');
64
+ });
65
+
66
+ test('findBounds ignores bounds-like fragments inside other attribute values', () => {
67
+ const xml = [
68
+ '<hierarchy>',
69
+ "<node text='Target' content-desc=\"metadata bounds='[900,900][1000,1000]'\" bounds='[100,200][300,500]'/>",
70
+ '</hierarchy>',
71
+ ].join('');
72
+
73
+ assert.deepEqual(findBounds(xml, 'target'), { x: 200, y: 350 });
74
+ });
@@ -1,6 +1,39 @@
1
1
  import { runCmd, whichCmd } from '../../utils/exec.ts';
2
- import { AppError } from '../../utils/errors.ts';
2
+ import type { ExecResult } from '../../utils/exec.ts';
3
+ import { AppError, asAppError } from '../../utils/errors.ts';
3
4
  import type { DeviceInfo } from '../../utils/device.ts';
5
+ import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
6
+ import { classifyBootFailure } from '../boot-diagnostics.ts';
7
+
8
+ const EMULATOR_SERIAL_PREFIX = 'emulator-';
9
+ const ANDROID_BOOT_POLL_MS = 1000;
10
+
11
+ function adbArgs(serial: string, args: string[]): string[] {
12
+ return ['-s', serial, ...args];
13
+ }
14
+
15
+ function isEmulatorSerial(serial: string): boolean {
16
+ return serial.startsWith(EMULATOR_SERIAL_PREFIX);
17
+ }
18
+
19
+ async function readAndroidBootProp(serial: string): Promise<ExecResult> {
20
+ return runCmd('adb', adbArgs(serial, ['shell', 'getprop', 'sys.boot_completed']), {
21
+ allowFailure: true,
22
+ });
23
+ }
24
+
25
+ async function resolveAndroidDeviceName(serial: string, rawModel: string): Promise<string> {
26
+ const modelName = rawModel.replace(/_/g, ' ').trim();
27
+ if (!isEmulatorSerial(serial)) return modelName || serial;
28
+ const avd = await runCmd('adb', adbArgs(serial, ['emu', 'avd', 'name']), {
29
+ allowFailure: true,
30
+ });
31
+ const avdName = avd.stdout.trim();
32
+ if (avd.exitCode === 0 && avdName) {
33
+ return avdName.replace(/_/g, ' ');
34
+ }
35
+ return modelName || serial;
36
+ }
4
37
 
5
38
  export async function listAndroidDevices(): Promise<DeviceInfo[]> {
6
39
  const adbAvailable = await whichCmd('adb');
@@ -10,62 +43,121 @@ export async function listAndroidDevices(): Promise<DeviceInfo[]> {
10
43
 
11
44
  const result = await runCmd('adb', ['devices', '-l']);
12
45
  const lines = result.stdout.split('\n').map((l: string) => l.trim());
13
- const devices: DeviceInfo[] = [];
46
+ const entries = lines
47
+ .filter((line) => line.length > 0 && !line.startsWith('List of devices'))
48
+ .map((line) => line.split(/\s+/))
49
+ .filter((parts) => parts[1] === 'device')
50
+ .map((parts) => ({
51
+ serial: parts[0],
52
+ rawModel: (parts.find((p: string) => p.startsWith('model:')) ?? '').replace('model:', ''),
53
+ }));
14
54
 
15
- for (const line of lines) {
16
- if (!line || line.startsWith('List of devices')) continue;
17
- const parts = line.split(/\s+/);
18
- const serial = parts[0];
19
- const state = parts[1];
20
- if (state !== 'device') continue;
21
-
22
- const modelPart = parts.find((p: string) => p.startsWith('model:')) ?? '';
23
- const rawModel = modelPart.replace('model:', '').replace(/_/g, ' ').trim();
24
- let name = rawModel || serial;
25
-
26
- if (serial.startsWith('emulator-')) {
27
- const avd = await runCmd('adb', ['-s', serial, 'emu', 'avd', 'name'], {
28
- allowFailure: true,
29
- });
30
- const avdName = (avd.stdout as string).trim();
31
- if (avd.exitCode === 0 && avdName) {
32
- name = avdName.replace(/_/g, ' ');
33
- }
34
- }
35
-
36
- const booted = await isAndroidBooted(serial);
37
-
38
- devices.push({
55
+ const devices = await Promise.all(entries.map(async ({ serial, rawModel }) => {
56
+ const [name, booted] = await Promise.all([
57
+ resolveAndroidDeviceName(serial, rawModel),
58
+ isAndroidBooted(serial),
59
+ ]);
60
+ return {
39
61
  platform: 'android',
40
62
  id: serial,
41
63
  name,
42
- kind: serial.startsWith('emulator-') ? 'emulator' : 'device',
64
+ kind: isEmulatorSerial(serial) ? 'emulator' : 'device',
43
65
  booted,
44
- });
45
- }
66
+ } satisfies DeviceInfo;
67
+ }));
46
68
 
47
69
  return devices;
48
70
  }
49
71
 
50
72
  export async function isAndroidBooted(serial: string): Promise<boolean> {
51
73
  try {
52
- const result = await runCmd('adb', ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], {
53
- allowFailure: true,
54
- });
55
- return (result.stdout as string).trim() === '1';
74
+ const result = await readAndroidBootProp(serial);
75
+ return result.stdout.trim() === '1';
56
76
  } catch {
57
77
  return false;
58
78
  }
59
79
  }
60
80
 
61
81
  export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
62
- const start = Date.now();
63
- while (Date.now() - start < timeoutMs) {
64
- if (await isAndroidBooted(serial)) return;
65
- await new Promise((resolve) => setTimeout(resolve, 1000));
82
+ const deadline = Deadline.fromTimeoutMs(timeoutMs);
83
+ const maxAttempts = Math.max(1, Math.ceil(timeoutMs / ANDROID_BOOT_POLL_MS));
84
+ let lastBootResult: ExecResult | undefined;
85
+ let timedOut = false;
86
+ try {
87
+ await retryWithPolicy(
88
+ async ({ deadline: attemptDeadline }) => {
89
+ if (attemptDeadline?.isExpired()) {
90
+ timedOut = true;
91
+ throw new AppError('COMMAND_FAILED', 'Android boot deadline exceeded', {
92
+ serial,
93
+ timeoutMs,
94
+ elapsedMs: deadline.elapsedMs(),
95
+ message: 'timeout',
96
+ });
97
+ }
98
+ const result = await readAndroidBootProp(serial);
99
+ lastBootResult = result;
100
+ if (result.stdout.trim() === '1') return;
101
+ throw new AppError('COMMAND_FAILED', 'Android device is still booting', {
102
+ serial,
103
+ stdout: result.stdout,
104
+ stderr: result.stderr,
105
+ exitCode: result.exitCode,
106
+ });
107
+ },
108
+ {
109
+ maxAttempts,
110
+ baseDelayMs: ANDROID_BOOT_POLL_MS,
111
+ maxDelayMs: ANDROID_BOOT_POLL_MS,
112
+ jitter: 0,
113
+ shouldRetry: (error) => {
114
+ const reason = classifyBootFailure({
115
+ error,
116
+ stdout: lastBootResult?.stdout,
117
+ stderr: lastBootResult?.stderr,
118
+ });
119
+ return reason !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING' && reason !== 'BOOT_TIMEOUT';
120
+ },
121
+ },
122
+ { deadline },
123
+ );
124
+ } catch (error) {
125
+ const appErr = asAppError(error);
126
+ const stdout = lastBootResult?.stdout;
127
+ const stderr = lastBootResult?.stderr;
128
+ const exitCode = lastBootResult?.exitCode;
129
+ const reason = classifyBootFailure({
130
+ error,
131
+ stdout,
132
+ stderr,
133
+ });
134
+ const baseDetails = {
135
+ serial,
136
+ timeoutMs,
137
+ elapsedMs: deadline.elapsedMs(),
138
+ reason,
139
+ stdout,
140
+ stderr,
141
+ exitCode,
142
+ };
143
+ if (timedOut || reason === 'BOOT_TIMEOUT') {
144
+ throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails);
145
+ }
146
+ if (appErr.code === 'TOOL_MISSING' || reason === 'TOOL_MISSING') {
147
+ throw new AppError('TOOL_MISSING', appErr.message, {
148
+ ...baseDetails,
149
+ ...(appErr.details ?? {}),
150
+ });
151
+ }
152
+ if (reason === 'PERMISSION_DENIED' || reason === 'DEVICE_UNAVAILABLE' || reason === 'DEVICE_OFFLINE') {
153
+ throw new AppError('COMMAND_FAILED', appErr.message, {
154
+ ...baseDetails,
155
+ ...(appErr.details ?? {}),
156
+ });
157
+ }
158
+ throw new AppError(appErr.code, appErr.message, {
159
+ ...baseDetails,
160
+ ...(appErr.details ?? {}),
161
+ }, appErr.cause);
66
162
  }
67
- throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', {
68
- serial,
69
- timeoutMs,
70
- });
71
163
  }