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.
- package/README.md +15 -0
- package/dist/bin/axsnapshot +0 -0
- package/dist/src/274.js +1 -0
- package/dist/src/bin.js +29 -27
- package/dist/src/daemon.js +9 -6
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
- package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +151 -2
- 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/__tests__/index.test.ts +74 -0
- package/src/platforms/android/devices.ts +133 -41
- package/src/platforms/android/index.ts +47 -293
- package/src/platforms/android/ui-hierarchy.ts +312 -0
- package/src/platforms/boot-diagnostics.ts +67 -0
- package/src/platforms/ios/index.ts +94 -2
- package/src/platforms/ios/runner-client.ts +115 -55
- 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,
|
|
@@ -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
|
|
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
|
|
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.
|
|
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);
|
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
|
+
});
|
|
@@ -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 {
|
|
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
|
}
|