agent-device 0.3.0 → 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.
- package/README.md +15 -0
- package/dist/src/274.js +1 -0
- package/dist/src/bin.js +29 -27
- package/dist/src/daemon.js +9 -8
- 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/src/cli.ts +6 -0
- package/src/daemon-client.ts +1 -24
- package/src/daemon.ts +1 -24
- package/src/platforms/__tests__/boot-diagnostics.test.ts +30 -0
- package/src/platforms/android/devices.ts +133 -41
- package/src/platforms/boot-diagnostics.ts +67 -0
- package/src/platforms/ios/index.ts +94 -2
- package/src/utils/__tests__/retry.test.ts +27 -0
- package/src/utils/args.ts +7 -1
- package/src/utils/retry.ts +73 -13
- package/src/utils/version.ts +26 -0
- package/dist/src/861.js +0 -1
|
@@ -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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-device",
|
|
3
|
-
"version": "0.3.
|
|
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 src/platforms/**/__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);
|
package/src/daemon-client.ts
CHANGED
|
@@ -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
|
+
});
|
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
import { runCmd, whichCmd } from '../../utils/exec.ts';
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
|
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
|
|
53
|
-
|
|
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
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { asAppError } from '../utils/errors.ts';
|
|
2
|
+
|
|
3
|
+
export type BootFailureReason =
|
|
4
|
+
| 'BOOT_TIMEOUT'
|
|
5
|
+
| 'DEVICE_UNAVAILABLE'
|
|
6
|
+
| 'DEVICE_OFFLINE'
|
|
7
|
+
| 'PERMISSION_DENIED'
|
|
8
|
+
| 'TOOL_MISSING'
|
|
9
|
+
| 'BOOT_COMMAND_FAILED'
|
|
10
|
+
| 'UNKNOWN';
|
|
11
|
+
|
|
12
|
+
export function classifyBootFailure(input: {
|
|
13
|
+
error?: unknown;
|
|
14
|
+
message?: string;
|
|
15
|
+
stdout?: string;
|
|
16
|
+
stderr?: string;
|
|
17
|
+
}): BootFailureReason {
|
|
18
|
+
const appErr = input.error ? asAppError(input.error) : null;
|
|
19
|
+
if (appErr?.code === 'TOOL_MISSING') return 'TOOL_MISSING';
|
|
20
|
+
const details = (appErr?.details ?? {}) as Record<string, unknown>;
|
|
21
|
+
const detailMessage = typeof details.message === 'string' ? details.message : undefined;
|
|
22
|
+
const detailStdout = typeof details.stdout === 'string' ? details.stdout : undefined;
|
|
23
|
+
const detailStderr = typeof details.stderr === 'string' ? details.stderr : undefined;
|
|
24
|
+
const nestedBoot = details.boot && typeof details.boot === 'object'
|
|
25
|
+
? (details.boot as Record<string, unknown>)
|
|
26
|
+
: null;
|
|
27
|
+
const nestedBootstatus = details.bootstatus && typeof details.bootstatus === 'object'
|
|
28
|
+
? (details.bootstatus as Record<string, unknown>)
|
|
29
|
+
: null;
|
|
30
|
+
|
|
31
|
+
const haystack = [
|
|
32
|
+
input.message,
|
|
33
|
+
appErr?.message,
|
|
34
|
+
input.stdout,
|
|
35
|
+
input.stderr,
|
|
36
|
+
detailMessage,
|
|
37
|
+
detailStdout,
|
|
38
|
+
detailStderr,
|
|
39
|
+
typeof nestedBoot?.stdout === 'string' ? nestedBoot.stdout : undefined,
|
|
40
|
+
typeof nestedBoot?.stderr === 'string' ? nestedBoot.stderr : undefined,
|
|
41
|
+
typeof nestedBootstatus?.stdout === 'string' ? nestedBootstatus.stdout : undefined,
|
|
42
|
+
typeof nestedBootstatus?.stderr === 'string' ? nestedBootstatus.stderr : undefined,
|
|
43
|
+
]
|
|
44
|
+
.filter(Boolean)
|
|
45
|
+
.join('\n')
|
|
46
|
+
.toLowerCase();
|
|
47
|
+
|
|
48
|
+
if (haystack.includes('timed out') || haystack.includes('timeout')) return 'BOOT_TIMEOUT';
|
|
49
|
+
if (
|
|
50
|
+
haystack.includes('device not found') ||
|
|
51
|
+
haystack.includes('no devices') ||
|
|
52
|
+
haystack.includes('unable to locate device') ||
|
|
53
|
+
haystack.includes('invalid device')
|
|
54
|
+
) {
|
|
55
|
+
return 'DEVICE_UNAVAILABLE';
|
|
56
|
+
}
|
|
57
|
+
if (haystack.includes('offline')) return 'DEVICE_OFFLINE';
|
|
58
|
+
if (
|
|
59
|
+
haystack.includes('permission denied') ||
|
|
60
|
+
haystack.includes('not authorized') ||
|
|
61
|
+
haystack.includes('unauthorized')
|
|
62
|
+
) {
|
|
63
|
+
return 'PERMISSION_DENIED';
|
|
64
|
+
}
|
|
65
|
+
if (appErr?.code === 'COMMAND_FAILED' || haystack.length > 0) return 'BOOT_COMMAND_FAILED';
|
|
66
|
+
return 'UNKNOWN';
|
|
67
|
+
}
|