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.
@@ -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.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 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);
@@ -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 { 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
  }
@@ -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
+ }