From c03ace3f16f873b4be332f58e93dd5cde683aa30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 11 Feb 2026 09:04:25 +0100 Subject: [PATCH 1/4] Add boot diagnostics classification and deadline-based boot retries --- package.json | 2 +- .../__tests__/boot-diagnostics.test.ts | 30 ++++++ src/platforms/android/devices.ts | 93 +++++++++++++++++-- src/platforms/boot-diagnostics.ts | 67 +++++++++++++ src/platforms/ios/index.ts | 89 +++++++++++++++++- src/utils/__tests__/retry.test.ts | 27 ++++++ src/utils/retry.ts | 86 ++++++++++++++--- 7 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 src/platforms/__tests__/boot-diagnostics.test.ts create mode 100644 src/platforms/boot-diagnostics.ts create mode 100644 src/utils/__tests__/retry.test.ts diff --git a/package.json b/package.json index 24c3f156b..94b37704f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "prepack": "pnpm build:node && pnpm build:axsnapshot", "typecheck": "tsc -p tsconfig.json", "test": "node --test", - "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", + "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", "test:smoke": "node --test test/integration/smoke-*.test.ts", "test:integration": "node --test test/integration/*.test.ts" }, diff --git a/src/platforms/__tests__/boot-diagnostics.test.ts b/src/platforms/__tests__/boot-diagnostics.test.ts new file mode 100644 index 000000000..5a46f12da --- /dev/null +++ b/src/platforms/__tests__/boot-diagnostics.test.ts @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { classifyBootFailure } from '../boot-diagnostics.ts'; +import { AppError } from '../../utils/errors.ts'; + +test('classifyBootFailure maps timeout errors', () => { + const reason = classifyBootFailure({ message: 'bootstatus timed out after 120s' }); + assert.equal(reason, 'BOOT_TIMEOUT'); +}); + +test('classifyBootFailure maps adb offline errors', () => { + const reason = classifyBootFailure({ stderr: 'error: device offline' }); + assert.equal(reason, 'DEVICE_OFFLINE'); +}); + +test('classifyBootFailure maps tool missing from AppError code', () => { + const reason = classifyBootFailure({ + error: new AppError('TOOL_MISSING', 'adb not found in PATH'), + }); + assert.equal(reason, 'TOOL_MISSING'); +}); + +test('classifyBootFailure reads stderr from AppError details', () => { + const reason = classifyBootFailure({ + error: new AppError('COMMAND_FAILED', 'adb failed', { + stderr: 'error: device unauthorized', + }), + }); + assert.equal(reason, 'PERMISSION_DENIED'); +}); diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index 0ac96a26a..a14edd456 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -1,6 +1,8 @@ import { runCmd, whichCmd } from '../../utils/exec.ts'; -import { AppError } from '../../utils/errors.ts'; +import { AppError, asAppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import { Deadline, retryWithPolicy } from '../../utils/retry.ts'; +import { classifyBootFailure } from '../boot-diagnostics.ts'; export async function listAndroidDevices(): Promise { const adbAvailable = await whichCmd('adb'); @@ -59,13 +61,86 @@ export async function isAndroidBooted(serial: string): Promise { } export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise { - const start = Date.now(); - while (Date.now() - start < timeoutMs) { - if (await isAndroidBooted(serial)) return; - await new Promise((resolve) => setTimeout(resolve, 1000)); + const deadline = Deadline.fromTimeoutMs(timeoutMs); + const maxAttempts = Math.max(1, Math.ceil(timeoutMs / 1000)); + let lastBootResult: { stdout: string; stderr: string; exitCode: number } | null = null; + let timedOut = false; + try { + await retryWithPolicy( + async ({ deadline: attemptDeadline }) => { + if (attemptDeadline?.isExpired()) { + timedOut = true; + throw new AppError('COMMAND_FAILED', 'Android boot deadline exceeded', { + serial, + timeoutMs, + elapsedMs: deadline.elapsedMs(), + message: 'timeout', + }); + } + const result = await runCmd( + 'adb', + ['-s', serial, 'shell', 'getprop', 'sys.boot_completed'], + { allowFailure: true }, + ); + lastBootResult = result; + if ((result.stdout as string).trim() === '1') return; + throw new AppError('COMMAND_FAILED', 'Android device is still booting', { + serial, + stdout: result.stdout, + stderr: result.stderr, + exitCode: result.exitCode, + }); + }, + { + maxAttempts, + baseDelayMs: 1000, + maxDelayMs: 1000, + jitter: 0, + shouldRetry: (error) => { + const reason = classifyBootFailure({ + error, + stdout: lastBootResult?.stdout, + stderr: lastBootResult?.stderr, + }); + return reason !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING' && reason !== 'BOOT_TIMEOUT'; + }, + }, + { deadline }, + ); + } catch (error) { + const appErr = asAppError(error); + const reason = classifyBootFailure({ + error, + stdout: lastBootResult?.stdout, + stderr: lastBootResult?.stderr, + }); + const baseDetails = { + serial, + timeoutMs, + elapsedMs: deadline.elapsedMs(), + reason, + stdout: lastBootResult?.stdout, + stderr: lastBootResult?.stderr, + exitCode: lastBootResult?.exitCode, + }; + if (timedOut || reason === 'BOOT_TIMEOUT') { + throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails); + } + if (appErr.code === 'TOOL_MISSING' || reason === 'TOOL_MISSING') { + throw new AppError('TOOL_MISSING', appErr.message, { + ...baseDetails, + ...(appErr.details ?? {}), + }); + } + if (reason === 'PERMISSION_DENIED' || reason === 'DEVICE_UNAVAILABLE' || reason === 'DEVICE_OFFLINE') { + throw new AppError('COMMAND_FAILED', appErr.message, { + ...baseDetails, + ...(appErr.details ?? {}), + }); + } + throw new AppError(appErr.code, appErr.message, { + ...baseDetails, + ...(appErr.details ?? {}), + }, appErr.cause); } - throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', { - serial, - timeoutMs, - }); } diff --git a/src/platforms/boot-diagnostics.ts b/src/platforms/boot-diagnostics.ts new file mode 100644 index 000000000..7dd592d76 --- /dev/null +++ b/src/platforms/boot-diagnostics.ts @@ -0,0 +1,67 @@ +import { asAppError } from '../utils/errors.ts'; + +export type BootFailureReason = + | 'BOOT_TIMEOUT' + | 'DEVICE_UNAVAILABLE' + | 'DEVICE_OFFLINE' + | 'PERMISSION_DENIED' + | 'TOOL_MISSING' + | 'BOOT_COMMAND_FAILED' + | 'UNKNOWN'; + +export function classifyBootFailure(input: { + error?: unknown; + message?: string; + stdout?: string; + stderr?: string; +}): BootFailureReason { + const appErr = input.error ? asAppError(input.error) : null; + if (appErr?.code === 'TOOL_MISSING') return 'TOOL_MISSING'; + const details = (appErr?.details ?? {}) as Record; + const detailMessage = typeof details.message === 'string' ? details.message : undefined; + const detailStdout = typeof details.stdout === 'string' ? details.stdout : undefined; + const detailStderr = typeof details.stderr === 'string' ? details.stderr : undefined; + const nestedBoot = details.boot && typeof details.boot === 'object' + ? (details.boot as Record) + : null; + const nestedBootstatus = details.bootstatus && typeof details.bootstatus === 'object' + ? (details.bootstatus as Record) + : null; + + const haystack = [ + input.message, + appErr?.message, + input.stdout, + input.stderr, + detailMessage, + detailStdout, + detailStderr, + typeof nestedBoot?.stdout === 'string' ? nestedBoot.stdout : undefined, + typeof nestedBoot?.stderr === 'string' ? nestedBoot.stderr : undefined, + typeof nestedBootstatus?.stdout === 'string' ? nestedBootstatus.stdout : undefined, + typeof nestedBootstatus?.stderr === 'string' ? nestedBootstatus.stderr : undefined, + ] + .filter(Boolean) + .join('\n') + .toLowerCase(); + + if (haystack.includes('timed out') || haystack.includes('timeout')) return 'BOOT_TIMEOUT'; + if ( + haystack.includes('device not found') || + haystack.includes('no devices') || + haystack.includes('unable to locate device') || + haystack.includes('invalid device') + ) { + return 'DEVICE_UNAVAILABLE'; + } + if (haystack.includes('offline')) return 'DEVICE_OFFLINE'; + if ( + haystack.includes('permission denied') || + haystack.includes('not authorized') || + haystack.includes('unauthorized') + ) { + return 'PERMISSION_DENIED'; + } + if (appErr?.code === 'COMMAND_FAILED' || haystack.length > 0) return 'BOOT_COMMAND_FAILED'; + return 'UNKNOWN'; +} diff --git a/src/platforms/ios/index.ts b/src/platforms/ios/index.ts index 17d452e5a..91b44d4ea 100644 --- a/src/platforms/ios/index.ts +++ b/src/platforms/ios/index.ts @@ -1,11 +1,15 @@ import { runCmd } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import { Deadline, retryWithPolicy } from '../../utils/retry.ts'; +import { classifyBootFailure } from '../boot-diagnostics.ts'; const ALIASES: Record = { settings: 'com.apple.Preferences', }; +const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS, 120_000, 5_000); + export async function resolveIosApp(device: DeviceInfo, app: string): Promise { const trimmed = app.trim(); if (trimmed.includes('.')) return trimmed; @@ -207,8 +211,82 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { if (device.kind !== 'simulator') return; const state = await getSimulatorState(device.id); if (state === 'Booted') return; - await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true }); - await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], { allowFailure: true }); + const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS); + let bootResult: { stdout: string; stderr: string; exitCode: number } | null = null; + let bootStatusResult: { stdout: string; stderr: string; exitCode: number } | null = null; + try { + await retryWithPolicy( + async () => { + const currentState = await getSimulatorState(device.id); + if (currentState === 'Booted') return; + bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], { allowFailure: true }); + const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase(); + const bootAlreadyDone = + bootOutput.includes('already booted') || bootOutput.includes('current state: booted'); + if (bootResult.exitCode !== 0 && !bootAlreadyDone) { + throw new AppError('COMMAND_FAILED', 'simctl boot failed', { + stdout: bootResult.stdout, + stderr: bootResult.stderr, + exitCode: bootResult.exitCode, + }); + } + bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], { + allowFailure: true, + }); + if (bootStatusResult.exitCode !== 0) { + throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', { + stdout: bootStatusResult.stdout, + stderr: bootStatusResult.stderr, + exitCode: bootStatusResult.exitCode, + }); + } + const nextState = await getSimulatorState(device.id); + if (nextState !== 'Booted') { + throw new AppError('COMMAND_FAILED', 'Simulator is still booting', { + state: nextState, + }); + } + }, + { + maxAttempts: 3, + baseDelayMs: 500, + maxDelayMs: 2000, + jitter: 0.2, + shouldRetry: (error) => { + const reason = classifyBootFailure({ + error, + stdout: bootStatusResult?.stdout ?? bootResult?.stdout, + stderr: bootStatusResult?.stderr ?? bootResult?.stderr, + }); + return reason !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING'; + }, + }, + { deadline }, + ); + } catch (error) { + const reason = classifyBootFailure({ + error, + stdout: bootStatusResult?.stdout ?? bootResult?.stdout, + stderr: bootStatusResult?.stderr ?? bootResult?.stderr, + }); + throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', { + platform: 'ios', + deviceId: device.id, + timeoutMs: IOS_BOOT_TIMEOUT_MS, + elapsedMs: deadline.elapsedMs(), + reason, + boot: bootResult + ? { exitCode: bootResult.exitCode, stdout: bootResult.stdout, stderr: bootResult.stderr } + : undefined, + bootstatus: bootStatusResult + ? { + exitCode: bootStatusResult.exitCode, + stdout: bootStatusResult.stdout, + stderr: bootStatusResult.stderr, + } + : undefined, + }); + } } async function getSimulatorState(udid: string): Promise { @@ -229,3 +307,10 @@ async function getSimulatorState(udid: string): Promise { } return null; } + +function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number { + if (!raw) return fallback; + const parsed = Number(raw); + if (!Number.isFinite(parsed)) return fallback; + return Math.max(min, Math.floor(parsed)); +} diff --git a/src/utils/__tests__/retry.test.ts b/src/utils/__tests__/retry.test.ts new file mode 100644 index 000000000..674e922e0 --- /dev/null +++ b/src/utils/__tests__/retry.test.ts @@ -0,0 +1,27 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { Deadline, retryWithPolicy } from '../retry.ts'; + +test('Deadline tracks remaining and expiration', async () => { + const deadline = Deadline.fromTimeoutMs(25); + assert.equal(deadline.isExpired(), false); + await new Promise((resolve) => setTimeout(resolve, 30)); + assert.equal(deadline.isExpired(), true); + assert.equal(deadline.remainingMs(), 0); +}); + +test('retryWithPolicy retries until success', async () => { + let attempts = 0; + const result = await retryWithPolicy( + async () => { + attempts += 1; + if (attempts < 3) { + throw new Error('transient'); + } + return 'ok'; + }, + { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 }, + ); + assert.equal(result, 'ok'); + assert.equal(attempts, 3); +}); diff --git a/src/utils/retry.ts b/src/utils/retry.ts index 9ef408593..231d8612d 100644 --- a/src/utils/retry.ts +++ b/src/utils/retry.ts @@ -8,6 +8,20 @@ type RetryOptions = { shouldRetry?: (error: unknown, attempt: number) => boolean; }; +export type RetryPolicy = { + maxAttempts: number; + baseDelayMs: number; + maxDelayMs: number; + jitter: number; + shouldRetry?: (error: unknown, attempt: number) => boolean; +}; + +export type RetryAttemptContext = { + attempt: number; + maxAttempts: number; + deadline?: Deadline; +}; + const defaultOptions: Required> = { attempts: 3, baseDelayMs: 200, @@ -15,30 +29,76 @@ const defaultOptions: Required( - fn: () => Promise, - options: RetryOptions = {}, +export class Deadline { + private readonly startedAtMs: number; + private readonly expiresAtMs: number; + + private constructor(startedAtMs: number, timeoutMs: number) { + this.startedAtMs = startedAtMs; + this.expiresAtMs = startedAtMs + Math.max(0, timeoutMs); + } + + static fromTimeoutMs(timeoutMs: number, nowMs = Date.now()): Deadline { + return new Deadline(nowMs, timeoutMs); + } + + remainingMs(nowMs = Date.now()): number { + return Math.max(0, this.expiresAtMs - nowMs); + } + + elapsedMs(nowMs = Date.now()): number { + return Math.max(0, nowMs - this.startedAtMs); + } + + isExpired(nowMs = Date.now()): boolean { + return this.remainingMs(nowMs) <= 0; + } +} + +export async function retryWithPolicy( + fn: (context: RetryAttemptContext) => Promise, + policy: Partial = {}, + options: { deadline?: Deadline } = {}, ): Promise { - const attempts = options.attempts ?? defaultOptions.attempts; - const baseDelayMs = options.baseDelayMs ?? defaultOptions.baseDelayMs; - const maxDelayMs = options.maxDelayMs ?? defaultOptions.maxDelayMs; - const jitter = options.jitter ?? defaultOptions.jitter; + const merged: RetryPolicy = { + maxAttempts: policy.maxAttempts ?? defaultOptions.attempts, + baseDelayMs: policy.baseDelayMs ?? defaultOptions.baseDelayMs, + maxDelayMs: policy.maxDelayMs ?? defaultOptions.maxDelayMs, + jitter: policy.jitter ?? defaultOptions.jitter, + shouldRetry: policy.shouldRetry, + }; let lastError: unknown; - for (let attempt = 1; attempt <= attempts; attempt += 1) { + for (let attempt = 1; attempt <= merged.maxAttempts; attempt += 1) { + if (options.deadline?.isExpired() && attempt > 1) break; try { - return await fn(); + return await fn({ attempt, maxAttempts: merged.maxAttempts, deadline: options.deadline }); } catch (err) { lastError = err; - if (attempt >= attempts) break; - if (options.shouldRetry && !options.shouldRetry(err, attempt)) break; - const delay = computeDelay(baseDelayMs, maxDelayMs, jitter, attempt); - await sleep(delay); + if (attempt >= merged.maxAttempts) break; + if (merged.shouldRetry && !merged.shouldRetry(err, attempt)) break; + const delay = computeDelay(merged.baseDelayMs, merged.maxDelayMs, merged.jitter, attempt); + const boundedDelay = options.deadline ? Math.min(delay, options.deadline.remainingMs()) : delay; + if (boundedDelay <= 0) break; + await sleep(boundedDelay); } } if (lastError) throw lastError; throw new AppError('COMMAND_FAILED', 'retry failed'); } +export async function withRetry( + fn: () => Promise, + options: RetryOptions = {}, +): Promise { + return retryWithPolicy(() => fn(), { + maxAttempts: options.attempts, + baseDelayMs: options.baseDelayMs, + maxDelayMs: options.maxDelayMs, + jitter: options.jitter, + shouldRetry: options.shouldRetry, + }); +} + function computeDelay(base: number, max: number, jitter: number, attempt: number): number { const exp = Math.min(max, base * 2 ** (attempt - 1)); const jitterAmount = exp * jitter; From 94ed1bdddc88273e94fb1b4b7791e83ac535bc01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 11 Feb 2026 19:02:29 +0100 Subject: [PATCH 2/4] Fix TypeScript narrowing in boot diagnostics error paths --- src/platforms/android/devices.ts | 16 ++++++++++------ src/platforms/ios/index.ts | 23 +++++++++++++++-------- 2 files changed, 25 insertions(+), 14 deletions(-) diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index a14edd456..206dfdb97 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -1,4 +1,5 @@ import { runCmd, whichCmd } from '../../utils/exec.ts'; +import type { ExecResult } from '../../utils/exec.ts'; import { AppError, asAppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { Deadline, retryWithPolicy } from '../../utils/retry.ts'; @@ -63,7 +64,7 @@ export async function isAndroidBooted(serial: string): Promise { export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise { const deadline = Deadline.fromTimeoutMs(timeoutMs); const maxAttempts = Math.max(1, Math.ceil(timeoutMs / 1000)); - let lastBootResult: { stdout: string; stderr: string; exitCode: number } | null = null; + let lastBootResult: ExecResult | undefined; let timedOut = false; try { await retryWithPolicy( @@ -109,19 +110,22 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro ); } catch (error) { const appErr = asAppError(error); + const stdout = lastBootResult?.stdout; + const stderr = lastBootResult?.stderr; + const exitCode = lastBootResult?.exitCode; const reason = classifyBootFailure({ error, - stdout: lastBootResult?.stdout, - stderr: lastBootResult?.stderr, + stdout, + stderr, }); const baseDetails = { serial, timeoutMs, elapsedMs: deadline.elapsedMs(), reason, - stdout: lastBootResult?.stdout, - stderr: lastBootResult?.stderr, - exitCode: lastBootResult?.exitCode, + stdout, + stderr, + exitCode, }; if (timedOut || reason === 'BOOT_TIMEOUT') { throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails); diff --git a/src/platforms/ios/index.ts b/src/platforms/ios/index.ts index 91b44d4ea..92ef996ae 100644 --- a/src/platforms/ios/index.ts +++ b/src/platforms/ios/index.ts @@ -1,4 +1,5 @@ import { runCmd } from '../../utils/exec.ts'; +import type { ExecResult } from '../../utils/exec.ts'; import { AppError } from '../../utils/errors.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { Deadline, retryWithPolicy } from '../../utils/retry.ts'; @@ -212,8 +213,8 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { const state = await getSimulatorState(device.id); if (state === 'Booted') return; const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS); - let bootResult: { stdout: string; stderr: string; exitCode: number } | null = null; - let bootStatusResult: { stdout: string; stderr: string; exitCode: number } | null = null; + let bootResult: ExecResult | undefined; + let bootStatusResult: ExecResult | undefined; try { await retryWithPolicy( async () => { @@ -264,10 +265,16 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { { deadline }, ); } catch (error) { + const bootStdout = bootResult?.stdout; + const bootStderr = bootResult?.stderr; + const bootExitCode = bootResult?.exitCode; + const bootstatusStdout = bootStatusResult?.stdout; + const bootstatusStderr = bootStatusResult?.stderr; + const bootstatusExitCode = bootStatusResult?.exitCode; const reason = classifyBootFailure({ error, - stdout: bootStatusResult?.stdout ?? bootResult?.stdout, - stderr: bootStatusResult?.stderr ?? bootResult?.stderr, + stdout: bootstatusStdout ?? bootStdout, + stderr: bootstatusStderr ?? bootStderr, }); throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', { platform: 'ios', @@ -276,13 +283,13 @@ export async function ensureBootedSimulator(device: DeviceInfo): Promise { elapsedMs: deadline.elapsedMs(), reason, boot: bootResult - ? { exitCode: bootResult.exitCode, stdout: bootResult.stdout, stderr: bootResult.stderr } + ? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr } : undefined, bootstatus: bootStatusResult ? { - exitCode: bootStatusResult.exitCode, - stdout: bootStatusResult.stdout, - stderr: bootStatusResult.stderr, + exitCode: bootstatusExitCode, + stdout: bootstatusStdout, + stderr: bootstatusStderr, } : undefined, }); From 6c5b10a17a17807595d1329400f83d8beb97b711 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 12 Feb 2026 10:03:11 +0100 Subject: [PATCH 3/4] update docs and cleanup --- README.md | 14 +++++ src/platforms/android/devices.ts | 95 ++++++++++++++++++-------------- 2 files changed, 68 insertions(+), 41 deletions(-) diff --git a/README.md b/README.md index ebc98c9a4..13b95127f 100644 --- a/README.md +++ b/README.md @@ -184,6 +184,11 @@ App state: - Built-in retries cover transient runner connection failures, AX snapshot hiccups, and Android UI dumps. - For snapshot issues (missing elements), compare with `--raw` flag for unaltered output and scope with `-s "