From 1b0a82344177e7986b1d97016bd73034317949c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 25 Jun 2026 21:13:52 +0200 Subject: [PATCH 01/12] feat: add doctor command --- src/__tests__/cli-doctor-progress.test.ts | 72 +++ src/__tests__/cli-network.test.ts | 39 ++ src/__tests__/daemon-client-progress.test.ts | 60 ++ src/__tests__/test-utils/color.ts | 14 + src/__tests__/test-utils/index.ts | 2 + src/cli-doctor-output.ts | 39 ++ src/cli-doctor-progress.ts | 40 ++ src/cli-status-markers.ts | 21 + src/cli-test-progress.ts | 16 +- src/cli.ts | 7 +- src/client-types.ts | 9 + src/client.ts | 1 + src/command-catalog.ts | 2 + src/commands/management/doctor.ts | 62 +++ src/commands/management/index.ts | 2 + src/commands/management/output.test.ts | 92 ++- src/commands/management/output.ts | 36 ++ src/contracts.ts | 2 +- src/daemon-client-progress.ts | 11 +- .../__tests__/daemon-command-registry.test.ts | 5 + src/daemon/daemon-command-registry.ts | 6 + src/daemon/handlers/session-doctor.ts | 525 ++++++++++++++++++ src/daemon/handlers/session.ts | 10 + src/daemon/request-progress-protocol.ts | 2 +- src/daemon/request-progress.ts | 15 +- src/daemon/session-store.ts | 6 + src/platforms/ios/__tests__/devices.test.ts | 76 +++ src/platforms/ios/devices.ts | 31 +- src/utils/__tests__/args.test.ts | 50 ++ src/utils/__tests__/device.test.ts | 46 ++ src/utils/cli-flags.ts | 24 + src/utils/cli-help.ts | 3 + src/utils/device.ts | 63 ++- .../provider-scenarios/doctor.test.ts | 146 +++++ 34 files changed, 1491 insertions(+), 44 deletions(-) create mode 100644 src/__tests__/cli-doctor-progress.test.ts create mode 100644 src/__tests__/test-utils/color.ts create mode 100644 src/cli-doctor-output.ts create mode 100644 src/cli-doctor-progress.ts create mode 100644 src/cli-status-markers.ts create mode 100644 src/commands/management/doctor.ts create mode 100644 src/daemon/handlers/session-doctor.ts create mode 100644 test/integration/provider-scenarios/doctor.test.ts diff --git a/src/__tests__/cli-doctor-progress.test.ts b/src/__tests__/cli-doctor-progress.test.ts new file mode 100644 index 000000000..b0e814b0d --- /dev/null +++ b/src/__tests__/cli-doctor-progress.test.ts @@ -0,0 +1,72 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { formatDoctorProgressEvent } from '../cli-doctor-progress.ts'; +import type { RequestProgressEvent } from '../daemon/request-progress.ts'; +import { withNoColor } from './test-utils/index.ts'; + +test('formatDoctorProgressEvent ignores non-doctor progress events', () => { + const line = formatDoctorProgressEvent({ + type: 'replay-test-suite', + status: 'start', + total: 1, + runnable: 1, + skipped: 0, + artifactsDir: '/tmp/replay-suite', + }); + + assert.equal(line, undefined); +}); + +test('formatDoctorProgressEvent renders doctor pass, info, warn, and fail checks', () => { + const cases: Array<{ event: RequestProgressEvent; expected: string }> = [ + { + event: { + type: 'doctor-check', + id: 'device', + status: 'pass', + summary: 'Selected Pixel (android/mobile)', + index: 1, + }, + expected: '✓ device: Selected Pixel (android/mobile)', + }, + { + event: { + type: 'doctor-check', + id: 'session', + status: 'info', + summary: 'No active session named default. Doctor will use the selected device.', + index: 2, + }, + expected: '- session: No active session named default. Doctor will use the selected device.', + }, + { + event: { + type: 'doctor-check', + id: 'android-reverse', + status: 'warn', + summary: 'Android adb reverse is missing for Metro port 8081.', + index: 3, + command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + expected: + '! android-reverse: Android adb reverse is missing for Metro port 8081.\n run: adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + { + event: { + type: 'doctor-check', + id: 'device', + status: 'fail', + summary: 'No devices found.', + index: 4, + command: 'agent-device devices', + }, + expected: '⨯ device: No devices found.\n run: agent-device devices', + }, + ]; + + withNoColor(() => { + for (const { event, expected } of cases) { + assert.equal(formatDoctorProgressEvent(event), expected); + } + }); +}); diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index bdbef7d7f..8fe355e85 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -124,6 +124,45 @@ test('test command prints suite summary and exits non-zero on failures', async ( assert.match(result.stdout, /Test summary: 1 passed, 1 failed in 0\.025s/); }); +test('doctor command opts into progress rows for human output', async () => { + const result = await runCliCapture(['doctor'], async () => ({ + ok: true, + data: { + status: 'pass', + summary: 'No blockers found.', + checks: [ + { + id: 'agent-device', + status: 'pass', + summary: 'agent-device 0.17.9 using /tmp/agent-device', + }, + ], + }, + })); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'doctor'); + assert.equal(result.calls[0]?.meta?.requestProgress, 'doctor'); + assert.match(result.stdout, /✓ agent-device: agent-device 0\.17\.9 using \/tmp\/agent-device/); +}); + +test('doctor command keeps json output non-streaming', async () => { + const result = await runCliCapture(['doctor', '--json'], async () => ({ + ok: true, + data: { + status: 'pass', + summary: 'No blockers found.', + checks: [], + }, + })); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.meta?.requestProgress, undefined); + assert.match(result.stdout, /"success": true/); +}); + test('test command --verbose prints all test statuses', async () => { const result = await runCliCapture(['test', './suite', '--verbose'], async () => makeReplaySuiteResponse(), diff --git a/src/__tests__/daemon-client-progress.test.ts b/src/__tests__/daemon-client-progress.test.ts index 0a552409d..4a02577f9 100644 --- a/src/__tests__/daemon-client-progress.test.ts +++ b/src/__tests__/daemon-client-progress.test.ts @@ -129,6 +129,66 @@ test('readDaemonSocketProgressResponse parses split progress lines before respon } }); +test('readDaemonSocketProgressResponse prints doctor progress before response envelopes', async () => { + const socket = createMockSocket(); + const req: DaemonRequest = { + session: 'default', + command: 'doctor', + positionals: [], + flags: {}, + token: 'secret', + meta: { requestId: 'req-doctor-progress', requestProgress: 'doctor' }, + }; + let stderr = ''; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + const originalForceColor = process.env.FORCE_COLOR; + const originalNoColor = process.env.NO_COLOR; + + try { + delete process.env.FORCE_COLOR; + process.env.NO_COLOR = '1'; + (process.stderr as any).write = ((chunk: unknown) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + const responsePromise = readSocketProgressResponse(socket, req); + const progressLine = JSON.stringify({ + type: 'progress', + event: { + type: 'doctor-check', + id: 'android-reverse', + status: 'warn', + summary: 'Android adb reverse is missing for Metro port 8081.', + index: 3, + command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + }); + const responseLine = JSON.stringify({ + type: 'response', + response: { ok: true, data: { status: 'warn', checks: [] } }, + }); + + socket.emit('data', `${progressLine}\n${responseLine}\n`); + + assert.deepEqual(await responsePromise, { ok: true, data: { status: 'warn', checks: [] } }); + assert.equal( + stderr, + [ + '! android-reverse: Android adb reverse is missing for Metro port 8081.', + ' run: adb -s emulator-5554 reverse tcp:8081 tcp:8081', + '', + ].join('\n'), + ); + } finally { + if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor; + else delete process.env.FORCE_COLOR; + if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor; + else delete process.env.NO_COLOR; + process.stderr.write = originalStderrWrite; + } +}); + test('readDaemonSocketProgressResponse rewrites live progress and clears it for final result', async () => { const socket = createMockSocket(); const req: DaemonRequest = { diff --git a/src/__tests__/test-utils/color.ts b/src/__tests__/test-utils/color.ts new file mode 100644 index 000000000..3221893f6 --- /dev/null +++ b/src/__tests__/test-utils/color.ts @@ -0,0 +1,14 @@ +export function withNoColor(run: () => T): T { + const originalForceColor = process.env.FORCE_COLOR; + const originalNoColor = process.env.NO_COLOR; + delete process.env.FORCE_COLOR; + process.env.NO_COLOR = '1'; + try { + return run(); + } finally { + if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor; + else delete process.env.FORCE_COLOR; + if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor; + else delete process.env.NO_COLOR; + } +} diff --git a/src/__tests__/test-utils/index.ts b/src/__tests__/test-utils/index.ts index a648875a7..c3f7c30c2 100644 --- a/src/__tests__/test-utils/index.ts +++ b/src/__tests__/test-utils/index.ts @@ -18,6 +18,8 @@ export { export { makeSnapshotState } from './snapshot-builders.ts'; +export { withNoColor } from './color.ts'; + export { closeLoopbackServer, listenOnLoopback, diff --git a/src/cli-doctor-output.ts b/src/cli-doctor-output.ts new file mode 100644 index 000000000..2cf80d905 --- /dev/null +++ b/src/cli-doctor-output.ts @@ -0,0 +1,39 @@ +import { formatCliStatusMarker, type CliStatusMarkerStatus } from './cli-status-markers.ts'; + +let renderedDoctorProgress = false; + +export function markDoctorProgressRendered(): void { + renderedDoctorProgress = true; +} + +export function consumeDoctorProgressRendered(): boolean { + const rendered = renderedDoctorProgress; + renderedDoctorProgress = false; + return rendered; +} + +export function formatDoctorCheckSummaryLine(check: Record): string { + const statusMarker = formatCliStatusMarker(doctorStatusMarker(check.status)); + return `${statusMarker} ${formatDoctorCheckLabel(check)}`; +} + +export function formatDoctorCheckDetailLines(check: Record): string[] { + if (check.status !== 'fail' && check.status !== 'warn') return []; + if (typeof check.command === 'string') return [` run: ${check.command}`]; + if (typeof check.hint === 'string') return [` hint: ${check.hint}`]; + return []; +} + +function doctorStatusMarker(status: unknown): CliStatusMarkerStatus { + if (status === 'pass') return 'pass'; + if (status === 'fail') return 'fail'; + if (status === 'warn') return 'warn'; + return 'skip'; +} + +function formatDoctorCheckLabel(check: Record): string { + const id = typeof check.id === 'string' && check.id.length > 0 ? check.id : 'check'; + const summary = + typeof check.summary === 'string' && check.summary.length > 0 ? check.summary : id; + return summary === id ? id : `${id}: ${summary}`; +} diff --git a/src/cli-doctor-progress.ts b/src/cli-doctor-progress.ts new file mode 100644 index 000000000..99e7e347c --- /dev/null +++ b/src/cli-doctor-progress.ts @@ -0,0 +1,40 @@ +import type { RequestProgressEvent } from './daemon/request-progress.ts'; +import { + markDoctorProgressRendered, + formatDoctorCheckDetailLines, + formatDoctorCheckSummaryLine, +} from './cli-doctor-output.ts'; + +type DoctorCheckProgressEvent = Extract; + +export type DoctorProgressRenderer = { + render(event: RequestProgressEvent): { text: string; newline: true } | undefined; +}; + +export function createDoctorProgressRenderer(): DoctorProgressRenderer { + const completed = new Set(); + return { + render(event) { + if (event.type !== 'doctor-check') return undefined; + const key = doctorCheckProgressKey(event); + if (completed.has(key)) return undefined; + completed.add(key); + markDoctorProgressRendered(); + const text = formatDoctorProgressEvent(event); + if (!text) return undefined; + return { + text, + newline: true, + }; + }, + }; +} + +export function formatDoctorProgressEvent(event: RequestProgressEvent): string | undefined { + if (event.type !== 'doctor-check') return undefined; + return [formatDoctorCheckSummaryLine(event), ...formatDoctorCheckDetailLines(event)].join('\n'); +} + +function doctorCheckProgressKey(event: DoctorCheckProgressEvent): string { + return [event.index, event.id, event.status, event.summary].join('\0'); +} diff --git a/src/cli-status-markers.ts b/src/cli-status-markers.ts new file mode 100644 index 000000000..e975fe1b0 --- /dev/null +++ b/src/cli-status-markers.ts @@ -0,0 +1,21 @@ +import { colorize, supportsColor } from './utils/output.ts'; + +export type CliStatusMarkerStatus = 'pass' | 'fail' | 'warn' | 'skip'; + +export function formatCliStatusMarker( + status: CliStatusMarkerStatus, + options: { passFormat?: 'green' | 'yellow' } = {}, +): string { + const useColor = supportsColor(process.stderr); + if (status === 'pass') { + const format = options.passFormat ?? 'green'; + return useColor ? colorizeStatusMarker('✓', format) : '✓'; + } + if (status === 'fail') return useColor ? colorizeStatusMarker('⨯', 'red') : '⨯'; + if (status === 'warn') return useColor ? colorizeStatusMarker('!', 'yellow') : '!'; + return useColor ? colorizeStatusMarker('-', 'dim') : '-'; +} + +function colorizeStatusMarker(text: string, format: Parameters[1]): string { + return colorize(text, format, { validateStream: false }); +} diff --git a/src/cli-test-progress.ts b/src/cli-test-progress.ts index 4a135613f..b56d7363c 100644 --- a/src/cli-test-progress.ts +++ b/src/cli-test-progress.ts @@ -3,7 +3,7 @@ import type { RequestProgressEvent } from './daemon/request-progress.ts'; import { replayTestStepLines } from './cli-test-trace.ts'; import type { ReplaySuiteTestResult } from './daemon/types.ts'; import { formatDurationSeconds } from './utils/duration-format.ts'; -import { colorize, supportsColor } from './utils/output.ts'; +import { formatCliStatusMarker } from './cli-status-markers.ts'; type ReplayTestCaseProgressEvent = Extract; type ReplayTestProgressFormatOptions = { @@ -137,18 +137,14 @@ function formatReplayTestProgressName(event: ReplayTestCaseProgressEvent): strin } function formatReplayTestProgressStatusLabel(event: ReplayTestCaseProgressEvent): string { - const useColor = supportsColor(process.stderr); if (event.status === 'pass') { - const format = event.attempt && event.attempt > 1 ? 'yellow' : 'green'; - return useColor ? colorizeProgressMarker('✓', format) : '✓'; + return formatCliStatusMarker('pass', { + passFormat: event.attempt && event.attempt > 1 ? 'yellow' : 'green', + }); } - if (event.status === 'fail') return useColor ? colorizeProgressMarker('⨯', 'red') : '⨯'; + if (event.status === 'fail') return formatCliStatusMarker('fail'); if (event.status === 'progress') return '⊙'; - return useColor ? colorizeProgressMarker('-', 'dim') : '-'; -} - -function colorizeProgressMarker(text: string, format: Parameters[1]): string { - return colorize(text, format, { validateStream: false }); + return formatCliStatusMarker('skip'); } function formatReplayTestProgressShardSuffix(event: ReplayTestCaseProgressEvent): string { diff --git a/src/cli.ts b/src/cli.ts index 13307a124..a66d02d39 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -542,13 +542,16 @@ function createCliDaemonTransport(options: { transport: AgentDeviceDaemonTransport; }): AgentDeviceDaemonTransport { const { command, flags, transport } = options; - if (command !== 'test' || flags.json) return transport; + if (flags.json) return transport; + const requestProgress = + command === 'test' ? 'replay-test' : command === 'doctor' ? 'doctor' : undefined; + if (!requestProgress) return transport; return async (req) => await transport({ ...req, meta: { ...req.meta, - requestProgress: 'replay-test', + requestProgress, }, }); } diff --git a/src/client-types.ts b/src/client-types.ts index 4885e47f6..c21f2f892 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -509,6 +509,13 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & { timeoutMs?: number; }; +export type DoctorCommandOptions = DeviceCommandBaseOptions & { + targetApp?: string; + metroHost?: string; + metroPort?: number; + kind?: 'auto' | 'react-native' | 'expo'; +}; + export type ViewportCommandOptions = DeviceCommandBaseOptions & { width: number; height: number; @@ -530,6 +537,7 @@ export type AgentDeviceCommandClient = { keyboard: (options?: KeyboardCommandOptions) => Promise; clipboard: (options: ClipboardCommandOptions) => Promise; reactNative: (options: ReactNativeCommandOptions) => Promise; + doctor: (options?: DoctorCommandOptions) => Promise; prepare: (options: PrepareCommandOptions) => Promise; viewport: (options: ViewportCommandOptions) => Promise; }; @@ -920,6 +928,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & metroPort?: number; bundleUrl?: string; launchUrl?: string; + targetApp?: string; appsFilter?: AppsFilter; installSource?: DaemonInstallSource; retainMaterializedPaths?: boolean; diff --git a/src/client.ts b/src/client.ts index efca6d455..8b2af4664 100644 --- a/src/client.ts +++ b/src/client.ts @@ -108,6 +108,7 @@ export function createAgentDeviceClient( keyboard: async (options = {}) => await executeCommand('keyboard', options), clipboard: async (options) => await executeCommand('clipboard', options), reactNative: async (options) => await executeCommand('react-native', options), + doctor: async (options = {}) => await executeCommand('doctor', options), prepare: async (options) => await executeCommand('prepare', options), viewport: async (options) => await executeCommand('viewport', options), }, diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 8d5ccf331..fb26e89ee 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -10,6 +10,7 @@ export const PUBLIC_COMMANDS = { close: 'close', clipboard: 'clipboard', devices: 'devices', + doctor: 'doctor', diff: 'diff', fill: 'fill', find: 'find', @@ -116,6 +117,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.doctor, PUBLIC_COMMANDS.gesture, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test, diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts new file mode 100644 index 000000000..957df0ab2 --- /dev/null +++ b/src/commands/management/doctor.ts @@ -0,0 +1,62 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import { enumField, integerField, stringField } from '../command-input.ts'; +import { defineExecutableCommand } from '../command-contract.ts'; +import { commonInputFromFlags, direct } from '../cli-grammar/common.ts'; +import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; +import { defineCommandFacet } from '../family/types.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { managementCliOutputFormatters } from './output.ts'; + +const doctorCommandMetadata = defineFieldCommandMetadata( + 'doctor', + 'Diagnose device, app, Metro, and React Native readiness before a run.', + { + targetApp: stringField('Expected app bundle id, package name, or app name.'), + metroHost: stringField('Metro host to probe.'), + metroPort: integerField('Metro port to probe.'), + kind: enumField(['auto', 'react-native', 'expo']), + }, +); + +const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, (client, input) => + client.command.doctor(input), +); + +const doctorCliSchema = { + usageOverride: + 'doctor [--platform ios|android|macos|linux|web|apple] [--target-app ] [--metro-host ] [--metro-port ] [--react-native|--expo|--kind auto|react-native|expo]', + helpDescription: + 'Read-only preflight for QA and dogfood runs. Reports device readiness, active sessions, target app discovery, Metro reachability, and obvious React Native overlay blockers from the current session snapshot. Default output is compact; use --json for full checks and evidence.', + summary: 'Preflight device, app, Metro, and RN/Expo readiness', + allowedFlags: ['targetApp', 'metroHost', 'metroPort', 'doctorReactNative', 'doctorExpo', 'kind'], +} as const satisfies CommandSchemaOverride; + +const doctorCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + targetApp: flags.targetApp, + metroHost: flags.metroHost, + metroPort: flags.metroPort, + kind: resolveDoctorKind(flags), +}); + +const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor); + +export const doctorCommandFacet = defineCommandFacet({ + name: 'doctor', + metadata: doctorCommandMetadata, + definition: doctorCommandDefinition, + cliSchema: doctorCliSchema, + cliReader: doctorCliReader, + daemonWriter: doctorDaemonWriter, + cliOutputFormatter: managementCliOutputFormatters.doctor, +}); + +function resolveDoctorKind(flags: Parameters[1]): 'auto' | 'react-native' | 'expo' { + if (flags.doctorExpo) return 'expo'; + if (flags.doctorReactNative) return 'react-native'; + if (flags.kind === 'expo' || flags.kind === 'react-native' || flags.kind === 'auto') { + return flags.kind; + } + return 'auto'; +} diff --git a/src/commands/management/index.ts b/src/commands/management/index.ts index 1d63dae3c..34d5c1d68 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -1,6 +1,7 @@ import { defineCommandFamilyFromFacets } from '../family/types.ts'; import { appsCommandFacet, closeCommandFacet, openCommandFacet } from './app.ts'; import { deviceManagementCommandFacets } from './device.ts'; +import { doctorCommandFacet } from './doctor.ts'; import { installManagementCommandFacets } from './install.ts'; import { prepareCommandFacet } from './prepare.ts'; import { pushManagementCommandFacets } from './push.ts'; @@ -11,6 +12,7 @@ export const managementCommandFamily = defineCommandFamilyFromFacets({ name: 'management', commands: [ ...deviceManagementCommandFacets, + doctorCommandFacet, prepareCommandFacet, appsCommandFacet, sessionCommandFacet, diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index dfe7e176f..b628ae43b 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'vitest'; -import { openCliOutput } from './output.ts'; +import { doctorCliOutput, openCliOutput } from './output.ts'; +import { markDoctorProgressRendered } from '../../cli-doctor-output.ts'; +import { withNoColor } from '../../__tests__/test-utils/index.ts'; describe('openCliOutput', () => { test('prints session state directory on a second line', () => { @@ -18,3 +20,91 @@ describe('openCliOutput', () => { }); }); }); + +describe('doctorCliOutput', () => { + test('prints passing checks by default using test-style status markers', () => { + const output = withNoColor(() => + doctorCliOutput({ + status: 'pass', + summary: 'No blockers found.', + checks: [ + { + id: 'agent-device', + status: 'pass', + summary: 'agent-device 0.17.9 using /tmp/agent-device', + }, + { + id: 'device', + status: 'pass', + summary: 'Selected Pixel (android)', + }, + { + id: 'session', + status: 'info', + summary: 'No active session named default. Doctor will use the selected device.', + }, + ], + }), + ); + + expect(output.text).toBe( + [ + 'Doctor: pass', + '✓ agent-device: agent-device 0.17.9 using /tmp/agent-device', + '✓ device: Selected Pixel (android)', + '- session: No active session named default. Doctor will use the selected device.', + ].join('\n'), + ); + }); + + test('keeps warning and failure recovery details under the relevant row', () => { + const output = withNoColor(() => + doctorCliOutput({ + status: 'fail', + checks: [ + { + id: 'device', + status: 'fail', + summary: 'No devices found.', + command: 'agent-device devices', + }, + { + id: 'android-reverse', + status: 'warn', + summary: 'Android adb reverse is missing for Metro port 8081.', + command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + ], + }), + ); + + expect(output.text).toBe( + [ + 'Doctor: fail', + '⨯ device: No devices found.', + ' run: agent-device devices', + '! android-reverse: Android adb reverse is missing for Metro port 8081.', + ' run: adb -s emulator-5554 reverse tcp:8081 tcp:8081', + ].join('\n'), + ); + }); + + test('prints only the summary after streamed progress rendered the checks', () => { + const output = withNoColor(() => { + markDoctorProgressRendered(); + return doctorCliOutput({ + status: 'pass', + summary: 'No blockers found.', + checks: [ + { + id: 'device', + status: 'pass', + summary: 'Selected Pixel (android)', + }, + ], + }); + }); + + expect(output.text).toBe(['Doctor: pass', 'No blockers found.'].join('\n')); + }); +}); diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index ac1bf3cde..64f21f874 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -18,6 +18,11 @@ import type { } from '../../client-types.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; import type { CliOutput } from '../command-contract.ts'; +import { + consumeDoctorProgressRendered, + formatDoctorCheckDetailLines, + formatDoctorCheckSummaryLine, +} from '../../cli-doctor-output.ts'; import { messageCliOutput, messageOutput, @@ -101,10 +106,32 @@ function shutdownCliOutput(result: CommandRequestResult): CliOutput { return { data, text: `${status}: ${device} (${platform})` }; } +export function doctorCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const status = typeof data.status === 'string' ? data.status : 'unknown'; + const lines = [`Doctor: ${status}`]; + const checks = readDoctorChecks(data.checks); + + if (consumeDoctorProgressRendered()) { + const summary = typeof data.summary === 'string' ? data.summary : undefined; + if (summary) lines.push(summary); + } else if (checks.length === 0) { + const summary = typeof data.summary === 'string' ? data.summary : 'No blockers found.'; + lines.push(summary); + } else { + for (const check of checks) { + lines.push(formatDoctorCheckSummaryLine(check)); + lines.push(...formatDoctorCheckDetailLines(check)); + } + } + return { data, text: lines.join('\n') }; +} + export const managementCliOutputFormatters = { boot: resultOutput(bootCliOutput), shutdown: resultOutput(shutdownCliOutput), devices: resultOutput(devicesCliOutput), + doctor: resultOutput(doctorCliOutput), apps: ({ input, result }) => appsCliOutput({ result: result as Parameters[0]['result'], @@ -126,3 +153,12 @@ function formatDeviceLine(device: AgentDeviceDevice): string { const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; return `${device.name} (${device.platform}${kind}${target})${booted}`; } + +function readDoctorChecks(value: unknown): Array> { + return Array.isArray(value) + ? value.filter( + (check): check is Record => + Boolean(check) && typeof check === 'object' && !Array.isArray(check), + ) + : []; +} diff --git a/src/contracts.ts b/src/contracts.ts index c2a3a514c..e6b6069c1 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -77,7 +77,7 @@ export type DaemonRequestMeta = { materializationId?: string; lockPolicy?: DaemonLockPolicy; lockPlatform?: PlatformSelector; - requestProgress?: 'replay-test'; + requestProgress?: 'replay-test' | 'doctor'; }; export type DaemonRequest = { diff --git a/src/daemon-client-progress.ts b/src/daemon-client-progress.ts index 26a647027..bbef51251 100644 --- a/src/daemon-client-progress.ts +++ b/src/daemon-client-progress.ts @@ -8,13 +8,20 @@ import { createReplayTestProgressRenderer, type ReplayTestProgressRenderer, } from './cli-test-progress.ts'; +import { + createDoctorProgressRenderer, + type DoctorProgressRenderer, +} from './cli-doctor-progress.ts'; import { isDaemonProgressEnvelope, isDaemonResponseEnvelope, shouldStreamRequestProgress, } from './daemon/request-progress-protocol.ts'; -function createRequestProgressRenderer(req: DaemonRequest): ReplayTestProgressRenderer { +type RequestProgressRenderer = ReplayTestProgressRenderer | DoctorProgressRenderer; + +function createRequestProgressRenderer(req: DaemonRequest): RequestProgressRenderer { + if (req.meta?.requestProgress === 'doctor') return createDoctorProgressRenderer(); return createReplayTestProgressRenderer({ verbose: Boolean(req.flags?.verbose || req.meta?.debug), liveProgress: shouldRenderLiveProgress(), @@ -24,7 +31,7 @@ function createRequestProgressRenderer(req: DaemonRequest): ReplayTestProgressRe function writeRequestProgressEvent( event: RequestProgressEvent, - renderer: ReplayTestProgressRenderer, + renderer: RequestProgressRenderer, ): void { const output = renderer.render(event); if (!output) return; diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index 269e39a6b..660cee28c 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -40,6 +40,7 @@ test('daemon command registry owns specialized handler routes', () => { test('daemon command registry owns session handler subroutes', () => { assert.equal(getSessionCommandKind(INTERNAL_COMMANDS.sessionList), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.devices), 'inventory'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.doctor), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.apps), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.boot), 'state'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.shutdown), 'state'); @@ -53,6 +54,7 @@ test('daemon command registry preserves request admission traits', () => { for (const command of [ INTERNAL_COMMANDS.sessionList, PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.doctor, INTERNAL_COMMANDS.releaseMaterializedPaths, INTERNAL_COMMANDS.leaseAllocate, INTERNAL_COMMANDS.leaseHeartbeat, @@ -65,6 +67,7 @@ test('daemon command registry preserves request admission traits', () => { for (const command of [ INTERNAL_COMMANDS.sessionList, PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.doctor, INTERNAL_COMMANDS.releaseMaterializedPaths, ]) { assert.equal(shouldValidateSessionSelector(command), false, `${command} selector`); @@ -139,6 +142,7 @@ test('daemon command registry preserves Android modal and lock-policy traits', ( assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.get), false); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.apps), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.devices), true); + assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.doctor), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.open), false); }); @@ -152,6 +156,7 @@ test('daemon command registry preserves provider device resolution traits', () = false, ); assert.equal(usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.open)), true); + assert.equal(usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.doctor)), true); assert.equal( usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.record, ['start'])), true, diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index e535f797a..1f6de93be 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -64,6 +64,12 @@ const DAEMON_COMMAND_DESCRIPTORS = [ lockPolicySelectorOverride: true, ...REQUEST_EXECUTION_EXEMPT, }), + descriptor(PUBLIC_COMMANDS.doctor, 'session', { + sessionKind: 'inventory', + lockPolicySelectorOverride: true, + allowSessionlessDefaultDevice: () => true, + ...REQUEST_EXECUTION_EXEMPT, + }), descriptor(PUBLIC_COMMANDS.apps, 'session', { sessionKind: 'inventory', lockPolicySelectorOverride: true, diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts new file mode 100644 index 000000000..679088a7f --- /dev/null +++ b/src/daemon/handlers/session-doctor.ts @@ -0,0 +1,525 @@ +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import { analyzeReactNativeOverlay } from '../../core/react-native-overlay.ts'; +import { getAndroidAppState, resolveAndroidApp } from '../../platforms/android/app-lifecycle.ts'; +import { + resolveAndroidAdbExecutor, + type AndroidAdbExecutor, +} from '../../platforms/android/adb-executor.ts'; +import { resolveIosApp } from '../../platforms/ios/apps.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { readVersion } from '../../utils/version.ts'; +import { normalizeError } from '../../utils/errors.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import { SessionStore } from '../session-store.ts'; +import { emitRequestProgress } from '../request-progress.ts'; +import { resolveCommandDevice } from './session-device-utils.ts'; + +type DoctorStatus = 'pass' | 'warn' | 'fail' | 'info'; +type DoctorKind = 'auto' | 'react-native' | 'expo'; + +type DoctorCheck = { + id: string; + status: DoctorStatus; + summary: string; + hint?: string; + command?: string; + evidence?: Record; +}; + +const DEFAULT_METRO_HOST = '127.0.0.1'; +const DEFAULT_METRO_PORT = 8081; +const METRO_PROBE_TIMEOUT_MS = 1500; +const ANDROID_PROBE_TIMEOUT_MS = 2000; +const ANDROID_LAUNCHER_PACKAGES = new Set([ + 'com.android.launcher', + 'com.android.launcher3', + 'com.google.android.apps.nexuslauncher', +]); + +export async function handleDoctorCommand(params: { + req: DaemonRequest; + sessionName: string; + sessionStore: SessionStore; + androidAdbExecutor?: AndroidAdbExecutor; +}): Promise { + const { req, sessionName, sessionStore, androidAdbExecutor } = params; + if (req.command !== PUBLIC_COMMANDS.doctor) return null; + + const session = sessionStore.get(sessionName); + const options = readDoctorOptions(req, session); + const checks: DoctorCheck[] = []; + appendDoctorChecks( + checks, + { + id: 'agent-device', + status: 'pass', + summary: `agent-device ${readVersion()} using ${sessionStore.resolveStateDir()}`, + evidence: { version: readVersion(), stateDir: sessionStore.resolveStateDir() }, + }, + ...sessionChecks(sessionStore, sessionName, session), + ); + + const device = await appendDeviceCheck(checks, req, session); + if (device) { + appendDoctorCheck(checks, deviceReadinessCheck(device)); + appendDoctorChecks(checks, ...platformScopeChecks(device, options)); + await appendAppChecks(checks, { device, session, targetApp: options.targetApp }); + await appendAndroidChecks(checks, { + device, + session, + targetApp: options.targetApp, + metroPort: options.metroPort, + androidAdbExecutor, + }); + appendReactNativeOverlayCheck(checks, session, options); + } + if (options.shouldProbeMetro) { + appendDoctorCheck(checks, await probeMetro(options.metroHost, options.metroPort, options.kind)); + } + + const status = summarizeDoctorStatus(checks); + return { + ok: true, + data: { + status, + summary: doctorSummary(status), + kind: options.kind, + platform: device?.platform, + target: device?.target ?? 'mobile', + targetApp: options.targetApp, + metro: options.shouldProbeMetro + ? { host: options.metroHost, port: options.metroPort } + : undefined, + checks: sortChecks(checks), + }, + }; +} + +function readDoctorOptions( + req: DaemonRequest, + session: SessionState | undefined, +): { + targetApp?: string; + metroHost: string; + metroPort: number; + kind: DoctorKind; + shouldProbeMetro: boolean; +} { + const rawKind = req.flags?.kind; + const kind: DoctorKind = rawKind === 'expo' || rawKind === 'react-native' ? rawKind : 'auto'; + const targetApp = readNonEmptyString(req.flags?.targetApp) ?? session?.appBundleId; + const metroHost = readNonEmptyString(req.flags?.metroHost) ?? DEFAULT_METRO_HOST; + const metroPort = readPositivePort(req.flags?.metroPort) ?? DEFAULT_METRO_PORT; + return { + targetApp, + metroHost, + metroPort, + kind, + shouldProbeMetro: + kind === 'react-native' || + kind === 'expo' || + typeof req.flags?.metroPort === 'number' || + typeof req.flags?.metroHost === 'string', + }; +} + +function sessionChecks( + sessionStore: SessionStore, + sessionName: string, + session: SessionState | undefined, +): DoctorCheck[] { + const sameDeviceSessions = session + ? sessionStore + .toArray() + .filter( + (candidate) => + candidate.name !== session.name && + candidate.device.platform === session.device.platform && + candidate.device.id === session.device.id, + ) + .map((candidate) => candidate.name) + : []; + + if (!session) { + return [ + { + id: 'session', + status: 'info', + summary: `No active session named ${sessionName}. Doctor will use the selected device.`, + hint: 'This is expected before a run. Use open when app foreground state matters.', + }, + ]; + } + + return [ + { + id: 'session', + status: sameDeviceSessions.length > 0 ? 'warn' : 'pass', + summary: + sameDeviceSessions.length > 0 + ? `Other active sessions target the same device: ${sameDeviceSessions.join(', ')}` + : `Active session ${session.name} targets ${session.device.name}`, + hint: + sameDeviceSessions.length > 0 + ? 'Close stale sessions before a QA run if they belong to old attempts.' + : undefined, + command: + sameDeviceSessions.length > 0 + ? `agent-device close --session ${sameDeviceSessions[0]} --platform ${session.device.platform}` + : undefined, + evidence: { + session: session.name, + sameDeviceSessions, + sessionStateDir: sessionStore.resolveSessionDir(session.name), + }, + }, + ]; +} + +async function appendDeviceCheck( + checks: DoctorCheck[], + req: DaemonRequest, + session: SessionState | undefined, +): Promise { + try { + const device = await resolveCommandDevice({ session, flags: req.flags, ensureReady: false }); + appendDoctorCheck(checks, { + id: 'device', + status: 'pass', + summary: `Selected ${device.name} (${device.platform}${device.target ? `/${device.target}` : ''})`, + evidence: { + id: device.id, + name: device.name, + platform: device.platform, + kind: device.kind, + target: device.target ?? 'mobile', + booted: device.booted, + }, + }); + return device; + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'device', + status: 'fail', + summary: normalized.message, + hint: normalized.hint, + command: 'agent-device devices', + evidence: { code: normalized.code, details: normalized.details }, + }); + return undefined; + } +} + +function deviceReadinessCheck(device: DeviceInfo): DoctorCheck { + if (device.booted === false) { + return { + id: 'device-readiness', + status: 'fail', + summary: `${device.name} is present but not booted.`, + command: `agent-device boot --platform ${device.platform}`, + evidence: { booted: false }, + }; + } + return { + id: 'device-readiness', + status: 'pass', + summary: + device.booted === true + ? `${device.name} is booted.` + : `${device.name} readiness is selected; boot state is not reported for this target.`, + evidence: { booted: device.booted }, + }; +} + +function platformScopeChecks( + device: DeviceInfo, + options: ReturnType, +): DoctorCheck[] { + if ( + (options.kind === 'react-native' || options.kind === 'expo') && + device.platform !== 'ios' && + device.platform !== 'android' + ) { + return [ + { + id: 'platform-scope', + status: 'info', + summary: `${options.kind} checks are app-mobile focused; ${device.platform} doctor covers device/session readiness only.`, + }, + ]; + } + if (device.platform === 'android' && options.kind !== 'auto') { + return [ + { + id: 'android-routing', + status: 'info', + summary: + 'Android URL opens can use host localhost automatically; package launches may still need adb reverse.', + command: `adb -s ${device.id} reverse tcp:${options.metroPort} tcp:${options.metroPort}`, + }, + ]; + } + return []; +} + +async function appendAppChecks( + checks: DoctorCheck[], + params: { device: DeviceInfo; session: SessionState | undefined; targetApp?: string }, +): Promise { + const { device, targetApp, session } = params; + if (!targetApp) { + appendDoctorCheck(checks, { + id: 'target-app', + status: 'info', + summary: 'No --target-app provided; app install/discovery check skipped.', + hint: 'Pass --target-app with the package or bundle expected for the run.', + }); + return; + } + + try { + const resolved = + device.platform === 'android' + ? (await resolveAndroidApp(device, targetApp)).value + : device.platform === 'ios' || device.platform === 'macos' + ? await resolveIosApp(device, targetApp) + : targetApp; + appendDoctorCheck(checks, { + id: 'target-app', + status: 'pass', + summary: `Target app is discoverable: ${resolved}`, + evidence: { requested: targetApp, resolved, sessionApp: session?.appBundleId }, + }); + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'target-app', + status: 'fail', + summary: `Target app is not discoverable: ${targetApp}`, + hint: normalized.hint ?? 'Install the app or pass the exact package/bundle id.', + command: `agent-device apps --platform ${device.platform}`, + evidence: { code: normalized.code, message: normalized.message }, + }); + } +} + +async function appendAndroidChecks( + checks: DoctorCheck[], + params: { + device: DeviceInfo; + session: SessionState | undefined; + targetApp?: string; + metroPort: number; + androidAdbExecutor?: AndroidAdbExecutor; + }, +): Promise { + const { device, session, targetApp, metroPort, androidAdbExecutor } = params; + if (device.platform !== 'android') return; + const adb = resolveAndroidAdbExecutor(device, androidAdbExecutor); + + try { + const state = await getAndroidAppState(device); + const foregroundPackage = state.package; + const expectedPackage = targetApp ?? session?.appBundleId; + const foregroundMatches = expectedPackage && foregroundPackage === expectedPackage; + const onLauncher = foregroundPackage ? ANDROID_LAUNCHER_PACKAGES.has(foregroundPackage) : false; + appendDoctorCheck(checks, { + id: 'android-foreground', + status: onLauncher || (expectedPackage && !foregroundMatches) ? 'warn' : 'pass', + summary: onLauncher + ? 'Android is on the launcher, not the target app.' + : expectedPackage && !foregroundMatches + ? `Android foreground package is ${foregroundPackage ?? 'unknown'}, expected ${expectedPackage}.` + : `Android foreground package is ${foregroundPackage ?? 'unknown'}.`, + command: + onLauncher || (expectedPackage && !foregroundMatches && expectedPackage) + ? `agent-device open ${expectedPackage} --platform android` + : undefined, + evidence: state as Record, + }); + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'android-foreground', + status: 'warn', + summary: 'Could not read Android foreground package.', + hint: normalized.message, + evidence: { code: normalized.code }, + }); + } + + appendDoctorCheck(checks, await probeAndroidReverse(adb, device.id, metroPort)); + appendDoctorCheck(checks, await probeAndroidAnimations(adb)); +} + +async function probeAndroidReverse( + adb: AndroidAdbExecutor, + serial: string, + metroPort: number, +): Promise { + try { + const result = await adb(['reverse', '--list'], { + allowFailure: true, + timeoutMs: ANDROID_PROBE_TIMEOUT_MS, + }); + const expected = `tcp:${metroPort} tcp:${metroPort}`; + const hasReverse = result.stdout.includes(expected); + return { + id: 'android-reverse', + status: hasReverse ? 'pass' : 'warn', + summary: hasReverse + ? `Android adb reverse exists for Metro port ${metroPort}.` + : `Android adb reverse is missing for Metro port ${metroPort}.`, + command: hasReverse + ? undefined + : `adb -s ${serial} reverse tcp:${metroPort} tcp:${metroPort}`, + evidence: { stdout: result.stdout.trim() }, + }; + } catch (error) { + const normalized = normalizeError(error); + return { + id: 'android-reverse', + status: 'warn', + summary: 'Could not inspect Android adb reverse mappings.', + hint: normalized.message, + evidence: { code: normalized.code }, + }; + } +} + +async function probeAndroidAnimations(adb: AndroidAdbExecutor): Promise { + const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']; + try { + const values: Record = {}; + for (const key of keys) { + const result = await adb(['shell', 'settings', 'get', 'global', key], { + allowFailure: true, + timeoutMs: ANDROID_PROBE_TIMEOUT_MS, + }); + values[key] = result.stdout.trim(); + } + const enabled = Object.values(values).some((value) => value !== '0' && value !== '0.0'); + return { + id: 'android-animations', + status: enabled ? 'warn' : 'pass', + summary: enabled + ? 'Android animations are enabled and can slow or flake automation.' + : 'Android animations are disabled.', + hint: enabled ? 'Disable animations in emulator settings before long QA runs.' : undefined, + evidence: values, + }; + } catch (error) { + const normalized = normalizeError(error); + return { + id: 'android-animations', + status: 'warn', + summary: 'Could not read Android animation settings.', + hint: normalized.message, + evidence: { code: normalized.code }, + }; + } +} + +function appendReactNativeOverlayCheck( + checks: DoctorCheck[], + session: SessionState | undefined, + options: ReturnType, +): void { + if (options.kind === 'auto' && !session?.snapshot) return; + if (!session?.snapshot) { + appendDoctorCheck(checks, { + id: 'rn-overlay', + status: 'info', + summary: 'No current session snapshot; React Native overlay check skipped.', + command: 'agent-device snapshot -i', + }); + return; + } + const overlay = analyzeReactNativeOverlay(session.snapshot.nodes); + appendDoctorCheck(checks, { + id: 'rn-overlay', + status: overlay.detected ? 'warn' : 'pass', + summary: overlay.detected + ? `React Native ${overlay.redBox ? 'RedBox' : 'LogBox'} overlay appears in the current snapshot.` + : 'No React Native overlay detected in the current snapshot.', + command: overlay.detected ? 'agent-device react-native dismiss-overlay' : undefined, + evidence: { + redBox: overlay.redBox, + dismissTargets: overlay.dismissNodes.length + overlay.collapsedNodes.length, + }, + }); +} + +async function probeMetro(host: string, port: number, kind: DoctorKind): Promise { + const url = `http://${host}:${port}/status`; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(METRO_PROBE_TIMEOUT_MS) }); + const text = await response.text(); + const running = response.ok && text.toLowerCase().includes('packager-status:running'); + return { + id: 'metro', + status: running ? 'pass' : 'warn', + summary: running + ? `Metro is reachable at ${url}.` + : `Metro responded at ${url}, but did not report packager-status:running.`, + hint: running + ? undefined + : 'Verify this is the Metro instance for the target app, or restart Metro.', + evidence: { url, statusCode: response.status, body: text.slice(0, 120), kind }, + }; + } catch (error) { + return { + id: 'metro', + status: kind === 'auto' ? 'warn' : 'fail', + summary: `Metro is not reachable at ${url}.`, + hint: 'Start Metro, pass the correct --metro-host/--metro-port, or use a remote Metro profile.', + command: `curl -fsS ${url}`, + evidence: { url, error: error instanceof Error ? error.message : String(error), kind }, + }; + } +} + +function summarizeDoctorStatus(checks: DoctorCheck[]): 'pass' | 'warn' | 'fail' { + if (checks.some((check) => check.status === 'fail')) return 'fail'; + if (checks.some((check) => check.status === 'warn')) return 'warn'; + return 'pass'; +} + +function doctorSummary(status: 'pass' | 'warn' | 'fail'): string { + if (status === 'fail') return 'Blockers found before the run.'; + if (status === 'warn') return 'No hard blockers found, but warnings need attention.'; + return 'No blockers found.'; +} + +function sortChecks(checks: DoctorCheck[]): DoctorCheck[] { + const order: Record = { fail: 0, warn: 1, pass: 2, info: 3 }; + return [...checks].sort((a, b) => order[a.status] - order[b.status]); +} + +function appendDoctorChecks(checks: DoctorCheck[], ...items: DoctorCheck[]): void { + for (const check of items) { + appendDoctorCheck(checks, check); + } +} + +function appendDoctorCheck(checks: DoctorCheck[], check: DoctorCheck): void { + checks.push(check); + emitRequestProgress({ + type: 'doctor-check', + id: check.id, + status: check.status, + summary: check.summary, + index: checks.length, + hint: check.hint, + command: check.command, + }); +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function readPositivePort(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +} diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 9d30f903e..53c258ad5 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -35,6 +35,7 @@ import { handleSessionInventoryCommands } from './session-inventory.ts'; import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; +import { handleDoctorCommand } from './session-doctor.ts'; import { getSessionCommandKind } from '../daemon-command-registry.ts'; const PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS = 45_000; @@ -261,6 +262,15 @@ export async function handleSessionCommands(params: { androidAdbExecutor, } = params; + if (req.command === PUBLIC_COMMANDS.doctor) { + return await handleDoctorCommand({ + req, + sessionName, + sessionStore, + androidAdbExecutor, + }); + } + if (getSessionCommandKind(req.command) === 'inventory') { return await handleSessionInventoryCommands({ req, diff --git a/src/daemon/request-progress-protocol.ts b/src/daemon/request-progress-protocol.ts index cf580da78..592bddacd 100644 --- a/src/daemon/request-progress-protocol.ts +++ b/src/daemon/request-progress-protocol.ts @@ -12,7 +12,7 @@ export type DaemonResponseEnvelope = { }; export function shouldStreamRequestProgress(req: Pick): boolean { - return req.meta?.requestProgress === 'replay-test'; + return req.meta?.requestProgress === 'replay-test' || req.meta?.requestProgress === 'doctor'; } export function isDaemonProgressEnvelope(value: unknown): value is DaemonProgressEnvelope { diff --git a/src/daemon/request-progress.ts b/src/daemon/request-progress.ts index 90c950eec..5d0729daf 100644 --- a/src/daemon/request-progress.ts +++ b/src/daemon/request-progress.ts @@ -32,7 +32,20 @@ export type ReplayTestProgressEvent = { deviceId?: string; }; -export type RequestProgressEvent = ReplayTestSuiteProgressEvent | ReplayTestProgressEvent; +export type DoctorCheckProgressEvent = { + type: 'doctor-check'; + id: string; + status: 'pass' | 'warn' | 'fail' | 'info'; + summary: string; + index: number; + hint?: string; + command?: string; +}; + +export type RequestProgressEvent = + | ReplayTestSuiteProgressEvent + | ReplayTestProgressEvent + | DoctorCheckProgressEvent; export type RequestProgressSink = (event: RequestProgressEvent) => void; export type ReplayTestActionProgressContext = Omit< ReplayTestProgressEvent, diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index d74ae703e..09bbb13d0 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -75,6 +75,12 @@ export class SessionStore { return path.join(this.sessionsDir, safeSessionName(sessionName)); } + resolveStateDir(): string { + return path.basename(this.sessionsDir) === 'sessions' + ? path.dirname(this.sessionsDir) + : this.sessionsDir; + } + ensureSessionDir(sessionName: string): string { const sessionDir = this.resolveSessionDir(sessionName); fs.mkdirSync(sessionDir, { recursive: true }); diff --git a/src/platforms/ios/__tests__/devices.test.ts b/src/platforms/ios/__tests__/devices.test.ts index dabe3ad21..aa76bfebc 100644 --- a/src/platforms/ios/__tests__/devices.test.ts +++ b/src/platforms/ios/__tests__/devices.test.ts @@ -83,6 +83,82 @@ test('apple product type helpers classify iOS and tvOS product families', () => assert.equal(isAppleTvProductType('iPhone16,2'), false); }); +test('listAppleDevices orders simulators by iPhone, iPad, tvOS, then physical devices', async () => { + mockRunCommand = async (_cmd, args) => { + if (args.join(' ') === 'simctl list devices -j') { + return { + stdout: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ + { + name: 'Apple TV 4K (3rd generation)', + udid: 'tvos-sim', + state: 'Shutdown', + isAvailable: true, + }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + name: 'iPad Pro 13-inch', + udid: 'ipad-sim', + state: 'Shutdown', + isAvailable: true, + }, + { + name: 'iPhone 16', + udid: 'iphone-sim', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + stderr: '', + exitCode: 0, + }; + } + + if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') { + const jsonPath = String(args[4]); + await fs.writeFile( + jsonPath, + JSON.stringify({ + result: { + devices: [ + { + name: 'My iPhone', + hardwareProperties: { + platform: 'iOS', + udid: 'physical-iphone', + productType: 'iPhone16,2', + }, + }, + ], + }, + }), + 'utf8', + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + + if (args.join(' ') === 'xctrace list devices') { + return { stdout: '== Devices ==', stderr: '', exitCode: 0 }; + } + + throw new Error(`unexpected xcrun args: ${args.join(' ')}`); + }; + + const devices = await withMockedPlatform( + 'darwin', + async () => await withMockedAppleTools(async () => await listAppleDevices()), + ); + + assert.deepEqual( + devices.slice(0, 4).map((device) => device.id), + ['iphone-sim', 'ipad-sim', 'tvos-sim', 'physical-iphone'], + ); +}); + test('parseXctracePhysicalAppleDevices parses only physical devices from the Devices section', () => { const parsed = parseXctracePhysicalAppleDevices( [ diff --git a/src/platforms/ios/devices.ts b/src/platforms/ios/devices.ts index e890abba8..a38632b0a 100644 --- a/src/platforms/ios/devices.ts +++ b/src/platforms/ios/devices.ts @@ -2,7 +2,11 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { AppError } from '../../utils/errors.ts'; -import type { DeviceInfo, DeviceTarget } from '../../utils/device.ts'; +import { + sortAppleDevicesForSelection, + type DeviceInfo, + type DeviceTarget, +} from '../../utils/device.ts'; import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts'; import { buildHostMacDevice } from '../macos/devices.ts'; import { buildSimctlArgs } from './simctl.ts'; @@ -157,23 +161,10 @@ export async function findBootableIosSimulator( return null; } - const simulators = parseSimctlAppleDevices(payload, simulatorSetPath); - let bestBooted: DeviceInfo | null = null; - let bestMobile: DeviceInfo | null = null; - let bestAny: DeviceInfo | null = null; - - for (const simulator of simulators) { - if (targetFilter && simulator.target !== targetFilter) continue; - if (simulator.booted) { - bestBooted = bestBooted ?? simulator; - } - if (simulator.target === 'mobile') { - bestMobile = bestMobile ?? simulator; - } - bestAny = bestAny ?? simulator; - } - - return bestBooted ?? bestMobile ?? bestAny; + const simulators = sortAppleDevicesForSelection( + parseSimctlAppleDevices(payload, simulatorSetPath), + ); + return simulators.find((simulator) => !targetFilter || simulator.target === targetFilter) ?? null; } function parseSimctlAppleDevices( @@ -348,7 +339,7 @@ export async function listAppleDevices( // Do not enumerate host-global physical devices, but keep the local Mac available // because desktop targeting is independent of simulator sets. if (simulatorSetPath) { - return devices; + return sortAppleDevicesForSelection(devices); } const [devicectlDevices, xctraceDevices] = await Promise.all([ @@ -357,5 +348,5 @@ export async function listAppleDevices( ]); devices = mergeAppleDevices(devices, devicectlDevices); - return mergeAppleDevices(devices, xctraceDevices); + return sortAppleDevicesForSelection(mergeAppleDevices(devices, xctraceDevices)); } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index e3a90cac7..47c090fbb 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -92,6 +92,48 @@ test('parseArgs recognizes command-specific flag combinations', async () => { assert.equal(parsed.flags.platform, 'ios'); }, }, + { + label: 'doctor android react native', + argv: [ + 'doctor', + '--platform', + 'android', + '--target-app', + 'com.example.app', + '--metro-port', + '8081', + '--react-native', + ], + strictFlags: true, + assertParsed: (parsed) => { + assert.equal(parsed.command, 'doctor'); + assert.equal(parsed.flags.platform, 'android'); + assert.equal(parsed.flags.targetApp, 'com.example.app'); + assert.equal(parsed.flags.metroPort, 8081); + assert.equal(parsed.flags.doctorReactNative, true); + }, + }, + { + label: 'doctor ios expo', + argv: [ + 'doctor', + '--platform', + 'ios', + '--target-app', + 'com.example.app', + '--metro-port', + '8081', + '--expo', + ], + strictFlags: true, + assertParsed: (parsed) => { + assert.equal(parsed.command, 'doctor'); + assert.equal(parsed.flags.platform, 'ios'); + assert.equal(parsed.flags.targetApp, 'com.example.app'); + assert.equal(parsed.flags.metroPort, 8081); + assert.equal(parsed.flags.doctorExpo, true); + }, + }, { label: 'open --platform apple alias', argv: ['open', 'Settings', '--platform', 'apple', '--target', 'tv'], @@ -1663,6 +1705,14 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help debugging/); assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); + assert.match( + help, + /agent-device doctor --platform android --target-app com\.example\.app --metro-port 8081 --react-native/, + ); + assert.match( + help, + /agent-device doctor --platform ios --target-app com\.example\.app --metro-port 8081 --expo/, + ); assert.match(help, /same host context that owns Metro/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); diff --git a/src/utils/__tests__/device.test.ts b/src/utils/__tests__/device.test.ts index 0426e52c8..84ac4e817 100644 --- a/src/utils/__tests__/device.test.ts +++ b/src/utils/__tests__/device.test.ts @@ -137,6 +137,52 @@ test('resolveDevice prefers booted simulator over physical device', async () => assert.equal(result.id, 'sim-1'); }); +test('resolveDevice keeps Apple simulator family priority ahead of boot state', async () => { + const tvSimulator: DeviceInfo = { + platform: 'ios', + id: 'tv-sim', + name: 'Apple TV 4K', + kind: 'simulator', + target: 'tv', + booted: true, + }; + const iphoneSimulator: DeviceInfo = { + platform: 'ios', + id: 'iphone-sim', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: false, + }; + + const result = await resolveDevice([tvSimulator, iphoneSimulator], { platform: 'ios' }); + + assert.equal(result.id, 'iphone-sim'); +}); + +test('resolveDevice prefers booted Apple simulator within the same family', async () => { + const shutdownIphone: DeviceInfo = { + platform: 'ios', + id: 'iphone-shutdown', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: false, + }; + const bootedIphone: DeviceInfo = { + platform: 'ios', + id: 'iphone-booted', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + + const result = await resolveDevice([shutdownIphone, bootedIphone], { platform: 'ios' }); + + assert.equal(result.id, 'iphone-booted'); +}); + test('resolveDevice returns physical device when explicitly selected by deviceName', async () => { const physical: DeviceInfo = { platform: 'ios', diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 6a1f99703..ddd078b4b 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -58,6 +58,9 @@ export type CliFlags = RemoteConfigMetroOptions & iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; + targetApp?: string; + doctorReactNative?: boolean; + doctorExpo?: boolean; session?: string; metroHost?: string; metroPort?: number; @@ -576,6 +579,27 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--android-device-allowlist ', usageDescription: 'Comma/space separated Android serial allowlist for discovery/selection', }, + { + key: 'targetApp', + names: ['--target-app'], + type: 'string', + usageLabel: '--target-app ', + usageDescription: 'Doctor: app bundle id, package name, or app name expected for the run', + }, + { + key: 'doctorReactNative', + names: ['--react-native'], + type: 'boolean', + usageLabel: '--react-native', + usageDescription: 'Doctor: include React Native-specific preflight checks', + }, + { + key: 'doctorExpo', + names: ['--expo'], + type: 'boolean', + usageLabel: '--expo', + usageDescription: 'Doctor: include Expo/Metro-specific preflight checks', + }, { key: 'activity', names: ['--activity'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 0f70280b0..ccba0d073 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -531,6 +531,9 @@ Choose the next help topic: Remote/cloud config, leases, and local service tunnels: help remote. React Native dev loop: + Before QA/dogfood runs, use doctor to separate environment setup from app failures: + agent-device doctor --platform android --target-app com.example.app --metro-port 8081 --react-native + agent-device doctor --platform ios --target-app com.example.app --metro-port 8081 --expo For "start from screen X" flows, prefer open --relaunch before the first snapshot so the app does not reuse a prior in-progress navigation state. JS-only change with Metro connected: agent-device metro reload diff --git a/src/utils/device.ts b/src/utils/device.ts index 223121134..a03bd5c60 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -76,6 +76,15 @@ export function resolveAppleSimulatorSetPathForSelector(params: { return simulatorSetPath; } +export function sortAppleDevicesForSelection( + devices: TDevice[], +): TDevice[] { + return devices + .map((device, index) => ({ device, index })) + .sort((left, right) => compareAppleDevicesForSelection(left, right)) + .map(({ device }) => device); +} + function supportsAppleSimulatorSelection(platform: PlatformSelector | undefined): boolean { return !platform || platform === 'apple' || platform === 'ios'; } @@ -95,6 +104,9 @@ export async function resolveDevice( if (selector.target) { candidates = candidates.filter((d) => (d.target ?? 'mobile') === selector.target); } + if (isAppleDeviceCandidateSet(candidates)) { + candidates = sortAppleDevicesForSelection(candidates); + } if (selector.udid) { const match = candidates.find((d) => d.id === selector.udid && isApplePlatform(d.platform)); @@ -142,15 +154,54 @@ export async function resolveDevice( candidates = virtual; } - const booted = candidates.filter((d) => d.booted); - const onlyBooted = booted[0]; - if (onlyBooted !== undefined && booted.length === 1) return onlyBooted; + if (!isAppleDeviceCandidateSet(candidates)) { + const booted = candidates.filter((d) => d.booted); + const onlyBooted = booted[0]; + if (onlyBooted !== undefined && booted.length === 1) return onlyBooted; + } - // When multiple candidates remain equally valid, preserve discovery order from - // the underlying platform tools rather than introducing another tie-breaker here. - const selected = booted[0] ?? candidates[0]; + // Apple candidates are pre-sorted by agent-friendly default priority. Other + // platforms preserve discovery order except for the existing booted-device preference. + const selected = isAppleDeviceCandidateSet(candidates) + ? candidates[0] + : (candidates.find((d) => d.booted) ?? candidates[0]); if (selected === undefined) { throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector }); } return selected; } + +function compareAppleDevicesForSelection( + left: { device: TDevice; index: number }, + right: { device: TDevice; index: number }, +): number { + return ( + appleDeviceSelectionRank(left.device) - appleDeviceSelectionRank(right.device) || + Number(right.device.booted === true) - Number(left.device.booted === true) || + left.device.name.localeCompare(right.device.name) || + left.index - right.index + ); +} + +function appleDeviceSelectionRank(device: DeviceInfo): number { + const target = device.target ?? 'mobile'; + if (device.kind === 'simulator') { + if (target === 'mobile') return isIpadDeviceName(device.name) ? 1 : 0; + if (target === 'tv') return 2; + return 3; + } + if (device.kind === 'device' && device.platform === 'ios') { + if (target === 'mobile') return isIpadDeviceName(device.name) ? 11 : 10; + if (target === 'tv') return 12; + return 13; + } + return 14; +} + +function isAppleDeviceCandidateSet(devices: DeviceInfo[]): boolean { + return devices.length > 0 && devices.every((device) => isApplePlatform(device.platform)); +} + +function isIpadDeviceName(name: string): boolean { + return /\bipad\b/i.test(name); +} diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts new file mode 100644 index 000000000..03320d26e --- /dev/null +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -0,0 +1,146 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { test } from 'vitest'; +import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; +import { assertRpcOk } from './assertions.ts'; +import { + PROVIDER_SCENARIO_ANDROID, + PROVIDER_SCENARIO_IOS_SIMULATOR, + PROVIDER_SCENARIO_LINUX, + PROVIDER_SCENARIO_MACOS, + PROVIDER_SCENARIO_WEB, +} from './fixtures.ts'; +import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; + +test('Provider-backed integration doctor reports Android RN/Metro readiness through daemon route', async () => { + const server = await startMetroStatusServer(); + const adbCalls: string[][] = []; + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push([...args]); + return androidDoctorAdbResult(args, server.port); + }, + }; + + try { + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', [], { + platform: 'android', + targetApp: 'com.example.app', + kind: 'react-native', + metroPort: server.port, + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'pass'); + assertDoctorCheck(data, 'metro', 'pass'); + assertDoctorCheck(data, 'android-reverse', 'pass'); + assertDoctorCheck(data, 'android-animations', 'pass'); + assert.ok( + adbCalls.some((args) => args.join(' ') === 'reverse --list'), + JSON.stringify(adbCalls), + ); + }, + ); + } finally { + await server.close(); + } +}); + +test('Provider-backed integration doctor runs predictably for supported platform selectors', async () => { + const devices = [ + PROVIDER_SCENARIO_ANDROID, + PROVIDER_SCENARIO_IOS_SIMULATOR, + PROVIDER_SCENARIO_MACOS, + PROVIDER_SCENARIO_LINUX, + PROVIDER_SCENARIO_WEB, + ]; + const adbProvider: AndroidAdbProvider = { + exec: async (args) => androidDoctorAdbResult(args, 8081), + }; + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => devices, + }), + async (daemon) => { + for (const device of devices) { + const response = await daemon.callCommand('doctor', [], { + platform: device.platform, + kind: device.platform === 'ios' || device.platform === 'android' ? 'auto' : 'expo', + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.platform, device.platform); + assert.ok(Array.isArray(data.checks), `${device.platform} checks`); + if (device.platform !== 'ios' && device.platform !== 'android') { + assertDoctorCheck(data, 'platform-scope', 'info'); + } + } + }, + ); +}); + +function assertDoctorCheck( + data: { checks: Array<{ id: string; status: string }> }, + id: string, + status: string, +): void { + const check = data.checks.find((entry) => entry.id === id); + assert.ok(check, `missing ${id}: ${JSON.stringify(data.checks)}`); + assert.equal(check.status, status); +} + +function androidDoctorAdbResult( + args: string[], + metroPort: number, +): { + stdout: string; + stderr: string; + exitCode: number; +} { + const command = args.join(' '); + if (command === 'shell dumpsys window windows') { + return { + stdout: 'mCurrentFocus=Window{123 u0 com.example.app/.MainActivity}\n', + stderr: '', + exitCode: 0, + }; + } + if (command === 'reverse --list') { + return { + stdout: `emulator-5554 tcp:${metroPort} tcp:${metroPort}\n`, + stderr: '', + exitCode: 0, + }; + } + if (command.startsWith('shell settings get global ')) { + return { stdout: '0\n', stderr: '', exitCode: 0 }; + } + return { stdout: '', stderr: '', exitCode: 0 }; +} + +async function startMetroStatusServer(): Promise<{ port: number; close: () => Promise }> { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('packager-status:running'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + port: address.port, + close: async () => + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} From cd95d71af8c2dff52a51b6c0d08a917b8a69bc95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 25 Jun 2026 21:51:26 +0200 Subject: [PATCH 02/12] fix: reduce doctor command complexity --- src/daemon/handlers/session-doctor.ts | 152 ++++++++++++++-------- src/utils/device.ts | 173 +++++++++++++++----------- 2 files changed, 197 insertions(+), 128 deletions(-) diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index 679088a7f..ab3b23e58 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -1,6 +1,10 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import { analyzeReactNativeOverlay } from '../../core/react-native-overlay.ts'; -import { getAndroidAppState, resolveAndroidApp } from '../../platforms/android/app-lifecycle.ts'; +import { + getAndroidAppState, + resolveAndroidApp, + type AndroidForegroundApp, +} from '../../platforms/android/app-lifecycle.ts'; import { resolveAndroidAdbExecutor, type AndroidAdbExecutor, @@ -16,6 +20,13 @@ import { resolveCommandDevice } from './session-device-utils.ts'; type DoctorStatus = 'pass' | 'warn' | 'fail' | 'info'; type DoctorKind = 'auto' | 'react-native' | 'expo'; +type DoctorOptions = { + targetApp?: string; + metroHost: string; + metroPort: number; + kind: DoctorKind; + shouldProbeMetro: boolean; +}; type DoctorCheck = { id: string; @@ -95,18 +106,8 @@ export async function handleDoctorCommand(params: { }; } -function readDoctorOptions( - req: DaemonRequest, - session: SessionState | undefined, -): { - targetApp?: string; - metroHost: string; - metroPort: number; - kind: DoctorKind; - shouldProbeMetro: boolean; -} { - const rawKind = req.flags?.kind; - const kind: DoctorKind = rawKind === 'expo' || rawKind === 'react-native' ? rawKind : 'auto'; +function readDoctorOptions(req: DaemonRequest, session: SessionState | undefined): DoctorOptions { + const kind = readDoctorKind(req.flags?.kind); const targetApp = readNonEmptyString(req.flags?.targetApp) ?? session?.appBundleId; const metroHost = readNonEmptyString(req.flags?.metroHost) ?? DEFAULT_METRO_HOST; const metroPort = readPositivePort(req.flags?.metroPort) ?? DEFAULT_METRO_PORT; @@ -115,14 +116,20 @@ function readDoctorOptions( metroHost, metroPort, kind, - shouldProbeMetro: - kind === 'react-native' || - kind === 'expo' || - typeof req.flags?.metroPort === 'number' || - typeof req.flags?.metroHost === 'string', + shouldProbeMetro: shouldProbeMetro(req.flags, kind), }; } +function readDoctorKind(value: unknown): DoctorKind { + return value === 'expo' || value === 'react-native' ? value : 'auto'; +} + +function shouldProbeMetro(flags: DaemonRequest['flags'], kind: DoctorKind): boolean { + return ( + kind !== 'auto' || typeof flags?.metroPort === 'number' || typeof flags?.metroHost === 'string' + ); +} + function sessionChecks( sessionStore: SessionStore, sessionName: string, @@ -232,10 +239,7 @@ function deviceReadinessCheck(device: DeviceInfo): DoctorCheck { }; } -function platformScopeChecks( - device: DeviceInfo, - options: ReturnType, -): DoctorCheck[] { +function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): DoctorCheck[] { if ( (options.kind === 'react-native' || options.kind === 'expo') && device.platform !== 'ios' && @@ -320,24 +324,7 @@ async function appendAndroidChecks( try { const state = await getAndroidAppState(device); - const foregroundPackage = state.package; - const expectedPackage = targetApp ?? session?.appBundleId; - const foregroundMatches = expectedPackage && foregroundPackage === expectedPackage; - const onLauncher = foregroundPackage ? ANDROID_LAUNCHER_PACKAGES.has(foregroundPackage) : false; - appendDoctorCheck(checks, { - id: 'android-foreground', - status: onLauncher || (expectedPackage && !foregroundMatches) ? 'warn' : 'pass', - summary: onLauncher - ? 'Android is on the launcher, not the target app.' - : expectedPackage && !foregroundMatches - ? `Android foreground package is ${foregroundPackage ?? 'unknown'}, expected ${expectedPackage}.` - : `Android foreground package is ${foregroundPackage ?? 'unknown'}.`, - command: - onLauncher || (expectedPackage && !foregroundMatches && expectedPackage) - ? `agent-device open ${expectedPackage} --platform android` - : undefined, - evidence: state as Record, - }); + appendDoctorCheck(checks, androidForegroundCheck(state, targetApp ?? session?.appBundleId)); } catch (error) { const normalized = normalizeError(error); appendDoctorCheck(checks, { @@ -353,6 +340,48 @@ async function appendAndroidChecks( appendDoctorCheck(checks, await probeAndroidAnimations(adb)); } +function androidForegroundCheck( + state: AndroidForegroundApp, + expectedPackage: string | undefined, +): DoctorCheck { + const foregroundPackage = state.package; + const onLauncher = isAndroidLauncherPackage(foregroundPackage); + const mismatch = hasAndroidForegroundMismatch(foregroundPackage, expectedPackage); + return { + id: 'android-foreground', + status: onLauncher || mismatch ? 'warn' : 'pass', + summary: androidForegroundSummary(foregroundPackage, expectedPackage, onLauncher, mismatch), + command: + expectedPackage && (onLauncher || mismatch) + ? `agent-device open ${expectedPackage} --platform android` + : undefined, + evidence: state as Record, + }; +} + +function isAndroidLauncherPackage(packageName: string | undefined): boolean { + return packageName ? ANDROID_LAUNCHER_PACKAGES.has(packageName) : false; +} + +function hasAndroidForegroundMismatch( + foregroundPackage: string | undefined, + expectedPackage: string | undefined, +): boolean { + return expectedPackage !== undefined && foregroundPackage !== expectedPackage; +} + +function androidForegroundSummary( + foregroundPackage: string | undefined, + expectedPackage: string | undefined, + onLauncher: boolean, + mismatch: boolean, +): string { + const actual = foregroundPackage ?? 'unknown'; + if (onLauncher) return 'Android is on the launcher, not the target app.'; + if (mismatch) return `Android foreground package is ${actual}, expected ${expectedPackage}.`; + return `Android foreground package is ${actual}.`; +} + async function probeAndroidReverse( adb: AndroidAdbExecutor, serial: string, @@ -424,20 +453,21 @@ async function probeAndroidAnimations(adb: AndroidAdbExecutor): Promise, + options: DoctorOptions, ): void { - if (options.kind === 'auto' && !session?.snapshot) return; - if (!session?.snapshot) { - appendDoctorCheck(checks, { - id: 'rn-overlay', - status: 'info', - summary: 'No current session snapshot; React Native overlay check skipped.', - command: 'agent-device snapshot -i', - }); - return; - } + const check = reactNativeOverlayCheck(session, options); + if (check) appendDoctorCheck(checks, check); +} + +function reactNativeOverlayCheck( + session: SessionState | undefined, + options: DoctorOptions, +): DoctorCheck | undefined { + if (shouldSkipReactNativeOverlayCheck(session, options)) return undefined; + if (!session?.snapshot) return missingSnapshotOverlayCheck(); + const overlay = analyzeReactNativeOverlay(session.snapshot.nodes); - appendDoctorCheck(checks, { + return { id: 'rn-overlay', status: overlay.detected ? 'warn' : 'pass', summary: overlay.detected @@ -448,7 +478,23 @@ function appendReactNativeOverlayCheck( redBox: overlay.redBox, dismissTargets: overlay.dismissNodes.length + overlay.collapsedNodes.length, }, - }); + }; +} + +function shouldSkipReactNativeOverlayCheck( + session: SessionState | undefined, + options: DoctorOptions, +): boolean { + return options.kind === 'auto' && !session?.snapshot; +} + +function missingSnapshotOverlayCheck(): DoctorCheck { + return { + id: 'rn-overlay', + status: 'info', + summary: 'No current session snapshot; React Native overlay check skipped.', + command: 'agent-device snapshot -i', + }; } async function probeMetro(host: string, port: number, kind: DoctorKind): Promise { diff --git a/src/utils/device.ts b/src/utils/device.ts index a03bd5c60..83e695947 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -94,81 +94,97 @@ export async function resolveDevice( selector: DeviceSelector, context: DeviceSelectionContext = {}, ): Promise { - let candidates = devices; - const normalize = (value: string): string => - value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); + const candidates = sortDeviceCandidatesForSelection(filterDeviceCandidates(devices, selector)); + const explicitSelection = resolveExplicitDeviceSelection(candidates, selector); + if (explicitSelection) return explicitSelection; - if (selector.platform) { - candidates = candidates.filter((d) => matchesPlatformSelector(d.platform, selector.platform)); - } - if (selector.target) { - candidates = candidates.filter((d) => (d.target ?? 'mobile') === selector.target); - } - if (isAppleDeviceCandidateSet(candidates)) { - candidates = sortAppleDevicesForSelection(candidates); - } + const onlyCandidate = candidates[0]; + if (onlyCandidate !== undefined && candidates.length === 1) return onlyCandidate; - if (selector.udid) { - const match = candidates.find((d) => d.id === selector.udid && isApplePlatform(d.platform)); - if (!match) { - throw new AppError('DEVICE_NOT_FOUND', `No Apple device with UDID ${selector.udid}`); - } - return match; + if (candidates.length === 0) { + throwNoDevicesFound(selector, context); } - if (selector.serial) { - const match = candidates.find((d) => d.id === selector.serial && d.platform === 'android'); - if (!match) - throw new AppError('DEVICE_NOT_FOUND', `No Android device with serial ${selector.serial}`); - return match; - } + const selected = selectDefaultDevice(candidates); + if (selected === undefined) throwNoDevicesFound(selector, context); + return selected; +} - if (selector.deviceName) { - const target = normalize(selector.deviceName); - const match = candidates.find((d) => normalize(d.name) === target); - if (!match) { - throw new AppError('DEVICE_NOT_FOUND', `No device named ${selector.deviceName}`); - } - return match; - } +function filterDeviceCandidates(devices: DeviceInfo[], selector: DeviceSelector): DeviceInfo[] { + return devices + .filter((device) => matchesPlatformSelector(device.platform, selector.platform)) + .filter((device) => !selector.target || (device.target ?? 'mobile') === selector.target); +} - const onlyCandidate = candidates[0]; - if (onlyCandidate !== undefined && candidates.length === 1) return onlyCandidate; +function sortDeviceCandidatesForSelection(candidates: DeviceInfo[]): DeviceInfo[] { + return isAppleDeviceCandidateSet(candidates) + ? sortAppleDevicesForSelection(candidates) + : candidates; +} - if (candidates.length === 0) { - const simulatorSetPath = context.simulatorSetPath; - if (simulatorSetPath && supportsAppleSimulatorSelection(selector.platform)) { - throw new AppError('DEVICE_NOT_FOUND', 'No devices found in the scoped simulator set', { - simulatorSetPath, - hint: `The simulator set at "${simulatorSetPath}" appears to be empty. Create a simulator first:\n xcrun simctl --set "${simulatorSetPath}" create "iPhone 16" com.apple.CoreSimulator.SimDeviceType.iPhone-16 com.apple.CoreSimulator.SimRuntime.iOS-18-0`, - selector, - }); - } - throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector }); - } +function resolveExplicitDeviceSelection( + candidates: DeviceInfo[], + selector: DeviceSelector, +): DeviceInfo | undefined { + if (selector.udid) return findAppleDeviceById(candidates, selector.udid); + if (selector.serial) return findAndroidDeviceById(candidates, selector.serial); + if (selector.deviceName) return findDeviceByName(candidates, selector.deviceName); + return undefined; +} - // Prefer virtual devices (simulators/emulators) over physical devices unless - // a physical device was explicitly requested via --device/--udid/--serial. - const virtual = candidates.filter((d) => d.kind !== 'device'); - if (virtual.length > 0) { - candidates = virtual; - } +function findAppleDeviceById(candidates: DeviceInfo[], udid: string): DeviceInfo { + const match = candidates.find((device) => device.id === udid && isApplePlatform(device.platform)); + if (!match) throw new AppError('DEVICE_NOT_FOUND', `No Apple device with UDID ${udid}`); + return match; +} - if (!isAppleDeviceCandidateSet(candidates)) { - const booted = candidates.filter((d) => d.booted); - const onlyBooted = booted[0]; - if (onlyBooted !== undefined && booted.length === 1) return onlyBooted; - } +function findAndroidDeviceById(candidates: DeviceInfo[], serial: string): DeviceInfo { + const match = candidates.find((device) => device.id === serial && device.platform === 'android'); + if (!match) throw new AppError('DEVICE_NOT_FOUND', `No Android device with serial ${serial}`); + return match; +} + +function findDeviceByName(candidates: DeviceInfo[], deviceName: string): DeviceInfo { + const normalizedName = normalizeDeviceName(deviceName); + const match = candidates.find((device) => normalizeDeviceName(device.name) === normalizedName); + if (!match) throw new AppError('DEVICE_NOT_FOUND', `No device named ${deviceName}`); + return match; +} - // Apple candidates are pre-sorted by agent-friendly default priority. Other - // platforms preserve discovery order except for the existing booted-device preference. - const selected = isAppleDeviceCandidateSet(candidates) - ? candidates[0] - : (candidates.find((d) => d.booted) ?? candidates[0]); - if (selected === undefined) { - throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector }); +function selectDefaultDevice(candidates: DeviceInfo[]): DeviceInfo | undefined { + const selectable = preferVirtualDevices(candidates); + const singleBootedDevice = findSingleBootedDevice(selectable); + if (singleBootedDevice && !isAppleDeviceCandidateSet(selectable)) return singleBootedDevice; + return isAppleDeviceCandidateSet(selectable) + ? selectable[0] + : (selectable.find((device) => device.booted) ?? selectable[0]); +} + +function preferVirtualDevices(candidates: DeviceInfo[]): DeviceInfo[] { + // Prefer virtual devices unless a physical device was explicitly selected. + const virtual = candidates.filter((device) => device.kind !== 'device'); + return virtual.length > 0 ? virtual : candidates; +} + +function findSingleBootedDevice(candidates: DeviceInfo[]): DeviceInfo | undefined { + const booted = candidates.filter((device) => device.booted); + return booted.length === 1 ? booted[0] : undefined; +} + +function throwNoDevicesFound(selector: DeviceSelector, context: DeviceSelectionContext): never { + const simulatorSetPath = context.simulatorSetPath; + if (simulatorSetPath && supportsAppleSimulatorSelection(selector.platform)) { + throw new AppError('DEVICE_NOT_FOUND', 'No devices found in the scoped simulator set', { + simulatorSetPath, + hint: `The simulator set at "${simulatorSetPath}" appears to be empty. Create a simulator first:\n xcrun simctl --set "${simulatorSetPath}" create "iPhone 16" com.apple.CoreSimulator.SimDeviceType.iPhone-16 com.apple.CoreSimulator.SimRuntime.iOS-18-0`, + selector, + }); } - return selected; + throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector }); +} + +function normalizeDeviceName(value: string): string { + return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); } function compareAppleDevicesForSelection( @@ -184,20 +200,27 @@ function compareAppleDevicesForSelection( } function appleDeviceSelectionRank(device: DeviceInfo): number { - const target = device.target ?? 'mobile'; - if (device.kind === 'simulator') { - if (target === 'mobile') return isIpadDeviceName(device.name) ? 1 : 0; - if (target === 'tv') return 2; - return 3; - } - if (device.kind === 'device' && device.platform === 'ios') { - if (target === 'mobile') return isIpadDeviceName(device.name) ? 11 : 10; - if (target === 'tv') return 12; - return 13; - } + if (device.kind === 'simulator') return appleTargetSelectionRank(device, 0, 1, 2, 3); + if (device.kind === 'device' && device.platform === 'ios') + return appleTargetSelectionRank(device, 10, 11, 12, 13); return 14; } +function appleTargetSelectionRank( + device: DeviceInfo, + phoneRank: number, + ipadRank: number, + tvRank: number, + fallbackRank: number, +): number { + const targetRanks: Record = { + mobile: isIpadDeviceName(device.name) ? ipadRank : phoneRank, + tv: tvRank, + desktop: fallbackRank, + }; + return targetRanks[device.target ?? 'mobile']; +} + function isAppleDeviceCandidateSet(devices: DeviceInfo[]): boolean { return devices.length > 0 && devices.every((device) => isApplePlatform(device.platform)); } From b74c0294c859eccca54af9fa6b32bed1ec6c3929 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 25 Jun 2026 21:54:33 +0200 Subject: [PATCH 03/12] fix: classify doctor integration flags --- scripts/integration-progress-model.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 58ce1034f..c01864452 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -136,6 +136,7 @@ function summarizeProviderScenarioFlagCoverage(files) { ['androidDeviceAllowlist', 'Android serial allowlist reaches inventory resolution'], ['session', 'named session routing'], ['surface', 'macOS app/frontmost/desktop/menubar surfaces'], + ['targetApp', 'doctor target app checks route through platform app discovery'], ['activity', 'Android explicit launch activity'], ['launchConsole', 'iOS simulator launch console capture'], ['saveScript', 'open/close replay recording output'], @@ -283,6 +284,8 @@ function summarizeProviderScenarioFlagExclusions() { 'shardSplit', 'searchPath', 'stepsFile', + 'doctorReactNative', + 'doctorExpo', 'proxyHost', 'proxyPort', ], From 484bad56cef3a90094c24ae24b038b0f6e7682b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 25 Jun 2026 22:14:38 +0200 Subject: [PATCH 04/12] fix: simplify doctor setup --- scripts/integration-progress-model.ts | 3 - src/client-types.ts | 8 +- src/commands/management/doctor.ts | 27 +----- src/daemon/handlers/session-doctor.ts | 90 ++++++++++++------- src/utils/__tests__/args.test.ts | 45 ++-------- src/utils/cli-flags.ts | 24 ----- src/utils/cli-help.ts | 4 +- src/utils/project-runtime.ts | 32 +++++++ .../provider-scenarios/doctor.test.ts | 74 ++++++++------- .../integration/provider-scenarios/harness.ts | 9 +- 10 files changed, 154 insertions(+), 162 deletions(-) create mode 100644 src/utils/project-runtime.ts diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index c01864452..58ce1034f 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -136,7 +136,6 @@ function summarizeProviderScenarioFlagCoverage(files) { ['androidDeviceAllowlist', 'Android serial allowlist reaches inventory resolution'], ['session', 'named session routing'], ['surface', 'macOS app/frontmost/desktop/menubar surfaces'], - ['targetApp', 'doctor target app checks route through platform app discovery'], ['activity', 'Android explicit launch activity'], ['launchConsole', 'iOS simulator launch console capture'], ['saveScript', 'open/close replay recording output'], @@ -284,8 +283,6 @@ function summarizeProviderScenarioFlagExclusions() { 'shardSplit', 'searchPath', 'stepsFile', - 'doctorReactNative', - 'doctorExpo', 'proxyHost', 'proxyPort', ], diff --git a/src/client-types.ts b/src/client-types.ts index c21f2f892..1a9a6139e 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -509,12 +509,7 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & { timeoutMs?: number; }; -export type DoctorCommandOptions = DeviceCommandBaseOptions & { - targetApp?: string; - metroHost?: string; - metroPort?: number; - kind?: 'auto' | 'react-native' | 'expo'; -}; +export type DoctorCommandOptions = DeviceCommandBaseOptions; export type ViewportCommandOptions = DeviceCommandBaseOptions & { width: number; @@ -928,7 +923,6 @@ export type InternalRequestOptions = AgentDeviceClientConfig & metroPort?: number; bundleUrl?: string; launchUrl?: string; - targetApp?: string; appsFilter?: AppsFilter; installSource?: DaemonInstallSource; retainMaterializedPaths?: boolean; diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index 957df0ab2..32c027010 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -1,6 +1,5 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; -import { enumField, integerField, stringField } from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonInputFromFlags, direct } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; @@ -11,12 +10,7 @@ import { managementCliOutputFormatters } from './output.ts'; const doctorCommandMetadata = defineFieldCommandMetadata( 'doctor', 'Diagnose device, app, Metro, and React Native readiness before a run.', - { - targetApp: stringField('Expected app bundle id, package name, or app name.'), - metroHost: stringField('Metro host to probe.'), - metroPort: integerField('Metro port to probe.'), - kind: enumField(['auto', 'react-native', 'expo']), - }, + {}, ); const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, (client, input) => @@ -24,20 +18,14 @@ const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, ( ); const doctorCliSchema = { - usageOverride: - 'doctor [--platform ios|android|macos|linux|web|apple] [--target-app ] [--metro-host ] [--metro-port ] [--react-native|--expo|--kind auto|react-native|expo]', + usageOverride: 'doctor [--platform ios|android|macos|linux|web|apple]', helpDescription: - 'Read-only preflight for QA and dogfood runs. Reports device readiness, active sessions, target app discovery, Metro reachability, and obvious React Native overlay blockers from the current session snapshot. Default output is compact; use --json for full checks and evidence.', + 'Read-only preflight for QA and dogfood runs. Reports device readiness, active sessions, app discovery from the active session, Metro reachability inferred from cwd/runtime, and obvious React Native overlay blockers from the current session snapshot. Default output is compact; use --json for full checks and evidence.', summary: 'Preflight device, app, Metro, and RN/Expo readiness', - allowedFlags: ['targetApp', 'metroHost', 'metroPort', 'doctorReactNative', 'doctorExpo', 'kind'], } as const satisfies CommandSchemaOverride; const doctorCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), - targetApp: flags.targetApp, - metroHost: flags.metroHost, - metroPort: flags.metroPort, - kind: resolveDoctorKind(flags), }); const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor); @@ -51,12 +39,3 @@ export const doctorCommandFacet = defineCommandFacet({ daemonWriter: doctorDaemonWriter, cliOutputFormatter: managementCliOutputFormatters.doctor, }); - -function resolveDoctorKind(flags: Parameters[1]): 'auto' | 'react-native' | 'expo' { - if (flags.doctorExpo) return 'expo'; - if (flags.doctorReactNative) return 'react-native'; - if (flags.kind === 'expo' || flags.kind === 'react-native' || flags.kind === 'auto') { - return flags.kind; - } - return 'auto'; -} diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index ab3b23e58..5947cb8cd 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -11,6 +11,7 @@ import { } from '../../platforms/android/adb-executor.ts'; import { resolveIosApp } from '../../platforms/ios/apps.ts'; import type { DeviceInfo } from '../../utils/device.ts'; +import { detectProjectRuntimeKind } from '../../utils/project-runtime.ts'; import { readVersion } from '../../utils/version.ts'; import { normalizeError } from '../../utils/errors.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; @@ -46,6 +47,7 @@ const ANDROID_LAUNCHER_PACKAGES = new Set([ 'com.android.launcher3', 'com.google.android.apps.nexuslauncher', ]); +const REMOTE_CONNECTION_FLAG_KEYS = ['daemonBaseUrl', 'tenant', 'runId', 'leaseId'] as const; export async function handleDoctorCommand(params: { req: DaemonRequest; @@ -67,6 +69,7 @@ export async function handleDoctorCommand(params: { summary: `agent-device ${readVersion()} using ${sessionStore.resolveStateDir()}`, evidence: { version: readVersion(), stateDir: sessionStore.resolveStateDir() }, }, + ...remoteConnectionChecks(req), ...sessionChecks(sessionStore, sessionName, session), ); @@ -80,6 +83,7 @@ export async function handleDoctorCommand(params: { session, targetApp: options.targetApp, metroPort: options.metroPort, + shouldProbeMetro: options.shouldProbeMetro, androidAdbExecutor, }); appendReactNativeOverlayCheck(checks, session, options); @@ -107,27 +111,51 @@ export async function handleDoctorCommand(params: { } function readDoctorOptions(req: DaemonRequest, session: SessionState | undefined): DoctorOptions { - const kind = readDoctorKind(req.flags?.kind); - const targetApp = readNonEmptyString(req.flags?.targetApp) ?? session?.appBundleId; - const metroHost = readNonEmptyString(req.flags?.metroHost) ?? DEFAULT_METRO_HOST; - const metroPort = readPositivePort(req.flags?.metroPort) ?? DEFAULT_METRO_PORT; + const kind = detectProjectRuntimeKind(req.meta?.cwd); + const targetApp = session?.appBundleId; + const metroHost = readNonEmptyString(req.runtime?.metroHost) ?? DEFAULT_METRO_HOST; + const metroPort = readPositivePort(req.runtime?.metroPort) ?? DEFAULT_METRO_PORT; return { targetApp, metroHost, metroPort, kind, - shouldProbeMetro: shouldProbeMetro(req.flags, kind), + shouldProbeMetro: shouldProbeMetro(req, kind), }; } -function readDoctorKind(value: unknown): DoctorKind { - return value === 'expo' || value === 'react-native' ? value : 'auto'; +function shouldProbeMetro(req: DaemonRequest, kind: DoctorKind): boolean { + return ( + kind !== 'auto' || + typeof req.runtime?.metroPort === 'number' || + typeof req.runtime?.metroHost === 'string' + ); } -function shouldProbeMetro(flags: DaemonRequest['flags'], kind: DoctorKind): boolean { - return ( - kind !== 'auto' || typeof flags?.metroPort === 'number' || typeof flags?.metroHost === 'string' +function remoteConnectionChecks(req: DaemonRequest): DoctorCheck[] { + const evidence = remoteConnectionEvidence(req); + if (!evidence) return []; + return [ + { + id: 'remote-connection', + status: 'info', + summary: 'Remote daemon/session scope is active.', + evidence, + }, + ]; +} + +function remoteConnectionEvidence(req: DaemonRequest): Record | undefined { + const configured = Object.fromEntries( + REMOTE_CONNECTION_FLAG_KEYS.flatMap((key) => + typeof req.flags?.[key] === 'string' ? [[key, '']] : [], + ), ); + const evidence = { + ...configured, + ...(req.flags?.sessionIsolation === 'tenant' ? { sessionIsolation: 'tenant' } : {}), + }; + return Object.keys(evidence).length > 0 ? evidence : undefined; } function sessionChecks( @@ -273,12 +301,6 @@ async function appendAppChecks( ): Promise { const { device, targetApp, session } = params; if (!targetApp) { - appendDoctorCheck(checks, { - id: 'target-app', - status: 'info', - summary: 'No --target-app provided; app install/discovery check skipped.', - hint: 'Pass --target-app with the package or bundle expected for the run.', - }); return; } @@ -315,28 +337,34 @@ async function appendAndroidChecks( session: SessionState | undefined; targetApp?: string; metroPort: number; + shouldProbeMetro: boolean; androidAdbExecutor?: AndroidAdbExecutor; }, ): Promise { - const { device, session, targetApp, metroPort, androidAdbExecutor } = params; + const { device, session, targetApp, metroPort, shouldProbeMetro, androidAdbExecutor } = params; if (device.platform !== 'android') return; const adb = resolveAndroidAdbExecutor(device, androidAdbExecutor); - - try { - const state = await getAndroidAppState(device); - appendDoctorCheck(checks, androidForegroundCheck(state, targetApp ?? session?.appBundleId)); - } catch (error) { - const normalized = normalizeError(error); - appendDoctorCheck(checks, { - id: 'android-foreground', - status: 'warn', - summary: 'Could not read Android foreground package.', - hint: normalized.message, - evidence: { code: normalized.code }, - }); + const expectedPackage = targetApp ?? session?.appBundleId; + + if (expectedPackage) { + try { + const state = await getAndroidAppState(device); + appendDoctorCheck(checks, androidForegroundCheck(state, expectedPackage)); + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'android-foreground', + status: 'warn', + summary: 'Could not read Android foreground package.', + hint: normalized.message, + evidence: { code: normalized.code }, + }); + } } - appendDoctorCheck(checks, await probeAndroidReverse(adb, device.id, metroPort)); + if (shouldProbeMetro) { + appendDoctorCheck(checks, await probeAndroidReverse(adb, device.id, metroPort)); + } appendDoctorCheck(checks, await probeAndroidAnimations(adb)); } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 47c090fbb..2887bf451 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -93,45 +93,22 @@ test('parseArgs recognizes command-specific flag combinations', async () => { }, }, { - label: 'doctor android react native', - argv: [ - 'doctor', - '--platform', - 'android', - '--target-app', - 'com.example.app', - '--metro-port', - '8081', - '--react-native', - ], + label: 'doctor android', + argv: ['doctor', '--platform', 'android'], strictFlags: true, assertParsed: (parsed) => { assert.equal(parsed.command, 'doctor'); assert.equal(parsed.flags.platform, 'android'); - assert.equal(parsed.flags.targetApp, 'com.example.app'); - assert.equal(parsed.flags.metroPort, 8081); - assert.equal(parsed.flags.doctorReactNative, true); }, }, { - label: 'doctor ios expo', - argv: [ - 'doctor', - '--platform', - 'ios', - '--target-app', - 'com.example.app', - '--metro-port', - '8081', - '--expo', - ], + label: 'doctor remote session', + argv: ['doctor', '--session', 'remote-ios', '--remote-config', './remote.json'], strictFlags: true, assertParsed: (parsed) => { assert.equal(parsed.command, 'doctor'); - assert.equal(parsed.flags.platform, 'ios'); - assert.equal(parsed.flags.targetApp, 'com.example.app'); - assert.equal(parsed.flags.metroPort, 8081); - assert.equal(parsed.flags.doctorExpo, true); + assert.equal(parsed.flags.session, 'remote-ios'); + assert.equal(parsed.flags.remoteConfig, './remote.json'); }, }, { @@ -1705,14 +1682,8 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help debugging/); assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); - assert.match( - help, - /agent-device doctor --platform android --target-app com\.example\.app --metro-port 8081 --react-native/, - ); - assert.match( - help, - /agent-device doctor --platform ios --target-app com\.example\.app --metro-port 8081 --expo/, - ); + assert.match(help, /agent-device doctor --platform android/); + assert.match(help, /agent-device doctor --platform ios/); assert.match(help, /same host context that owns Metro/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index ddd078b4b..6a1f99703 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -58,9 +58,6 @@ export type CliFlags = RemoteConfigMetroOptions & iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; - targetApp?: string; - doctorReactNative?: boolean; - doctorExpo?: boolean; session?: string; metroHost?: string; metroPort?: number; @@ -579,27 +576,6 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--android-device-allowlist ', usageDescription: 'Comma/space separated Android serial allowlist for discovery/selection', }, - { - key: 'targetApp', - names: ['--target-app'], - type: 'string', - usageLabel: '--target-app ', - usageDescription: 'Doctor: app bundle id, package name, or app name expected for the run', - }, - { - key: 'doctorReactNative', - names: ['--react-native'], - type: 'boolean', - usageLabel: '--react-native', - usageDescription: 'Doctor: include React Native-specific preflight checks', - }, - { - key: 'doctorExpo', - names: ['--expo'], - type: 'boolean', - usageLabel: '--expo', - usageDescription: 'Doctor: include Expo/Metro-specific preflight checks', - }, { key: 'activity', names: ['--activity'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index ccba0d073..3c1120bd5 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -532,8 +532,8 @@ Choose the next help topic: React Native dev loop: Before QA/dogfood runs, use doctor to separate environment setup from app failures: - agent-device doctor --platform android --target-app com.example.app --metro-port 8081 --react-native - agent-device doctor --platform ios --target-app com.example.app --metro-port 8081 --expo + agent-device doctor --platform android + agent-device doctor --platform ios For "start from screen X" flows, prefer open --relaunch before the first snapshot so the app does not reuse a prior in-progress navigation state. JS-only change with Metro connected: agent-device metro reload diff --git a/src/utils/project-runtime.ts b/src/utils/project-runtime.ts new file mode 100644 index 000000000..76da5dc5a --- /dev/null +++ b/src/utils/project-runtime.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type ProjectRuntimeKind = 'auto' | 'react-native' | 'expo'; + +type PackageJsonShape = { + dependencies?: Record; + devDependencies?: Record; +}; + +export function detectProjectRuntimeKind(cwd: string | undefined): ProjectRuntimeKind { + const packageJson = readPackageJson(cwd); + if (!packageJson) return 'auto'; + + const dependencies = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.devDependencies ?? {}), + }; + if (typeof dependencies.expo === 'string') return 'expo'; + if (typeof dependencies['react-native'] === 'string') return 'react-native'; + return 'auto'; +} + +function readPackageJson(cwd: string | undefined): PackageJsonShape | undefined { + if (!cwd) return undefined; + const packageJsonPath = path.join(cwd, 'package.json'); + try { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJsonShape; + } catch { + return undefined; + } +} diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 03320d26e..3147df35e 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import fs from 'node:fs'; import http from 'node:http'; import { test } from 'vitest'; import type { AndroidAdbProvider } from '../../../src/platforms/android/adb-executor.ts'; @@ -10,9 +11,13 @@ import { PROVIDER_SCENARIO_MACOS, PROVIDER_SCENARIO_WEB, } from './fixtures.ts'; -import { createProviderScenarioHarness, withProviderScenarioResource } from './harness.ts'; +import { + createProviderScenarioHarness, + withProviderScenarioResource, + withProviderScenarioTempDir, +} from './harness.ts'; -test('Provider-backed integration doctor reports Android RN/Metro readiness through daemon route', async () => { +test('Provider-backed integration doctor infers Android RN/Metro readiness through daemon route', async () => { const server = await startMetroStatusServer(); const adbCalls: string[][] = []; const adbProvider: AndroidAdbProvider = { @@ -23,30 +28,39 @@ test('Provider-backed integration doctor reports Android RN/Metro readiness thro }; try { - await withProviderScenarioResource( - async () => - await createProviderScenarioHarness({ - androidAdbProvider: () => adbProvider, - deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], - }), - async (daemon) => { - const response = await daemon.callCommand('doctor', [], { - platform: 'android', - targetApp: 'com.example.app', - kind: 'react-native', - metroPort: server.port, - }); - assertRpcOk(response); - const data = response.json.result.data; - assert.equal(data.status, 'pass'); - assertDoctorCheck(data, 'metro', 'pass'); - assertDoctorCheck(data, 'android-reverse', 'pass'); - assertDoctorCheck(data, 'android-animations', 'pass'); - assert.ok( - adbCalls.some((args) => args.join(' ') === 'reverse --list'), - JSON.stringify(adbCalls), - ); - }, + await withProviderScenarioTempDir( + 'agent-device-doctor-rn-', + async (cwd) => + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + androidAdbProvider: () => adbProvider, + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + writePackageJson(cwd, { dependencies: { 'react-native': '0.0.0' } }); + const response = await daemon.callCommand( + 'doctor', + [], + { platform: 'android' }, + { + meta: { cwd }, + runtime: { metroPort: server.port }, + }, + ); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'pass'); + assert.equal(data.kind, 'react-native'); + assertDoctorCheck(data, 'metro', 'pass'); + assertDoctorCheck(data, 'android-reverse', 'pass'); + assertDoctorCheck(data, 'android-animations', 'pass'); + assert.ok( + adbCalls.some((args) => args.join(' ') === 'reverse --list'), + JSON.stringify(adbCalls), + ); + }, + ), ); } finally { await server.close(); @@ -75,20 +89,20 @@ test('Provider-backed integration doctor runs predictably for supported platform for (const device of devices) { const response = await daemon.callCommand('doctor', [], { platform: device.platform, - kind: device.platform === 'ios' || device.platform === 'android' ? 'auto' : 'expo', }); assertRpcOk(response); const data = response.json.result.data; assert.equal(data.platform, device.platform); assert.ok(Array.isArray(data.checks), `${device.platform} checks`); - if (device.platform !== 'ios' && device.platform !== 'android') { - assertDoctorCheck(data, 'platform-scope', 'info'); - } } }, ); }); +function writePackageJson(dir: string, value: Record): void { + fs.writeFileSync(`${dir}/package.json`, `${JSON.stringify(value)}\n`); +} + function assertDoctorCheck( data: { checks: Array<{ id: string; status: string }> }, id: string, diff --git a/test/integration/provider-scenarios/harness.ts b/test/integration/provider-scenarios/harness.ts index b9e9f18e1..658742068 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -21,7 +21,7 @@ export type ProviderScenarioHarness = { command: string, positionals?: string[], flags?: DaemonRequest['flags'], - options?: { meta?: DaemonRequest['meta'] }, + options?: { meta?: DaemonRequest['meta']; runtime?: DaemonRequest['runtime'] }, ) => Promise; client: () => AgentDeviceClient; session: (name?: string) => SessionState | undefined; @@ -62,7 +62,7 @@ export async function createProviderScenarioHarness( return { callCommand: async (command, positionals = [], flags = {}, options = {}) => responseToRpcResult( - await handleRequest(commandRequest(command, positionals, flags, options.meta)), + await handleRequest(commandRequest(command, positionals, flags, options)), `direct-${command}-${Date.now()}`, ), client: () => createAgentDeviceClient({}, { transport }), @@ -126,7 +126,7 @@ function commandRequest( command: string, positionals: string[] = [], flags: DaemonRequest['flags'] = {}, - meta?: DaemonRequest['meta'], + options: { meta?: DaemonRequest['meta']; runtime?: DaemonRequest['runtime'] } = {}, ): DaemonRequest { return { token: PROVIDER_SCENARIO_TOKEN, @@ -134,7 +134,8 @@ function commandRequest( command, positionals, flags, - meta, + runtime: options.runtime, + meta: options.meta, }; } From 3ea04131d5ff8f7c6da2cf83b98793c603752cf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 11:33:37 +0200 Subject: [PATCH 05/12] refactor: split doctor checks --- src/daemon/handlers/session-doctor-android.ts | 168 ++++++ src/daemon/handlers/session-doctor-app.ts | 42 ++ src/daemon/handlers/session-doctor-device.ts | 90 +++ src/daemon/handlers/session-doctor-metro.ts | 36 ++ src/daemon/handlers/session-doctor-options.ts | 120 ++++ src/daemon/handlers/session-doctor-output.ts | 38 ++ .../handlers/session-doctor-react-native.ts | 51 ++ src/daemon/handlers/session-doctor-types.ts | 20 + src/daemon/handlers/session-doctor.ts | 566 +----------------- src/daemon/session-store.ts | 6 - 10 files changed, 596 insertions(+), 541 deletions(-) create mode 100644 src/daemon/handlers/session-doctor-android.ts create mode 100644 src/daemon/handlers/session-doctor-app.ts create mode 100644 src/daemon/handlers/session-doctor-device.ts create mode 100644 src/daemon/handlers/session-doctor-metro.ts create mode 100644 src/daemon/handlers/session-doctor-options.ts create mode 100644 src/daemon/handlers/session-doctor-output.ts create mode 100644 src/daemon/handlers/session-doctor-react-native.ts create mode 100644 src/daemon/handlers/session-doctor-types.ts diff --git a/src/daemon/handlers/session-doctor-android.ts b/src/daemon/handlers/session-doctor-android.ts new file mode 100644 index 000000000..dfdbe0eb9 --- /dev/null +++ b/src/daemon/handlers/session-doctor-android.ts @@ -0,0 +1,168 @@ +import { + getAndroidAppState, + type AndroidForegroundApp, +} from '../../platforms/android/app-lifecycle.ts'; +import { + resolveAndroidAdbExecutor, + type AndroidAdbExecutor, +} from '../../platforms/android/adb-executor.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { normalizeError } from '../../utils/errors.ts'; +import type { SessionState } from '../types.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; +import type { DoctorCheck } from './session-doctor-types.ts'; + +const ANDROID_PROBE_TIMEOUT_MS = 2000; +const ANDROID_LAUNCHER_PACKAGES = new Set([ + 'com.android.launcher', + 'com.android.launcher3', + 'com.google.android.apps.nexuslauncher', +]); + +export async function appendAndroidChecks( + checks: DoctorCheck[], + params: { + device: DeviceInfo; + session: SessionState | undefined; + targetApp?: string; + metroPort: number; + shouldProbeMetro: boolean; + androidAdbExecutor?: AndroidAdbExecutor; + }, +): Promise { + const { device, session, targetApp, metroPort, shouldProbeMetro, androidAdbExecutor } = params; + if (device.platform !== 'android') return; + const adb = resolveAndroidAdbExecutor(device, androidAdbExecutor); + const expectedPackage = targetApp ?? session?.appBundleId; + + if (expectedPackage) { + try { + const state = await getAndroidAppState(device); + appendDoctorCheck(checks, androidForegroundCheck(state, expectedPackage)); + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'android-foreground', + status: 'warn', + summary: 'Could not read Android foreground package.', + hint: normalized.message, + evidence: { code: normalized.code }, + }); + } + } + + if (shouldProbeMetro) { + appendDoctorCheck(checks, await probeAndroidReverse(adb, device.id, metroPort)); + } + appendDoctorCheck(checks, await probeAndroidAnimations(adb)); +} + +function androidForegroundCheck( + state: AndroidForegroundApp, + expectedPackage: string | undefined, +): DoctorCheck { + const foregroundPackage = state.package; + const onLauncher = isAndroidLauncherPackage(foregroundPackage); + const mismatch = hasAndroidForegroundMismatch(foregroundPackage, expectedPackage); + return { + id: 'android-foreground', + status: onLauncher || mismatch ? 'warn' : 'pass', + summary: androidForegroundSummary(foregroundPackage, expectedPackage, onLauncher, mismatch), + command: + expectedPackage && (onLauncher || mismatch) + ? `agent-device open ${expectedPackage} --platform android` + : undefined, + evidence: state as Record, + }; +} + +function isAndroidLauncherPackage(packageName: string | undefined): boolean { + return packageName ? ANDROID_LAUNCHER_PACKAGES.has(packageName) : false; +} + +function hasAndroidForegroundMismatch( + foregroundPackage: string | undefined, + expectedPackage: string | undefined, +): boolean { + return expectedPackage !== undefined && foregroundPackage !== expectedPackage; +} + +function androidForegroundSummary( + foregroundPackage: string | undefined, + expectedPackage: string | undefined, + onLauncher: boolean, + mismatch: boolean, +): string { + const actual = foregroundPackage ?? 'unknown'; + if (onLauncher) return 'Android is on the launcher, not the target app.'; + if (mismatch) return `Android foreground package is ${actual}, expected ${expectedPackage}.`; + return `Android foreground package is ${actual}.`; +} + +async function probeAndroidReverse( + adb: AndroidAdbExecutor, + serial: string, + metroPort: number, +): Promise { + try { + const result = await adb(['reverse', '--list'], { + allowFailure: true, + timeoutMs: ANDROID_PROBE_TIMEOUT_MS, + }); + const expected = `tcp:${metroPort} tcp:${metroPort}`; + const hasReverse = result.stdout.includes(expected); + return { + id: 'android-reverse', + status: hasReverse ? 'pass' : 'warn', + summary: hasReverse + ? `Android adb reverse exists for Metro port ${metroPort}.` + : `Android adb reverse is missing for Metro port ${metroPort}.`, + command: hasReverse + ? undefined + : `adb -s ${serial} reverse tcp:${metroPort} tcp:${metroPort}`, + evidence: { stdout: result.stdout.trim() }, + }; + } catch (error) { + const normalized = normalizeError(error); + return { + id: 'android-reverse', + status: 'warn', + summary: 'Could not inspect Android adb reverse mappings.', + hint: normalized.message, + evidence: { code: normalized.code }, + }; + } +} + +async function probeAndroidAnimations(adb: AndroidAdbExecutor): Promise { + const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']; + try { + const values: Record = {}; + for (const key of keys) { + const result = await adb(['shell', 'settings', 'get', 'global', key], { + allowFailure: true, + timeoutMs: ANDROID_PROBE_TIMEOUT_MS, + }); + values[key] = result.stdout.trim(); + } + const enabled = Object.values(values).some((value) => value !== '0' && value !== '0.0'); + return { + id: 'android-animations', + status: enabled ? 'warn' : 'pass', + summary: enabled + ? 'Android animations are enabled and can slow or flake automation.' + : 'Android animations are disabled.', + hint: enabled ? 'Disable animations in emulator settings before long QA runs.' : undefined, + evidence: values, + }; + } catch (error) { + const normalized = normalizeError(error); + return { + id: 'android-animations', + status: 'warn', + summary: 'Could not read Android animation settings.', + hint: normalized.message, + evidence: { code: normalized.code }, + }; + } +} diff --git a/src/daemon/handlers/session-doctor-app.ts b/src/daemon/handlers/session-doctor-app.ts new file mode 100644 index 000000000..d3eb6a562 --- /dev/null +++ b/src/daemon/handlers/session-doctor-app.ts @@ -0,0 +1,42 @@ +import { resolveAndroidApp } from '../../platforms/android/app-lifecycle.ts'; +import { resolveIosApp } from '../../platforms/ios/apps.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { normalizeError } from '../../utils/errors.ts'; +import type { SessionState } from '../types.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; +import type { DoctorCheck } from './session-doctor-types.ts'; + +export async function appendAppChecks( + checks: DoctorCheck[], + params: { device: DeviceInfo; session: SessionState | undefined; targetApp?: string }, +): Promise { + const { device, targetApp, session } = params; + if (!targetApp) { + return; + } + + try { + const resolved = + device.platform === 'android' + ? (await resolveAndroidApp(device, targetApp)).value + : device.platform === 'ios' || device.platform === 'macos' + ? await resolveIosApp(device, targetApp) + : targetApp; + appendDoctorCheck(checks, { + id: 'target-app', + status: 'pass', + summary: `Target app is discoverable: ${resolved}`, + evidence: { requested: targetApp, resolved, sessionApp: session?.appBundleId }, + }); + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'target-app', + status: 'fail', + summary: `Target app is not discoverable: ${targetApp}`, + hint: normalized.hint ?? 'Install the app or pass the exact package/bundle id.', + command: `agent-device apps --platform ${device.platform}`, + evidence: { code: normalized.code, message: normalized.message }, + }); + } +} diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts new file mode 100644 index 000000000..b96594f87 --- /dev/null +++ b/src/daemon/handlers/session-doctor-device.ts @@ -0,0 +1,90 @@ +import type { DeviceInfo } from '../../utils/device.ts'; +import { normalizeError } from '../../utils/errors.ts'; +import type { DaemonRequest, SessionState } from '../types.ts'; +import { resolveCommandDevice } from './session-device-utils.ts'; +import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; + +export async function appendDeviceCheck( + checks: DoctorCheck[], + req: DaemonRequest, + session: SessionState | undefined, +): Promise { + try { + const device = await resolveCommandDevice({ session, flags: req.flags, ensureReady: false }); + appendDoctorCheck(checks, { + id: 'device', + status: 'pass', + summary: `Selected ${device.name} (${device.platform}${device.target ? `/${device.target}` : ''})`, + evidence: { + id: device.id, + name: device.name, + platform: device.platform, + kind: device.kind, + target: device.target ?? 'mobile', + booted: device.booted, + }, + }); + return device; + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'device', + status: 'fail', + summary: normalized.message, + hint: normalized.hint, + command: 'agent-device devices', + evidence: { code: normalized.code, details: normalized.details }, + }); + return undefined; + } +} + +export function deviceReadinessCheck(device: DeviceInfo): DoctorCheck { + if (device.booted === false) { + return { + id: 'device-readiness', + status: 'fail', + summary: `${device.name} is present but not booted.`, + command: `agent-device boot --platform ${device.platform}`, + evidence: { booted: false }, + }; + } + return { + id: 'device-readiness', + status: 'pass', + summary: + device.booted === true + ? `${device.name} is booted.` + : `${device.name} readiness is selected; boot state is not reported for this target.`, + evidence: { booted: device.booted }, + }; +} + +export function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): DoctorCheck[] { + if ( + (options.kind === 'react-native' || options.kind === 'expo') && + device.platform !== 'ios' && + device.platform !== 'android' + ) { + return [ + { + id: 'platform-scope', + status: 'info', + summary: `${options.kind} checks are app-mobile focused; ${device.platform} doctor covers device/session readiness only.`, + }, + ]; + } + if (device.platform === 'android' && options.kind !== 'auto') { + return [ + { + id: 'android-routing', + status: 'info', + summary: + 'Android URL opens can use host localhost automatically; package launches may still need adb reverse.', + command: `adb -s ${device.id} reverse tcp:${options.metroPort} tcp:${options.metroPort}`, + }, + ]; + } + return []; +} diff --git a/src/daemon/handlers/session-doctor-metro.ts b/src/daemon/handlers/session-doctor-metro.ts new file mode 100644 index 000000000..d3cc0a11a --- /dev/null +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -0,0 +1,36 @@ +import type { DoctorCheck, DoctorKind } from './session-doctor-types.ts'; + +const METRO_PROBE_TIMEOUT_MS = 1500; + +export async function probeMetro( + host: string, + port: number, + kind: DoctorKind, +): Promise { + const url = `http://${host}:${port}/status`; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(METRO_PROBE_TIMEOUT_MS) }); + const text = await response.text(); + const running = response.ok && text.toLowerCase().includes('packager-status:running'); + return { + id: 'metro', + status: running ? 'pass' : 'warn', + summary: running + ? `Metro is reachable at ${url}.` + : `Metro responded at ${url}, but did not report packager-status:running.`, + hint: running + ? undefined + : 'Verify this is the Metro instance for the target app, or restart Metro.', + evidence: { url, statusCode: response.status, body: text.slice(0, 120), kind }, + }; + } catch (error) { + return { + id: 'metro', + status: kind === 'auto' ? 'warn' : 'fail', + summary: `Metro is not reachable at ${url}.`, + hint: 'Start Metro, pass the correct --metro-host/--metro-port, or use a remote Metro profile.', + command: `curl -fsS ${url}`, + evidence: { url, error: error instanceof Error ? error.message : String(error), kind }, + }; + } +} diff --git a/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts new file mode 100644 index 000000000..5f6654417 --- /dev/null +++ b/src/daemon/handlers/session-doctor-options.ts @@ -0,0 +1,120 @@ +import { detectProjectRuntimeKind } from '../../utils/project-runtime.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, SessionState } from '../types.ts'; +import type { DoctorCheck, DoctorKind, DoctorOptions } from './session-doctor-types.ts'; + +const DEFAULT_METRO_HOST = '127.0.0.1'; +const DEFAULT_METRO_PORT = 8081; +const REMOTE_CONNECTION_FLAG_KEYS = ['daemonBaseUrl', 'tenant', 'runId', 'leaseId'] as const; + +export function readDoctorOptions( + req: DaemonRequest, + session: SessionState | undefined, +): DoctorOptions { + const kind = detectProjectRuntimeKind(req.meta?.cwd); + const targetApp = session?.appBundleId; + const metroHost = readNonEmptyString(req.runtime?.metroHost) ?? DEFAULT_METRO_HOST; + const metroPort = readPositivePort(req.runtime?.metroPort) ?? DEFAULT_METRO_PORT; + return { + targetApp, + metroHost, + metroPort, + kind, + shouldProbeMetro: shouldProbeMetro(req, kind), + }; +} + +export function remoteConnectionChecks(req: DaemonRequest): DoctorCheck[] { + const evidence = remoteConnectionEvidence(req); + if (!evidence) return []; + return [ + { + id: 'remote-connection', + status: 'info', + summary: 'Remote daemon/session scope is active.', + evidence, + }, + ]; +} + +export function sessionChecks( + sessionStore: SessionStore, + sessionName: string, + session: SessionState | undefined, +): DoctorCheck[] { + const sameDeviceSessions = session + ? sessionStore + .toArray() + .filter( + (candidate) => + candidate.name !== session.name && + candidate.device.platform === session.device.platform && + candidate.device.id === session.device.id, + ) + .map((candidate) => candidate.name) + : []; + + if (!session) { + return [ + { + id: 'session', + status: 'info', + summary: `No active session named ${sessionName}. Doctor will use the selected device.`, + hint: 'This is expected before a run. Use open when app foreground state matters.', + }, + ]; + } + + return [ + { + id: 'session', + status: sameDeviceSessions.length > 0 ? 'warn' : 'pass', + summary: + sameDeviceSessions.length > 0 + ? `Other active sessions target the same device: ${sameDeviceSessions.join(', ')}` + : `Active session ${session.name} targets ${session.device.name}`, + hint: + sameDeviceSessions.length > 0 + ? 'Close stale sessions before a QA run if they belong to old attempts.' + : undefined, + command: + sameDeviceSessions.length > 0 + ? `agent-device close --session ${sameDeviceSessions[0]} --platform ${session.device.platform}` + : undefined, + evidence: { + session: session.name, + sameDeviceSessions, + sessionStateDir: sessionStore.resolveSessionDir(session.name), + }, + }, + ]; +} + +function shouldProbeMetro(req: DaemonRequest, kind: DoctorKind): boolean { + return ( + kind !== 'auto' || + typeof req.runtime?.metroPort === 'number' || + typeof req.runtime?.metroHost === 'string' + ); +} + +function remoteConnectionEvidence(req: DaemonRequest): Record | undefined { + const configured = Object.fromEntries( + REMOTE_CONNECTION_FLAG_KEYS.flatMap((key) => + typeof req.flags?.[key] === 'string' ? [[key, '']] : [], + ), + ); + const evidence = { + ...configured, + ...(req.flags?.sessionIsolation === 'tenant' ? { sessionIsolation: 'tenant' } : {}), + }; + return Object.keys(evidence).length > 0 ? evidence : undefined; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function readPositivePort(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +} diff --git a/src/daemon/handlers/session-doctor-output.ts b/src/daemon/handlers/session-doctor-output.ts new file mode 100644 index 000000000..ea1ca17d7 --- /dev/null +++ b/src/daemon/handlers/session-doctor-output.ts @@ -0,0 +1,38 @@ +import { emitRequestProgress } from '../request-progress.ts'; +import type { DoctorCheck, DoctorStatus } from './session-doctor-types.ts'; + +export function summarizeDoctorStatus(checks: DoctorCheck[]): 'pass' | 'warn' | 'fail' { + if (checks.some((check) => check.status === 'fail')) return 'fail'; + if (checks.some((check) => check.status === 'warn')) return 'warn'; + return 'pass'; +} + +export function doctorSummary(status: 'pass' | 'warn' | 'fail'): string { + if (status === 'fail') return 'Blockers found before the run.'; + if (status === 'warn') return 'No hard blockers found, but warnings need attention.'; + return 'No blockers found.'; +} + +export function sortChecks(checks: DoctorCheck[]): DoctorCheck[] { + const order: Record = { fail: 0, warn: 1, pass: 2, info: 3 }; + return [...checks].sort((a, b) => order[a.status] - order[b.status]); +} + +export function appendDoctorChecks(checks: DoctorCheck[], ...items: DoctorCheck[]): void { + for (const check of items) { + appendDoctorCheck(checks, check); + } +} + +export function appendDoctorCheck(checks: DoctorCheck[], check: DoctorCheck): void { + checks.push(check); + emitRequestProgress({ + type: 'doctor-check', + id: check.id, + status: check.status, + summary: check.summary, + index: checks.length, + hint: check.hint, + command: check.command, + }); +} diff --git a/src/daemon/handlers/session-doctor-react-native.ts b/src/daemon/handlers/session-doctor-react-native.ts new file mode 100644 index 000000000..4d51991e2 --- /dev/null +++ b/src/daemon/handlers/session-doctor-react-native.ts @@ -0,0 +1,51 @@ +import { analyzeReactNativeOverlay } from '../../core/react-native-overlay.ts'; +import type { SessionState } from '../types.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; +import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; + +export function appendReactNativeOverlayCheck( + checks: DoctorCheck[], + session: SessionState | undefined, + options: DoctorOptions, +): void { + const check = reactNativeOverlayCheck(session, options); + if (check) appendDoctorCheck(checks, check); +} + +function reactNativeOverlayCheck( + session: SessionState | undefined, + options: DoctorOptions, +): DoctorCheck | undefined { + if (shouldSkipReactNativeOverlayCheck(session, options)) return undefined; + if (!session?.snapshot) return missingSnapshotOverlayCheck(); + + const overlay = analyzeReactNativeOverlay(session.snapshot.nodes); + return { + id: 'rn-overlay', + status: overlay.detected ? 'warn' : 'pass', + summary: overlay.detected + ? `React Native ${overlay.redBox ? 'RedBox' : 'LogBox'} overlay appears in the current snapshot.` + : 'No React Native overlay detected in the current snapshot.', + command: overlay.detected ? 'agent-device react-native dismiss-overlay' : undefined, + evidence: { + redBox: overlay.redBox, + dismissTargets: overlay.dismissNodes.length + overlay.collapsedNodes.length, + }, + }; +} + +function shouldSkipReactNativeOverlayCheck( + session: SessionState | undefined, + options: DoctorOptions, +): boolean { + return options.kind === 'auto' && !session?.snapshot; +} + +function missingSnapshotOverlayCheck(): DoctorCheck { + return { + id: 'rn-overlay', + status: 'info', + summary: 'No current session snapshot; React Native overlay check skipped.', + command: 'agent-device snapshot -i', + }; +} diff --git a/src/daemon/handlers/session-doctor-types.ts b/src/daemon/handlers/session-doctor-types.ts new file mode 100644 index 000000000..b8acdad87 --- /dev/null +++ b/src/daemon/handlers/session-doctor-types.ts @@ -0,0 +1,20 @@ +export type DoctorStatus = 'pass' | 'warn' | 'fail' | 'info'; + +export type DoctorKind = 'auto' | 'react-native' | 'expo'; + +export type DoctorOptions = { + targetApp?: string; + metroHost: string; + metroPort: number; + kind: DoctorKind; + shouldProbeMetro: boolean; +}; + +export type DoctorCheck = { + id: string; + status: DoctorStatus; + summary: string; + hint?: string; + command?: string; + evidence?: Record; +}; diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index 5947cb8cd..d5da8ade8 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -1,53 +1,31 @@ +import path from 'node:path'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { analyzeReactNativeOverlay } from '../../core/react-native-overlay.ts'; -import { - getAndroidAppState, - resolveAndroidApp, - type AndroidForegroundApp, -} from '../../platforms/android/app-lifecycle.ts'; -import { - resolveAndroidAdbExecutor, - type AndroidAdbExecutor, -} from '../../platforms/android/adb-executor.ts'; -import { resolveIosApp } from '../../platforms/ios/apps.ts'; -import type { DeviceInfo } from '../../utils/device.ts'; -import { detectProjectRuntimeKind } from '../../utils/project-runtime.ts'; +import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; import { readVersion } from '../../utils/version.ts'; -import { normalizeError } from '../../utils/errors.ts'; -import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; -import { emitRequestProgress } from '../request-progress.ts'; -import { resolveCommandDevice } from './session-device-utils.ts'; - -type DoctorStatus = 'pass' | 'warn' | 'fail' | 'info'; -type DoctorKind = 'auto' | 'react-native' | 'expo'; -type DoctorOptions = { - targetApp?: string; - metroHost: string; - metroPort: number; - kind: DoctorKind; - shouldProbeMetro: boolean; -}; - -type DoctorCheck = { - id: string; - status: DoctorStatus; - summary: string; - hint?: string; - command?: string; - evidence?: Record; -}; - -const DEFAULT_METRO_HOST = '127.0.0.1'; -const DEFAULT_METRO_PORT = 8081; -const METRO_PROBE_TIMEOUT_MS = 1500; -const ANDROID_PROBE_TIMEOUT_MS = 2000; -const ANDROID_LAUNCHER_PACKAGES = new Set([ - 'com.android.launcher', - 'com.android.launcher3', - 'com.google.android.apps.nexuslauncher', -]); -const REMOTE_CONNECTION_FLAG_KEYS = ['daemonBaseUrl', 'tenant', 'runId', 'leaseId'] as const; +import { appendAndroidChecks } from './session-doctor-android.ts'; +import { appendAppChecks } from './session-doctor-app.ts'; +import { + appendDeviceCheck, + deviceReadinessCheck, + platformScopeChecks, +} from './session-doctor-device.ts'; +import { probeMetro } from './session-doctor-metro.ts'; +import { + readDoctorOptions, + remoteConnectionChecks, + sessionChecks, +} from './session-doctor-options.ts'; +import { + appendDoctorCheck, + appendDoctorChecks, + doctorSummary, + sortChecks, + summarizeDoctorStatus, +} from './session-doctor-output.ts'; +import { appendReactNativeOverlayCheck } from './session-doctor-react-native.ts'; +import type { DoctorCheck } from './session-doctor-types.ts'; export async function handleDoctorCommand(params: { req: DaemonRequest; @@ -60,14 +38,15 @@ export async function handleDoctorCommand(params: { const session = sessionStore.get(sessionName); const options = readDoctorOptions(req, session); + const stateDir = resolveDoctorStateDir(sessionStore, sessionName); const checks: DoctorCheck[] = []; appendDoctorChecks( checks, { id: 'agent-device', status: 'pass', - summary: `agent-device ${readVersion()} using ${sessionStore.resolveStateDir()}`, - evidence: { version: readVersion(), stateDir: sessionStore.resolveStateDir() }, + summary: `agent-device ${readVersion()} using ${stateDir}`, + evidence: { version: readVersion(), stateDir }, }, ...remoteConnectionChecks(req), ...sessionChecks(sessionStore, sessionName, session), @@ -110,490 +89,7 @@ export async function handleDoctorCommand(params: { }; } -function readDoctorOptions(req: DaemonRequest, session: SessionState | undefined): DoctorOptions { - const kind = detectProjectRuntimeKind(req.meta?.cwd); - const targetApp = session?.appBundleId; - const metroHost = readNonEmptyString(req.runtime?.metroHost) ?? DEFAULT_METRO_HOST; - const metroPort = readPositivePort(req.runtime?.metroPort) ?? DEFAULT_METRO_PORT; - return { - targetApp, - metroHost, - metroPort, - kind, - shouldProbeMetro: shouldProbeMetro(req, kind), - }; -} - -function shouldProbeMetro(req: DaemonRequest, kind: DoctorKind): boolean { - return ( - kind !== 'auto' || - typeof req.runtime?.metroPort === 'number' || - typeof req.runtime?.metroHost === 'string' - ); -} - -function remoteConnectionChecks(req: DaemonRequest): DoctorCheck[] { - const evidence = remoteConnectionEvidence(req); - if (!evidence) return []; - return [ - { - id: 'remote-connection', - status: 'info', - summary: 'Remote daemon/session scope is active.', - evidence, - }, - ]; -} - -function remoteConnectionEvidence(req: DaemonRequest): Record | undefined { - const configured = Object.fromEntries( - REMOTE_CONNECTION_FLAG_KEYS.flatMap((key) => - typeof req.flags?.[key] === 'string' ? [[key, '']] : [], - ), - ); - const evidence = { - ...configured, - ...(req.flags?.sessionIsolation === 'tenant' ? { sessionIsolation: 'tenant' } : {}), - }; - return Object.keys(evidence).length > 0 ? evidence : undefined; -} - -function sessionChecks( - sessionStore: SessionStore, - sessionName: string, - session: SessionState | undefined, -): DoctorCheck[] { - const sameDeviceSessions = session - ? sessionStore - .toArray() - .filter( - (candidate) => - candidate.name !== session.name && - candidate.device.platform === session.device.platform && - candidate.device.id === session.device.id, - ) - .map((candidate) => candidate.name) - : []; - - if (!session) { - return [ - { - id: 'session', - status: 'info', - summary: `No active session named ${sessionName}. Doctor will use the selected device.`, - hint: 'This is expected before a run. Use open when app foreground state matters.', - }, - ]; - } - - return [ - { - id: 'session', - status: sameDeviceSessions.length > 0 ? 'warn' : 'pass', - summary: - sameDeviceSessions.length > 0 - ? `Other active sessions target the same device: ${sameDeviceSessions.join(', ')}` - : `Active session ${session.name} targets ${session.device.name}`, - hint: - sameDeviceSessions.length > 0 - ? 'Close stale sessions before a QA run if they belong to old attempts.' - : undefined, - command: - sameDeviceSessions.length > 0 - ? `agent-device close --session ${sameDeviceSessions[0]} --platform ${session.device.platform}` - : undefined, - evidence: { - session: session.name, - sameDeviceSessions, - sessionStateDir: sessionStore.resolveSessionDir(session.name), - }, - }, - ]; -} - -async function appendDeviceCheck( - checks: DoctorCheck[], - req: DaemonRequest, - session: SessionState | undefined, -): Promise { - try { - const device = await resolveCommandDevice({ session, flags: req.flags, ensureReady: false }); - appendDoctorCheck(checks, { - id: 'device', - status: 'pass', - summary: `Selected ${device.name} (${device.platform}${device.target ? `/${device.target}` : ''})`, - evidence: { - id: device.id, - name: device.name, - platform: device.platform, - kind: device.kind, - target: device.target ?? 'mobile', - booted: device.booted, - }, - }); - return device; - } catch (error) { - const normalized = normalizeError(error); - appendDoctorCheck(checks, { - id: 'device', - status: 'fail', - summary: normalized.message, - hint: normalized.hint, - command: 'agent-device devices', - evidence: { code: normalized.code, details: normalized.details }, - }); - return undefined; - } -} - -function deviceReadinessCheck(device: DeviceInfo): DoctorCheck { - if (device.booted === false) { - return { - id: 'device-readiness', - status: 'fail', - summary: `${device.name} is present but not booted.`, - command: `agent-device boot --platform ${device.platform}`, - evidence: { booted: false }, - }; - } - return { - id: 'device-readiness', - status: 'pass', - summary: - device.booted === true - ? `${device.name} is booted.` - : `${device.name} readiness is selected; boot state is not reported for this target.`, - evidence: { booted: device.booted }, - }; -} - -function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): DoctorCheck[] { - if ( - (options.kind === 'react-native' || options.kind === 'expo') && - device.platform !== 'ios' && - device.platform !== 'android' - ) { - return [ - { - id: 'platform-scope', - status: 'info', - summary: `${options.kind} checks are app-mobile focused; ${device.platform} doctor covers device/session readiness only.`, - }, - ]; - } - if (device.platform === 'android' && options.kind !== 'auto') { - return [ - { - id: 'android-routing', - status: 'info', - summary: - 'Android URL opens can use host localhost automatically; package launches may still need adb reverse.', - command: `adb -s ${device.id} reverse tcp:${options.metroPort} tcp:${options.metroPort}`, - }, - ]; - } - return []; -} - -async function appendAppChecks( - checks: DoctorCheck[], - params: { device: DeviceInfo; session: SessionState | undefined; targetApp?: string }, -): Promise { - const { device, targetApp, session } = params; - if (!targetApp) { - return; - } - - try { - const resolved = - device.platform === 'android' - ? (await resolveAndroidApp(device, targetApp)).value - : device.platform === 'ios' || device.platform === 'macos' - ? await resolveIosApp(device, targetApp) - : targetApp; - appendDoctorCheck(checks, { - id: 'target-app', - status: 'pass', - summary: `Target app is discoverable: ${resolved}`, - evidence: { requested: targetApp, resolved, sessionApp: session?.appBundleId }, - }); - } catch (error) { - const normalized = normalizeError(error); - appendDoctorCheck(checks, { - id: 'target-app', - status: 'fail', - summary: `Target app is not discoverable: ${targetApp}`, - hint: normalized.hint ?? 'Install the app or pass the exact package/bundle id.', - command: `agent-device apps --platform ${device.platform}`, - evidence: { code: normalized.code, message: normalized.message }, - }); - } -} - -async function appendAndroidChecks( - checks: DoctorCheck[], - params: { - device: DeviceInfo; - session: SessionState | undefined; - targetApp?: string; - metroPort: number; - shouldProbeMetro: boolean; - androidAdbExecutor?: AndroidAdbExecutor; - }, -): Promise { - const { device, session, targetApp, metroPort, shouldProbeMetro, androidAdbExecutor } = params; - if (device.platform !== 'android') return; - const adb = resolveAndroidAdbExecutor(device, androidAdbExecutor); - const expectedPackage = targetApp ?? session?.appBundleId; - - if (expectedPackage) { - try { - const state = await getAndroidAppState(device); - appendDoctorCheck(checks, androidForegroundCheck(state, expectedPackage)); - } catch (error) { - const normalized = normalizeError(error); - appendDoctorCheck(checks, { - id: 'android-foreground', - status: 'warn', - summary: 'Could not read Android foreground package.', - hint: normalized.message, - evidence: { code: normalized.code }, - }); - } - } - - if (shouldProbeMetro) { - appendDoctorCheck(checks, await probeAndroidReverse(adb, device.id, metroPort)); - } - appendDoctorCheck(checks, await probeAndroidAnimations(adb)); -} - -function androidForegroundCheck( - state: AndroidForegroundApp, - expectedPackage: string | undefined, -): DoctorCheck { - const foregroundPackage = state.package; - const onLauncher = isAndroidLauncherPackage(foregroundPackage); - const mismatch = hasAndroidForegroundMismatch(foregroundPackage, expectedPackage); - return { - id: 'android-foreground', - status: onLauncher || mismatch ? 'warn' : 'pass', - summary: androidForegroundSummary(foregroundPackage, expectedPackage, onLauncher, mismatch), - command: - expectedPackage && (onLauncher || mismatch) - ? `agent-device open ${expectedPackage} --platform android` - : undefined, - evidence: state as Record, - }; -} - -function isAndroidLauncherPackage(packageName: string | undefined): boolean { - return packageName ? ANDROID_LAUNCHER_PACKAGES.has(packageName) : false; -} - -function hasAndroidForegroundMismatch( - foregroundPackage: string | undefined, - expectedPackage: string | undefined, -): boolean { - return expectedPackage !== undefined && foregroundPackage !== expectedPackage; -} - -function androidForegroundSummary( - foregroundPackage: string | undefined, - expectedPackage: string | undefined, - onLauncher: boolean, - mismatch: boolean, -): string { - const actual = foregroundPackage ?? 'unknown'; - if (onLauncher) return 'Android is on the launcher, not the target app.'; - if (mismatch) return `Android foreground package is ${actual}, expected ${expectedPackage}.`; - return `Android foreground package is ${actual}.`; -} - -async function probeAndroidReverse( - adb: AndroidAdbExecutor, - serial: string, - metroPort: number, -): Promise { - try { - const result = await adb(['reverse', '--list'], { - allowFailure: true, - timeoutMs: ANDROID_PROBE_TIMEOUT_MS, - }); - const expected = `tcp:${metroPort} tcp:${metroPort}`; - const hasReverse = result.stdout.includes(expected); - return { - id: 'android-reverse', - status: hasReverse ? 'pass' : 'warn', - summary: hasReverse - ? `Android adb reverse exists for Metro port ${metroPort}.` - : `Android adb reverse is missing for Metro port ${metroPort}.`, - command: hasReverse - ? undefined - : `adb -s ${serial} reverse tcp:${metroPort} tcp:${metroPort}`, - evidence: { stdout: result.stdout.trim() }, - }; - } catch (error) { - const normalized = normalizeError(error); - return { - id: 'android-reverse', - status: 'warn', - summary: 'Could not inspect Android adb reverse mappings.', - hint: normalized.message, - evidence: { code: normalized.code }, - }; - } -} - -async function probeAndroidAnimations(adb: AndroidAdbExecutor): Promise { - const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']; - try { - const values: Record = {}; - for (const key of keys) { - const result = await adb(['shell', 'settings', 'get', 'global', key], { - allowFailure: true, - timeoutMs: ANDROID_PROBE_TIMEOUT_MS, - }); - values[key] = result.stdout.trim(); - } - const enabled = Object.values(values).some((value) => value !== '0' && value !== '0.0'); - return { - id: 'android-animations', - status: enabled ? 'warn' : 'pass', - summary: enabled - ? 'Android animations are enabled and can slow or flake automation.' - : 'Android animations are disabled.', - hint: enabled ? 'Disable animations in emulator settings before long QA runs.' : undefined, - evidence: values, - }; - } catch (error) { - const normalized = normalizeError(error); - return { - id: 'android-animations', - status: 'warn', - summary: 'Could not read Android animation settings.', - hint: normalized.message, - evidence: { code: normalized.code }, - }; - } -} - -function appendReactNativeOverlayCheck( - checks: DoctorCheck[], - session: SessionState | undefined, - options: DoctorOptions, -): void { - const check = reactNativeOverlayCheck(session, options); - if (check) appendDoctorCheck(checks, check); -} - -function reactNativeOverlayCheck( - session: SessionState | undefined, - options: DoctorOptions, -): DoctorCheck | undefined { - if (shouldSkipReactNativeOverlayCheck(session, options)) return undefined; - if (!session?.snapshot) return missingSnapshotOverlayCheck(); - - const overlay = analyzeReactNativeOverlay(session.snapshot.nodes); - return { - id: 'rn-overlay', - status: overlay.detected ? 'warn' : 'pass', - summary: overlay.detected - ? `React Native ${overlay.redBox ? 'RedBox' : 'LogBox'} overlay appears in the current snapshot.` - : 'No React Native overlay detected in the current snapshot.', - command: overlay.detected ? 'agent-device react-native dismiss-overlay' : undefined, - evidence: { - redBox: overlay.redBox, - dismissTargets: overlay.dismissNodes.length + overlay.collapsedNodes.length, - }, - }; -} - -function shouldSkipReactNativeOverlayCheck( - session: SessionState | undefined, - options: DoctorOptions, -): boolean { - return options.kind === 'auto' && !session?.snapshot; -} - -function missingSnapshotOverlayCheck(): DoctorCheck { - return { - id: 'rn-overlay', - status: 'info', - summary: 'No current session snapshot; React Native overlay check skipped.', - command: 'agent-device snapshot -i', - }; -} - -async function probeMetro(host: string, port: number, kind: DoctorKind): Promise { - const url = `http://${host}:${port}/status`; - try { - const response = await fetch(url, { signal: AbortSignal.timeout(METRO_PROBE_TIMEOUT_MS) }); - const text = await response.text(); - const running = response.ok && text.toLowerCase().includes('packager-status:running'); - return { - id: 'metro', - status: running ? 'pass' : 'warn', - summary: running - ? `Metro is reachable at ${url}.` - : `Metro responded at ${url}, but did not report packager-status:running.`, - hint: running - ? undefined - : 'Verify this is the Metro instance for the target app, or restart Metro.', - evidence: { url, statusCode: response.status, body: text.slice(0, 120), kind }, - }; - } catch (error) { - return { - id: 'metro', - status: kind === 'auto' ? 'warn' : 'fail', - summary: `Metro is not reachable at ${url}.`, - hint: 'Start Metro, pass the correct --metro-host/--metro-port, or use a remote Metro profile.', - command: `curl -fsS ${url}`, - evidence: { url, error: error instanceof Error ? error.message : String(error), kind }, - }; - } -} - -function summarizeDoctorStatus(checks: DoctorCheck[]): 'pass' | 'warn' | 'fail' { - if (checks.some((check) => check.status === 'fail')) return 'fail'; - if (checks.some((check) => check.status === 'warn')) return 'warn'; - return 'pass'; -} - -function doctorSummary(status: 'pass' | 'warn' | 'fail'): string { - if (status === 'fail') return 'Blockers found before the run.'; - if (status === 'warn') return 'No hard blockers found, but warnings need attention.'; - return 'No blockers found.'; -} - -function sortChecks(checks: DoctorCheck[]): DoctorCheck[] { - const order: Record = { fail: 0, warn: 1, pass: 2, info: 3 }; - return [...checks].sort((a, b) => order[a.status] - order[b.status]); -} - -function appendDoctorChecks(checks: DoctorCheck[], ...items: DoctorCheck[]): void { - for (const check of items) { - appendDoctorCheck(checks, check); - } -} - -function appendDoctorCheck(checks: DoctorCheck[], check: DoctorCheck): void { - checks.push(check); - emitRequestProgress({ - type: 'doctor-check', - id: check.id, - status: check.status, - summary: check.summary, - index: checks.length, - hint: check.hint, - command: check.command, - }); -} - -function readNonEmptyString(value: unknown): string | undefined { - return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; -} - -function readPositivePort(value: unknown): number | undefined { - return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +function resolveDoctorStateDir(sessionStore: SessionStore, sessionName: string): string { + const sessionsDir = path.dirname(sessionStore.resolveSessionDir(sessionName)); + return path.basename(sessionsDir) === 'sessions' ? path.dirname(sessionsDir) : sessionsDir; } diff --git a/src/daemon/session-store.ts b/src/daemon/session-store.ts index 09bbb13d0..d74ae703e 100644 --- a/src/daemon/session-store.ts +++ b/src/daemon/session-store.ts @@ -75,12 +75,6 @@ export class SessionStore { return path.join(this.sessionsDir, safeSessionName(sessionName)); } - resolveStateDir(): string { - return path.basename(this.sessionsDir) === 'sessions' - ? path.dirname(this.sessionsDir) - : this.sessionsDir; - } - ensureSessionDir(sessionName: string): string { const sessionDir = this.resolveSessionDir(sessionName); fs.mkdirSync(sessionDir, { recursive: true }); From fcd9679da4a239abe2aaf405671d1c27d69f0c3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:05:01 +0200 Subject: [PATCH 06/12] fix: simplify doctor check set --- src/daemon/handlers/session-doctor-android.ts | 114 +----------------- src/daemon/handlers/session-doctor-device.ts | 30 ++--- src/daemon/handlers/session-doctor.ts | 9 +- .../provider-scenarios/doctor.test.ts | 16 +-- 4 files changed, 16 insertions(+), 153 deletions(-) diff --git a/src/daemon/handlers/session-doctor-android.ts b/src/daemon/handlers/session-doctor-android.ts index dfdbe0eb9..6a010ee11 100644 --- a/src/daemon/handlers/session-doctor-android.ts +++ b/src/daemon/handlers/session-doctor-android.ts @@ -1,102 +1,27 @@ -import { - getAndroidAppState, - type AndroidForegroundApp, -} from '../../platforms/android/app-lifecycle.ts'; import { resolveAndroidAdbExecutor, type AndroidAdbExecutor, } from '../../platforms/android/adb-executor.ts'; import type { DeviceInfo } from '../../utils/device.ts'; import { normalizeError } from '../../utils/errors.ts'; -import type { SessionState } from '../types.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; import type { DoctorCheck } from './session-doctor-types.ts'; const ANDROID_PROBE_TIMEOUT_MS = 2000; -const ANDROID_LAUNCHER_PACKAGES = new Set([ - 'com.android.launcher', - 'com.android.launcher3', - 'com.google.android.apps.nexuslauncher', -]); export async function appendAndroidChecks( checks: DoctorCheck[], params: { device: DeviceInfo; - session: SessionState | undefined; - targetApp?: string; metroPort: number; shouldProbeMetro: boolean; androidAdbExecutor?: AndroidAdbExecutor; }, ): Promise { - const { device, session, targetApp, metroPort, shouldProbeMetro, androidAdbExecutor } = params; - if (device.platform !== 'android') return; + const { device, metroPort, shouldProbeMetro, androidAdbExecutor } = params; + if (device.platform !== 'android' || !shouldProbeMetro) return; const adb = resolveAndroidAdbExecutor(device, androidAdbExecutor); - const expectedPackage = targetApp ?? session?.appBundleId; - - if (expectedPackage) { - try { - const state = await getAndroidAppState(device); - appendDoctorCheck(checks, androidForegroundCheck(state, expectedPackage)); - } catch (error) { - const normalized = normalizeError(error); - appendDoctorCheck(checks, { - id: 'android-foreground', - status: 'warn', - summary: 'Could not read Android foreground package.', - hint: normalized.message, - evidence: { code: normalized.code }, - }); - } - } - - if (shouldProbeMetro) { - appendDoctorCheck(checks, await probeAndroidReverse(adb, device.id, metroPort)); - } - appendDoctorCheck(checks, await probeAndroidAnimations(adb)); -} - -function androidForegroundCheck( - state: AndroidForegroundApp, - expectedPackage: string | undefined, -): DoctorCheck { - const foregroundPackage = state.package; - const onLauncher = isAndroidLauncherPackage(foregroundPackage); - const mismatch = hasAndroidForegroundMismatch(foregroundPackage, expectedPackage); - return { - id: 'android-foreground', - status: onLauncher || mismatch ? 'warn' : 'pass', - summary: androidForegroundSummary(foregroundPackage, expectedPackage, onLauncher, mismatch), - command: - expectedPackage && (onLauncher || mismatch) - ? `agent-device open ${expectedPackage} --platform android` - : undefined, - evidence: state as Record, - }; -} - -function isAndroidLauncherPackage(packageName: string | undefined): boolean { - return packageName ? ANDROID_LAUNCHER_PACKAGES.has(packageName) : false; -} - -function hasAndroidForegroundMismatch( - foregroundPackage: string | undefined, - expectedPackage: string | undefined, -): boolean { - return expectedPackage !== undefined && foregroundPackage !== expectedPackage; -} - -function androidForegroundSummary( - foregroundPackage: string | undefined, - expectedPackage: string | undefined, - onLauncher: boolean, - mismatch: boolean, -): string { - const actual = foregroundPackage ?? 'unknown'; - if (onLauncher) return 'Android is on the launcher, not the target app.'; - if (mismatch) return `Android foreground package is ${actual}, expected ${expectedPackage}.`; - return `Android foreground package is ${actual}.`; + appendDoctorCheck(checks, await probeAndroidReverse(adb, device.id, metroPort)); } async function probeAndroidReverse( @@ -133,36 +58,3 @@ async function probeAndroidReverse( }; } } - -async function probeAndroidAnimations(adb: AndroidAdbExecutor): Promise { - const keys = ['window_animation_scale', 'transition_animation_scale', 'animator_duration_scale']; - try { - const values: Record = {}; - for (const key of keys) { - const result = await adb(['shell', 'settings', 'get', 'global', key], { - allowFailure: true, - timeoutMs: ANDROID_PROBE_TIMEOUT_MS, - }); - values[key] = result.stdout.trim(); - } - const enabled = Object.values(values).some((value) => value !== '0' && value !== '0.0'); - return { - id: 'android-animations', - status: enabled ? 'warn' : 'pass', - summary: enabled - ? 'Android animations are enabled and can slow or flake automation.' - : 'Android animations are disabled.', - hint: enabled ? 'Disable animations in emulator settings before long QA runs.' : undefined, - evidence: values, - }; - } catch (error) { - const normalized = normalizeError(error); - return { - id: 'android-animations', - status: 'warn', - summary: 'Could not read Android animation settings.', - hint: normalized.message, - evidence: { code: normalized.code }, - }; - } -} diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index b96594f87..91230fd94 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -14,8 +14,10 @@ export async function appendDeviceCheck( const device = await resolveCommandDevice({ session, flags: req.flags, ensureReady: false }); appendDoctorCheck(checks, { id: 'device', - status: 'pass', - summary: `Selected ${device.name} (${device.platform}${device.target ? `/${device.target}` : ''})`, + status: device.booted === false ? 'fail' : 'pass', + summary: deviceSummary(device), + command: + device.booted === false ? `agent-device boot --platform ${device.platform}` : undefined, evidence: { id: device.id, name: device.name, @@ -40,25 +42,15 @@ export async function appendDeviceCheck( } } -export function deviceReadinessCheck(device: DeviceInfo): DoctorCheck { +function deviceSummary(device: DeviceInfo): string { + const label = `${device.name} (${device.platform}${device.target ? `/${device.target}` : ''})`; if (device.booted === false) { - return { - id: 'device-readiness', - status: 'fail', - summary: `${device.name} is present but not booted.`, - command: `agent-device boot --platform ${device.platform}`, - evidence: { booted: false }, - }; + return `Selected ${label}, but it is not booted.`; + } + if (device.booted === true) { + return `Selected ${label}; device is booted.`; } - return { - id: 'device-readiness', - status: 'pass', - summary: - device.booted === true - ? `${device.name} is booted.` - : `${device.name} readiness is selected; boot state is not reported for this target.`, - evidence: { booted: device.booted }, - }; + return `Selected ${label}; boot state is not reported for this target.`; } export function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): DoctorCheck[] { diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index d5da8ade8..773bb8e34 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -6,11 +6,7 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { appendAndroidChecks } from './session-doctor-android.ts'; import { appendAppChecks } from './session-doctor-app.ts'; -import { - appendDeviceCheck, - deviceReadinessCheck, - platformScopeChecks, -} from './session-doctor-device.ts'; +import { appendDeviceCheck, platformScopeChecks } from './session-doctor-device.ts'; import { probeMetro } from './session-doctor-metro.ts'; import { readDoctorOptions, @@ -54,13 +50,10 @@ export async function handleDoctorCommand(params: { const device = await appendDeviceCheck(checks, req, session); if (device) { - appendDoctorCheck(checks, deviceReadinessCheck(device)); appendDoctorChecks(checks, ...platformScopeChecks(device, options)); await appendAppChecks(checks, { device, session, targetApp: options.targetApp }); await appendAndroidChecks(checks, { device, - session, - targetApp: options.targetApp, metroPort: options.metroPort, shouldProbeMetro: options.shouldProbeMetro, androidAdbExecutor, diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 3147df35e..d8229689c 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -54,11 +54,7 @@ test('Provider-backed integration doctor infers Android RN/Metro readiness throu assert.equal(data.kind, 'react-native'); assertDoctorCheck(data, 'metro', 'pass'); assertDoctorCheck(data, 'android-reverse', 'pass'); - assertDoctorCheck(data, 'android-animations', 'pass'); - assert.ok( - adbCalls.some((args) => args.join(' ') === 'reverse --list'), - JSON.stringify(adbCalls), - ); + assert.deepEqual(adbCalls, [['reverse', '--list']]); }, ), ); @@ -122,13 +118,6 @@ function androidDoctorAdbResult( exitCode: number; } { const command = args.join(' '); - if (command === 'shell dumpsys window windows') { - return { - stdout: 'mCurrentFocus=Window{123 u0 com.example.app/.MainActivity}\n', - stderr: '', - exitCode: 0, - }; - } if (command === 'reverse --list') { return { stdout: `emulator-5554 tcp:${metroPort} tcp:${metroPort}\n`, @@ -136,9 +125,6 @@ function androidDoctorAdbResult( exitCode: 0, }; } - if (command.startsWith('shell settings get global ')) { - return { stdout: '0\n', stderr: '', exitCode: 0 }; - } return { stdout: '', stderr: '', exitCode: 0 }; } From c873c365d8cecd9ae54dd5c6ef5c515a66cb2b87 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:14:36 +0200 Subject: [PATCH 07/12] fix: include stopped android avds in devices --- src/daemon/handlers/__tests__/session.test.ts | 50 +++++++++++++++++++ src/daemon/handlers/session-state.ts | 11 ++++ .../android/__tests__/devices.test.ts | 12 +++++ src/platforms/android/devices.ts | 30 +++++++++-- 4 files changed, 100 insertions(+), 3 deletions(-) diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 77d2164a6..cfc334843 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -937,6 +937,56 @@ test('boot launches Android emulator with GUI when no running device matches', a } }); +test('boot launches stopped Android emulator selected from inventory', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'android', + id: 'Pixel_9_Pro_XL', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: false, + }); + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith( + expect.objectContaining({ id: 'emulator-5554', booted: true }), + ); + if (response && response.ok) { + expect(response.data?.platform).toBe('android'); + expect(response.data?.id).toBe('emulator-5554'); + expect(response.data?.device).toBe('Pixel_9_Pro_XL'); + } +}); + test('boot --headless requires avd selector when device cannot be resolved', async () => { const sessionStore = makeSessionStore(); mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 48aa0bb09..ba1807aa3 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -233,6 +233,17 @@ export async function handleSessionStateCommands(params: { }); } await ensureDeviceReady(device); + } else if ( + device.platform === 'android' && + device.kind === 'emulator' && + device.booted !== true + ) { + device = await ensureAndroidEmulatorBoot({ + avdName: device.name, + serial: flags.serial, + headless: false, + }); + await ensureDeviceReady(device); } else { const shouldEnsureReady = device.platform !== 'android' || device.booted !== true; if (shouldEnsureReady) { diff --git a/src/platforms/android/__tests__/devices.test.ts b/src/platforms/android/__tests__/devices.test.ts index 1c2d58825..f2618f55e 100644 --- a/src/platforms/android/__tests__/devices.test.ts +++ b/src/platforms/android/__tests__/devices.test.ts @@ -311,6 +311,18 @@ test('listAndroidDevices falls back to model when emulator avd name is unavailab ); }); +test('listAndroidDevices includes stopped AVDs as non-booted emulators', async () => { + await withMockedAndroidTools(async () => { + const devices = await listAndroidDevices(); + + assert.equal(devices.length, 1); + assert.equal(devices[0]?.id, 'Pixel_9_Pro_XL'); + assert.equal(devices[0]?.name, 'Pixel_9_Pro_XL'); + assert.equal(devices[0]?.kind, 'emulator'); + assert.equal(devices[0]?.booted, false); + }); +}); + test('ensureAndroidEmulatorBooted launches emulator in headless mode when requested', async () => { await withMockedAndroidTools(async ({ emulatorLogPath, emulatorBootedPath }) => { const device = await ensureAndroidEmulatorBooted({ diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index 22d396460..5352010d5 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -256,7 +256,7 @@ export async function listAndroidDevices( }, ); - return devices; + return [...devices, ...(await listStoppedAndroidAvdDevices(devices))]; } type AndroidDeviceEntry = { @@ -321,6 +321,29 @@ async function listAndroidAvdNames(): Promise { return parseAndroidAvdList(result.stdout); } +async function listStoppedAndroidAvdDevices(runningDevices: DeviceInfo[]): Promise { + const avdNames = await listAndroidAvdNames().catch(() => []); + const runningEmulatorNames = new Set( + runningDevices + .filter((device) => device.kind === 'emulator') + .map((device) => normalizeAndroidName(device.name)), + ); + return avdNames + .filter((avdName) => !runningEmulatorNames.has(normalizeAndroidName(avdName))) + .map((avdName) => ({ + platform: 'android', + id: avdName, + name: avdName, + kind: 'emulator', + target: inferAndroidAvdTarget(avdName), + booted: false, + })); +} + +function inferAndroidAvdTarget(avdName: string): 'mobile' | 'tv' { + return /\b(tv|television)\b/i.test(normalizeAndroidName(avdName)) ? 'tv' : 'mobile'; +} + function findAndroidEmulatorByAvdName( devices: DeviceInfo[], avdName: string, @@ -434,7 +457,8 @@ export async function ensureAndroidEmulatorBooted(params: { resolvedAvdName, params.serial, ); - if (!existing) { + const runningExisting = existing && isEmulatorSerial(existing.id) ? existing : undefined; + if (!runningExisting) { const launchArgs = ['-avd', resolvedAvdName]; if (params.headless) { launchArgs.push('-no-window', '-no-audio'); @@ -443,7 +467,7 @@ export async function ensureAndroidEmulatorBooted(params: { } const discovered = - existing ?? + runningExisting ?? (await waitForAndroidEmulatorByAvdName({ avdName: resolvedAvdName, serial: params.serial, From f1e33ea6a9660281207b9ce2ef5d18befead11cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:37:29 +0200 Subject: [PATCH 08/12] fix: report doctor device inventory --- src/commands/management/doctor.ts | 13 +- src/daemon/handlers/session-doctor-device.ts | 237 +++++++++++++++--- src/daemon/handlers/session-doctor-options.ts | 27 +- src/daemon/handlers/session-doctor-types.ts | 1 + src/daemon/handlers/session-doctor.ts | 27 +- src/utils/__tests__/args.test.ts | 4 +- src/utils/cli-flags.ts | 8 + src/utils/cli-help.ts | 1 + .../provider-scenarios/doctor.test.ts | 59 ++++- 9 files changed, 329 insertions(+), 48 deletions(-) diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index 32c027010..6446791aa 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -1,5 +1,6 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts'; +import * as commandInput from '../command-input.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonInputFromFlags, direct } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; @@ -10,7 +11,11 @@ import { managementCliOutputFormatters } from './output.ts'; const doctorCommandMetadata = defineFieldCommandMetadata( 'doctor', 'Diagnose device, app, Metro, and React Native readiness before a run.', - {}, + { + remote: commandInput.booleanField( + 'Check remote connection setup instead of local device inventory.', + ), + }, ); const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, (client, input) => @@ -18,14 +23,16 @@ const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, ( ); const doctorCliSchema = { - usageOverride: 'doctor [--platform ios|android|macos|linux|web|apple]', + usageOverride: 'doctor [--platform ios|android|macos|linux|web|apple] [--remote]', helpDescription: - 'Read-only preflight for QA and dogfood runs. Reports device readiness, active sessions, app discovery from the active session, Metro reachability inferred from cwd/runtime, and obvious React Native overlay blockers from the current session snapshot. Default output is compact; use --json for full checks and evidence.', + 'Read-only preflight for QA and dogfood runs. Reports local device inventory, active sessions, app discovery from the active session, Metro reachability inferred from cwd/runtime, and obvious React Native overlay blockers from the current session snapshot. Use --remote to check remote connection setup without probing local devices. Default output is compact; use --json for full checks and evidence.', summary: 'Preflight device, app, Metro, and RN/Expo readiness', + allowedFlags: ['remote'], } as const satisfies CommandSchemaOverride; const doctorCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), + remote: flags.remote, }); const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor); diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index 91230fd94..e34f720ca 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -1,33 +1,53 @@ -import type { DeviceInfo } from '../../utils/device.ts'; -import { normalizeError } from '../../utils/errors.ts'; +import { listDeviceInventory } from '../../core/dispatch-resolve.ts'; +import type { DeviceInfo, DeviceTarget, Platform, PlatformSelector } from '../../utils/device.ts'; +import { + normalizePlatformSelector, + resolveAppleSimulatorSetPathForSelector, +} from '../../utils/device.ts'; +import { + resolveAndroidSerialAllowlist, + resolveIosSimulatorDeviceSetPath, +} from '../../utils/device-isolation.ts'; +import { AppError, normalizeError } from '../../utils/errors.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; -import { resolveCommandDevice } from './session-device-utils.ts'; import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; -export async function appendDeviceCheck( +export type DoctorDeviceInventory = { + devices: DeviceInfo[]; + platform?: PlatformSelector; + target?: DeviceTarget; +}; + +type DoctorInventoryFailure = { + platform: PlatformSelector; + message: string; + hint?: string; + code?: string; +}; + +export async function appendDeviceInventoryCheck( checks: DoctorCheck[], req: DaemonRequest, session: SessionState | undefined, -): Promise { +): Promise { try { - const device = await resolveCommandDevice({ session, flags: req.flags, ensureReady: false }); + const selector = deviceInventorySelector(req, session); + const inventory = await readDoctorDeviceInventory(selector); + const devices = filterInventoryForSelector(inventory.devices, selector); appendDoctorCheck(checks, { id: 'device', - status: device.booted === false ? 'fail' : 'pass', - summary: deviceSummary(device), - command: - device.booted === false ? `agent-device boot --platform ${device.platform}` : undefined, - evidence: { - id: device.id, - name: device.name, - platform: device.platform, - kind: device.kind, - target: device.target ?? 'mobile', - booted: device.booted, - }, + status: devices.length === 0 ? 'fail' : 'pass', + summary: deviceInventorySummary(devices, selector, inventory.failures), + hint: + devices.length === 0 + ? (inventory.failures.find((failure) => failure.hint)?.hint ?? + 'Start or create a simulator/emulator, connect a device, or adjust --platform/--target/--device selectors.') + : undefined, + command: devices.length === 0 ? deviceInventoryCommand(selector) : undefined, + evidence: deviceInventoryEvidence(devices, inventory.failures), }); - return device; + return { devices, platform: selector.platform, target: selector.target }; } catch (error) { const normalized = normalizeError(error); appendDoctorCheck(checks, { @@ -42,17 +62,6 @@ export async function appendDeviceCheck( } } -function deviceSummary(device: DeviceInfo): string { - const label = `${device.name} (${device.platform}${device.target ? `/${device.target}` : ''})`; - if (device.booted === false) { - return `Selected ${label}, but it is not booted.`; - } - if (device.booted === true) { - return `Selected ${label}; device is booted.`; - } - return `Selected ${label}; boot state is not reported for this target.`; -} - export function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): DoctorCheck[] { if ( (options.kind === 'react-native' || options.kind === 'expo') && @@ -80,3 +89,171 @@ export function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): } return []; } + +function deviceInventorySelector(req: DaemonRequest, session: SessionState | undefined) { + const flags = req.flags ?? {}; + const platform = normalizePlatformSelector(flags.platform) ?? session?.device.platform; + const target = flags.target ?? session?.device.target; + if (target && !platform) { + throw new AppError( + 'INVALID_ARGS', + 'Device target selector requires --platform. Use --platform ios|macos|android|linux|apple with --target mobile|tv|desktop.', + ); + } + const iosSimulatorSetPath = resolveAppleSimulatorSetPathForSelector({ + simulatorSetPath: resolveIosSimulatorDeviceSetPath(flags.iosSimulatorDeviceSet), + platform, + target, + }); + const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags.androidDeviceAllowlist); + return { + platform, + target, + deviceName: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorSetPath, + androidSerialAllowlist: androidSerialAllowlist + ? Array.from(androidSerialAllowlist).sort() + : undefined, + }; +} + +function filterInventoryForSelector( + devices: DeviceInfo[], + selector: ReturnType, +): DeviceInfo[] { + return devices.filter((device) => deviceMatchesSelector(device, selector)); +} + +async function readDoctorDeviceInventory( + selector: ReturnType, +): Promise<{ devices: DeviceInfo[]; failures: DoctorInventoryFailure[] }> { + if (selector.platform) { + return { devices: await listDeviceInventory(selector), failures: [] }; + } + + const devices: DeviceInfo[] = []; + const failures: DoctorInventoryFailure[] = []; + for (const platform of ['android', 'apple', 'linux'] as const) { + try { + devices.push(...(await listDeviceInventory({ ...selector, platform }))); + } catch (error) { + failures.push(inventoryFailure(platform, error)); + } + } + return { devices, failures: devices.length === 0 ? failures : [] }; +} + +function inventoryFailure(platform: PlatformSelector, error: unknown): DoctorInventoryFailure { + const normalized = normalizeError(error); + return { + platform, + message: normalized.message, + hint: normalized.hint, + code: normalized.code, + }; +} + +function deviceMatchesSelector( + device: DeviceInfo, + selector: ReturnType, +): boolean { + return [ + optionalPlatformMatches(selector.platform, device), + optionalValueMatches(selector.target, device.target), + optionalValueMatches(selector.deviceName, device.name), + optionalValueMatches(selector.udid, device.id), + optionalValueMatches(selector.serial, device.id), + ].every(Boolean); +} + +function optionalPlatformMatches( + selector: PlatformSelector | undefined, + device: DeviceInfo, +): boolean { + return selector === undefined || deviceMatchesPlatform(device, selector); +} + +function optionalValueMatches(expected: T | undefined, actual: T | undefined): boolean { + return expected === undefined || actual === expected; +} + +function deviceMatchesPlatform(device: DeviceInfo, selector: PlatformSelector): boolean { + if (selector === 'apple') return device.platform === 'ios' || device.platform === 'macos'; + return device.platform === selector; +} + +function deviceInventorySummary( + devices: DeviceInfo[], + selector: Pick, 'platform' | 'target'>, + failures: DoctorInventoryFailure[], +): string { + if (devices.length === 0) { + if (failures.length > 0) { + return `No ${deviceInventoryLabel(selector)} devices found; ${inventoryFailureSummary(failures)}.`; + } + return `No ${deviceInventoryLabel(selector)} devices found.`; + } + const booted = devices.filter((device) => device.booted === true).length; + return `${devices.length} ${deviceInventoryLabel(selector)} ${plural( + devices.length, + 'device', + )} available; ${booted} booted.`; +} + +function deviceInventoryLabel( + selector: Pick, 'platform' | 'target'>, +): string { + const platform = selector.platform ? platformLabel(selector.platform) : 'local'; + return selector.target ? `${platform} ${selector.target}` : platform; +} + +function inventoryFailureSummary(failures: DoctorInventoryFailure[]): string { + return failures + .slice(0, 2) + .map((failure) => `${platformLabel(failure.platform)} inventory failed: ${failure.message}`) + .join('; '); +} + +function platformLabel(platform: PlatformSelector): string { + if (platform === 'ios') return 'iOS'; + if (platform === 'macos') return 'macOS'; + if (platform === 'android') return 'Android'; + if (platform === 'linux') return 'Linux'; + if (platform === 'web') return 'web'; + return 'Apple'; +} + +function plural(count: number, singular: string): string { + return count === 1 ? singular : `${singular}s`; +} + +function deviceInventoryCommand( + selector: Pick, 'platform'>, +): string { + return selector.platform + ? `agent-device devices --platform ${selector.platform}` + : 'agent-device devices'; +} + +function deviceInventoryEvidence( + devices: DeviceInfo[], + failures: DoctorInventoryFailure[], +): Record { + const byPlatform = new Map(); + for (const device of devices) { + const entry = byPlatform.get(device.platform) ?? { available: 0, booted: 0 }; + entry.available += 1; + if (device.booted === true) entry.booted += 1; + byPlatform.set(device.platform, entry); + } + return { + available: devices.length, + booted: devices.filter((device) => device.booted === true).length, + byPlatform: Object.fromEntries( + [...byPlatform.entries()].sort(([a], [b]) => a.localeCompare(b)), + ), + ...(failures.length > 0 ? { failures } : {}), + }; +} diff --git a/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts index 5f6654417..35b068f53 100644 --- a/src/daemon/handlers/session-doctor-options.ts +++ b/src/daemon/handlers/session-doctor-options.ts @@ -20,18 +20,32 @@ export function readDoctorOptions( metroHost, metroPort, kind, + remote: req.flags?.remote === true, shouldProbeMetro: shouldProbeMetro(req, kind), }; } -export function remoteConnectionChecks(req: DaemonRequest): DoctorCheck[] { +export function remoteConnectionChecks( + req: DaemonRequest, + options: { required?: boolean } = {}, +): DoctorCheck[] { const evidence = remoteConnectionEvidence(req); - if (!evidence) return []; + if (!evidence) { + if (!options.required) return []; + return [ + { + id: 'remote-connection', + status: 'fail', + summary: 'No remote daemon/session scope is configured.', + hint: 'Use connect --remote-config , --remote-config , or direct --daemon-base-url/--daemon-auth-token flags.', + }, + ]; + } return [ { id: 'remote-connection', - status: 'info', - summary: 'Remote daemon/session scope is active.', + status: options.required ? 'pass' : 'info', + summary: 'Remote daemon/session scope is configured.', evidence, }, ]; @@ -41,6 +55,7 @@ export function sessionChecks( sessionStore: SessionStore, sessionName: string, session: SessionState | undefined, + options: { remote?: boolean } = {}, ): DoctorCheck[] { const sameDeviceSessions = session ? sessionStore @@ -59,7 +74,9 @@ export function sessionChecks( { id: 'session', status: 'info', - summary: `No active session named ${sessionName}. Doctor will use the selected device.`, + summary: options.remote + ? `No active session named ${sessionName}. Remote doctor will use configured remote scope.` + : `No active session named ${sessionName}. Doctor will use device inventory only.`, hint: 'This is expected before a run. Use open when app foreground state matters.', }, ]; diff --git a/src/daemon/handlers/session-doctor-types.ts b/src/daemon/handlers/session-doctor-types.ts index b8acdad87..1a46c114c 100644 --- a/src/daemon/handlers/session-doctor-types.ts +++ b/src/daemon/handlers/session-doctor-types.ts @@ -8,6 +8,7 @@ export type DoctorOptions = { metroPort: number; kind: DoctorKind; shouldProbeMetro: boolean; + remote: boolean; }; export type DoctorCheck = { diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index 773bb8e34..a2917722c 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -6,7 +6,7 @@ import type { DaemonRequest, DaemonResponse } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { appendAndroidChecks } from './session-doctor-android.ts'; import { appendAppChecks } from './session-doctor-app.ts'; -import { appendDeviceCheck, platformScopeChecks } from './session-doctor-device.ts'; +import { appendDeviceInventoryCheck, platformScopeChecks } from './session-doctor-device.ts'; import { probeMetro } from './session-doctor-metro.ts'; import { readDoctorOptions, @@ -44,11 +44,26 @@ export async function handleDoctorCommand(params: { summary: `agent-device ${readVersion()} using ${stateDir}`, evidence: { version: readVersion(), stateDir }, }, - ...remoteConnectionChecks(req), - ...sessionChecks(sessionStore, sessionName, session), + ...remoteConnectionChecks(req, { required: options.remote }), + ...sessionChecks(sessionStore, sessionName, session, { remote: options.remote }), ); - const device = await appendDeviceCheck(checks, req, session); + if (options.remote) { + const status = summarizeDoctorStatus(checks); + return { + ok: true, + data: { + status, + summary: doctorSummary(status), + kind: options.kind, + targetApp: options.targetApp, + checks: sortChecks(checks), + }, + }; + } + + const inventory = await appendDeviceInventoryCheck(checks, req, session); + const device = session?.device; if (device) { appendDoctorChecks(checks, ...platformScopeChecks(device, options)); await appendAppChecks(checks, { device, session, targetApp: options.targetApp }); @@ -71,8 +86,8 @@ export async function handleDoctorCommand(params: { status, summary: doctorSummary(status), kind: options.kind, - platform: device?.platform, - target: device?.target ?? 'mobile', + platform: device?.platform ?? inventory?.platform, + target: device?.target ?? inventory?.target, targetApp: options.targetApp, metro: options.shouldProbeMetro ? { host: options.metroHost, port: options.metroPort } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 2887bf451..5c4e481f9 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -103,10 +103,11 @@ test('parseArgs recognizes command-specific flag combinations', async () => { }, { label: 'doctor remote session', - argv: ['doctor', '--session', 'remote-ios', '--remote-config', './remote.json'], + argv: ['doctor', '--remote', '--session', 'remote-ios', '--remote-config', './remote.json'], strictFlags: true, assertParsed: (parsed) => { assert.equal(parsed.command, 'doctor'); + assert.equal(parsed.flags.remote, true); assert.equal(parsed.flags.session, 'remote-ios'); assert.equal(parsed.flags.remoteConfig, './remote.json'); }, @@ -1684,6 +1685,7 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /Help workflow owns the full Expo URL command shapes/); assert.match(help, /agent-device doctor --platform android/); assert.match(help, /agent-device doctor --platform ios/); + assert.match(help, /agent-device doctor --remote --remote-config \.\/remote\.json/); assert.match(help, /same host context that owns Metro/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 6a1f99703..2d2f3cade 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -58,6 +58,7 @@ export type CliFlags = RemoteConfigMetroOptions & iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; + remote?: boolean; session?: string; metroHost?: string; metroPort?: number; @@ -576,6 +577,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--android-device-allowlist ', usageDescription: 'Comma/space separated Android serial allowlist for discovery/selection', }, + { + key: 'remote', + names: ['--remote'], + type: 'boolean', + usageLabel: '--remote', + usageDescription: 'Doctor: check remote connection setup instead of local device inventory', + }, { key: 'activity', names: ['--activity'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 3c1120bd5..b79a72030 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -534,6 +534,7 @@ React Native dev loop: Before QA/dogfood runs, use doctor to separate environment setup from app failures: agent-device doctor --platform android agent-device doctor --platform ios + agent-device doctor --remote --remote-config ./remote.json For "start from screen X" flows, prefer open --relaunch before the first snapshot so the app does not reuse a prior in-progress navigation state. JS-only change with Metro connected: agent-device metro reload diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index d8229689c..303234289 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -17,7 +17,7 @@ import { withProviderScenarioTempDir, } from './harness.ts'; -test('Provider-backed integration doctor infers Android RN/Metro readiness through daemon route', async () => { +test('Provider-backed integration doctor infers Android RN/Metro readiness through daemon route without resolving a default device', async () => { const server = await startMetroStatusServer(); const adbCalls: string[][] = []; const adbProvider: AndroidAdbProvider = { @@ -52,9 +52,10 @@ test('Provider-backed integration doctor infers Android RN/Metro readiness throu const data = response.json.result.data; assert.equal(data.status, 'pass'); assert.equal(data.kind, 'react-native'); + assertDoctorCheck(data, 'device', 'pass'); assertDoctorCheck(data, 'metro', 'pass'); - assertDoctorCheck(data, 'android-reverse', 'pass'); - assert.deepEqual(adbCalls, [['reverse', '--list']]); + assertNoDoctorCheck(data, 'android-reverse'); + assert.deepEqual(adbCalls, []); }, ), ); @@ -95,6 +96,50 @@ test('Provider-backed integration doctor runs predictably for supported platform ); }); +test('Provider-backed integration doctor --remote skips local device inventory', async () => { + let inventoryCalls = 0; + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => { + inventoryCalls += 1; + return [PROVIDER_SCENARIO_ANDROID]; + }, + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', [], { + remote: true, + daemonBaseUrl: 'https://example.invalid/agent-device', + daemonAuthToken: 'secret', + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'pass'); + assertDoctorCheck(data, 'remote-connection', 'pass'); + assertNoDoctorCheck(data, 'device'); + assert.equal(inventoryCalls, 0); + }, + ); +}); + +test('Provider-backed integration doctor --remote fails without remote scope', async () => { + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', [], { remote: true }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'fail'); + assertDoctorCheck(data, 'remote-connection', 'fail'); + assertNoDoctorCheck(data, 'device'); + }, + ); +}); + function writePackageJson(dir: string, value: Record): void { fs.writeFileSync(`${dir}/package.json`, `${JSON.stringify(value)}\n`); } @@ -109,6 +154,14 @@ function assertDoctorCheck( assert.equal(check.status, status); } +function assertNoDoctorCheck(data: { checks: Array<{ id: string }> }, id: string): void { + assert.equal( + data.checks.some((entry) => entry.id === id), + false, + `unexpected ${id}: ${JSON.stringify(data.checks)}`, + ); +} + function androidDoctorAdbResult( args: string[], metroPort: number, From 37d26f4d8509d6b98b6d77023ed125eaedf01f72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:50:22 +0200 Subject: [PATCH 09/12] refactor: reuse device inventory selectors --- src/core/dispatch-resolve.ts | 99 ++++++++------------ src/daemon/handlers/session-doctor-device.ts | 92 +++++------------- src/utils/device.ts | 34 ++++++- 3 files changed, 90 insertions(+), 135 deletions(-) diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 0499de222..3fb6ae305 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -16,7 +16,7 @@ import { } from '../utils/device-isolation.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; import { listLocalDeviceInventory, type DeviceInventoryRequest } from './platform-inventory.ts'; -type ResolveDeviceFlags = Pick< +export type ResolveDeviceFlags = Pick< CliFlags, | 'platform' | 'target' @@ -112,21 +112,11 @@ function hasExplicitAppleDeviceSelector(selector: AppleDeviceSelector): boolean } export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise { - const normalizedPlatform = normalizePlatformSelector(flags.platform); - const iosSimulatorSetPath = resolveAppleSimulatorSetPathForSelector({ - simulatorSetPath: resolveIosSimulatorDeviceSetPath(flags.iosSimulatorDeviceSet), - platform: normalizedPlatform, - target: flags.target, - }); - const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags.androidDeviceAllowlist); - const cacheKey = buildResolveTargetDeviceCacheKey({ - flags, - normalizedPlatform, - iosSimulatorSetPath, - androidSerialAllowlist, - }); + const inventoryRequest = buildDeviceInventoryRequestFromFlags(flags); + const { iosSimulatorSetPath, ...selector } = inventoryRequest; + const cacheKey = buildResolveTargetDeviceCacheKey(inventoryRequest); const diagnosticData = { - platform: normalizedPlatform, + platform: inventoryRequest.platform, target: flags.target, cacheHit: false, }; @@ -138,27 +128,7 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise(task: () => Promise): Promise { if (resolveTargetDeviceCacheScope.getStore()) return await task(); return await resolveTargetDeviceCacheScope.run(new Map(), task); @@ -256,22 +249,6 @@ function cacheResolvedTargetDevice(cacheKey: string, device: DeviceInfo): Device return device; } -function buildResolveTargetDeviceCacheKey(params: { - flags: ResolveDeviceFlags; - normalizedPlatform?: PlatformSelector; - iosSimulatorSetPath?: string; - androidSerialAllowlist?: ReadonlySet; -}): string { - const { flags, normalizedPlatform, iosSimulatorSetPath, androidSerialAllowlist } = params; - return JSON.stringify({ - platform: normalizedPlatform, - target: flags.target, - device: flags.device, - udid: flags.udid, - serial: flags.serial, - iosSimulatorSetPath, - androidSerialAllowlist: androidSerialAllowlist - ? Array.from(androidSerialAllowlist).sort() - : undefined, - }); +function buildResolveTargetDeviceCacheKey(request: DeviceInventoryRequest): string { + return JSON.stringify(request); } diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index e34f720ca..d907087de 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -1,14 +1,11 @@ -import { listDeviceInventory } from '../../core/dispatch-resolve.ts'; -import type { DeviceInfo, DeviceTarget, Platform, PlatformSelector } from '../../utils/device.ts'; -import { - normalizePlatformSelector, - resolveAppleSimulatorSetPathForSelector, -} from '../../utils/device.ts'; import { - resolveAndroidSerialAllowlist, - resolveIosSimulatorDeviceSetPath, -} from '../../utils/device-isolation.ts'; -import { AppError, normalizeError } from '../../utils/errors.ts'; + buildDeviceInventoryRequestFromFlags, + listDeviceInventory, +} from '../../core/dispatch-resolve.ts'; +import type { DeviceInventoryRequest } from '../../core/platform-inventory.ts'; +import type { DeviceInfo, DeviceTarget, Platform, PlatformSelector } from '../../utils/device.ts'; +import { matchesDeviceSelector } from '../../utils/device.ts'; +import { normalizeError } from '../../utils/errors.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; @@ -92,42 +89,28 @@ export function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): function deviceInventorySelector(req: DaemonRequest, session: SessionState | undefined) { const flags = req.flags ?? {}; - const platform = normalizePlatformSelector(flags.platform) ?? session?.device.platform; - const target = flags.target ?? session?.device.target; - if (target && !platform) { - throw new AppError( - 'INVALID_ARGS', - 'Device target selector requires --platform. Use --platform ios|macos|android|linux|apple with --target mobile|tv|desktop.', - ); - } - const iosSimulatorSetPath = resolveAppleSimulatorSetPathForSelector({ - simulatorSetPath: resolveIosSimulatorDeviceSetPath(flags.iosSimulatorDeviceSet), - platform, - target, - }); - const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags.androidDeviceAllowlist); - return { - platform, - target, - deviceName: flags.device, + return buildDeviceInventoryRequestFromFlags({ + platform: flags.platform ?? session?.device.platform, + target: flags.target ?? session?.device.target, + device: flags.device, udid: flags.udid, serial: flags.serial, - iosSimulatorSetPath, - androidSerialAllowlist: androidSerialAllowlist - ? Array.from(androidSerialAllowlist).sort() - : undefined, - }; + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }); } function filterInventoryForSelector( devices: DeviceInfo[], - selector: ReturnType, + selector: DeviceInventoryRequest, ): DeviceInfo[] { - return devices.filter((device) => deviceMatchesSelector(device, selector)); + return devices.filter((device) => + matchesDeviceSelector(device, selector, { includeExplicitSelectors: true }), + ); } async function readDoctorDeviceInventory( - selector: ReturnType, + selector: DeviceInventoryRequest, ): Promise<{ devices: DeviceInfo[]; failures: DoctorInventoryFailure[] }> { if (selector.platform) { return { devices: await listDeviceInventory(selector), failures: [] }; @@ -155,38 +138,9 @@ function inventoryFailure(platform: PlatformSelector, error: unknown): DoctorInv }; } -function deviceMatchesSelector( - device: DeviceInfo, - selector: ReturnType, -): boolean { - return [ - optionalPlatformMatches(selector.platform, device), - optionalValueMatches(selector.target, device.target), - optionalValueMatches(selector.deviceName, device.name), - optionalValueMatches(selector.udid, device.id), - optionalValueMatches(selector.serial, device.id), - ].every(Boolean); -} - -function optionalPlatformMatches( - selector: PlatformSelector | undefined, - device: DeviceInfo, -): boolean { - return selector === undefined || deviceMatchesPlatform(device, selector); -} - -function optionalValueMatches(expected: T | undefined, actual: T | undefined): boolean { - return expected === undefined || actual === expected; -} - -function deviceMatchesPlatform(device: DeviceInfo, selector: PlatformSelector): boolean { - if (selector === 'apple') return device.platform === 'ios' || device.platform === 'macos'; - return device.platform === selector; -} - function deviceInventorySummary( devices: DeviceInfo[], - selector: Pick, 'platform' | 'target'>, + selector: Pick, failures: DoctorInventoryFailure[], ): string { if (devices.length === 0) { @@ -203,7 +157,7 @@ function deviceInventorySummary( } function deviceInventoryLabel( - selector: Pick, 'platform' | 'target'>, + selector: Pick, ): string { const platform = selector.platform ? platformLabel(selector.platform) : 'local'; return selector.target ? `${platform} ${selector.target}` : platform; @@ -229,9 +183,7 @@ function plural(count: number, singular: string): string { return count === 1 ? singular : `${singular}s`; } -function deviceInventoryCommand( - selector: Pick, 'platform'>, -): string { +function deviceInventoryCommand(selector: Pick): string { return selector.platform ? `agent-device devices --platform ${selector.platform}` : 'agent-device devices'; diff --git a/src/utils/device.ts b/src/utils/device.ts index 83e695947..16dbc672f 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -20,7 +20,7 @@ export type DeviceInfo = { simulatorSetPath?: string; }; -type DeviceSelector = { +export type DeviceSelector = { platform?: PlatformSelector; target?: DeviceTarget; deviceName?: string; @@ -111,9 +111,35 @@ export async function resolveDevice( } function filterDeviceCandidates(devices: DeviceInfo[], selector: DeviceSelector): DeviceInfo[] { - return devices - .filter((device) => matchesPlatformSelector(device.platform, selector.platform)) - .filter((device) => !selector.target || (device.target ?? 'mobile') === selector.target); + return devices.filter((device) => matchesDeviceSelector(device, selector)); +} + +export function matchesDeviceSelector( + device: DeviceInfo, + selector: DeviceSelector, + options: { includeExplicitSelectors?: boolean } = {}, +): boolean { + return ( + matchesPlatformSelector(device.platform, selector.platform) && + (!selector.target || (device.target ?? 'mobile') === selector.target) && + (!options.includeExplicitSelectors || matchesExplicitDeviceSelector(device, selector)) + ); +} + +function matchesExplicitDeviceSelector(device: DeviceInfo, selector: DeviceSelector): boolean { + if (selector.udid && !(device.id === selector.udid && isApplePlatform(device.platform))) { + return false; + } + if (selector.serial && !(device.id === selector.serial && device.platform === 'android')) { + return false; + } + if ( + selector.deviceName && + normalizeDeviceName(device.name) !== normalizeDeviceName(selector.deviceName) + ) { + return false; + } + return true; } function sortDeviceCandidatesForSelection(candidates: DeviceInfo[]): DeviceInfo[] { From 53ff403947034e6c826ec0fddfb1c7d8711e961c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 14:57:24 +0200 Subject: [PATCH 10/12] fix: summarize doctor inventory by platform --- src/daemon/handlers/session-doctor-device.ts | 44 +++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index d907087de..077127215 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -23,6 +23,8 @@ type DoctorInventoryFailure = { code?: string; }; +type DoctorInventoryGroup = 'android' | 'apple' | 'linux' | 'web'; + export async function appendDeviceInventoryCheck( checks: DoctorCheck[], req: DaemonRequest, @@ -150,10 +152,12 @@ function deviceInventorySummary( return `No ${deviceInventoryLabel(selector)} devices found.`; } const booted = devices.filter((device) => device.booted === true).length; - return `${devices.length} ${deviceInventoryLabel(selector)} ${plural( + const summary = `${devices.length} ${deviceInventoryLabel(selector)} ${plural( devices.length, 'device', - )} available; ${booted} booted.`; + )} available; ${booted} booted`; + const platformBreakdown = deviceInventorySummaryBreakdown(devices, selector); + return platformBreakdown ? `${summary} (${platformBreakdown}).` : `${summary}.`; } function deviceInventoryLabel( @@ -170,6 +174,42 @@ function inventoryFailureSummary(failures: DoctorInventoryFailure[]): string { .join('; '); } +function deviceInventorySummaryBreakdown( + devices: DeviceInfo[], + selector: Pick, +): string | undefined { + if (selector.platform || selector.target) return undefined; + const groups = deviceInventoryGroups(devices); + return (['android', 'apple', 'linux', 'web'] as const) + .flatMap((group) => { + const entry = groups[group]; + return entry.available > 0 + ? [`${entry.label} ${entry.available} available, ${entry.booted} booted`] + : []; + }) + .join('; '); +} + +function deviceInventoryGroups( + devices: DeviceInfo[], +): Record { + const groups = { + android: { label: 'Android', available: 0, booted: 0 }, + apple: { label: 'Apple', available: 0, booted: 0 }, + linux: { label: 'Linux', available: 0, booted: 0 }, + web: { label: 'web', available: 0, booted: 0 }, + }; + for (const device of devices) { + const group = + device.platform === 'ios' || device.platform === 'macos' + ? groups.apple + : groups[device.platform]; + group.available += 1; + if (device.booted === true) group.booted += 1; + } + return groups; +} + function platformLabel(platform: PlatformSelector): string { if (platform === 'ios') return 'iOS'; if (platform === 'macos') return 'macOS'; From 58c0c9596c7ce38a10c4d2a65ce284f708b68055 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:10:18 +0200 Subject: [PATCH 11/12] fix: show metro cwd in doctor --- .../__tests__/session-doctor-metro.test.ts | 54 ++++++++++++++ src/daemon/handlers/session-doctor-metro.ts | 72 ++++++++++++++++++- .../provider-scenarios/doctor.test.ts | 14 +++- 3 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 src/daemon/handlers/__tests__/session-doctor-metro.test.ts diff --git a/src/daemon/handlers/__tests__/session-doctor-metro.test.ts b/src/daemon/handlers/__tests__/session-doctor-metro.test.ts new file mode 100644 index 000000000..36edd4746 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-doctor-metro.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { test } from 'vitest'; +import { probeMetro } from '../session-doctor-metro.ts'; + +test('probeMetro includes local process cwd when it can resolve the Metro listener', async () => { + const server = await startMetroStatusServer(); + const cwd = '/tmp/example-app'; + try { + const check = await probeMetro('127.0.0.1', server.port, 'react-native', { + resolveProcessInfo: async () => ({ pid: 12345, cwd }), + }); + + assert.equal(check.status, 'pass'); + assert.match(check.summary, /cwd: \/tmp\/example-app/); + assert.deepEqual(check.evidence?.process, { pid: 12345, cwd }); + } finally { + await server.close(); + } +}); + +test('probeMetro ignores local process lookup failures', async () => { + const server = await startMetroStatusServer(); + try { + const check = await probeMetro('127.0.0.1', server.port, 'react-native', { + resolveProcessInfo: async () => { + throw new Error('lookup failed'); + }, + }); + + assert.equal(check.status, 'pass'); + assert.equal(check.summary, `Metro is reachable at http://127.0.0.1:${server.port}/status.`); + assert.equal(check.evidence?.process, undefined); + } finally { + await server.close(); + } +}); + +async function startMetroStatusServer(): Promise<{ port: number; close: () => Promise }> { + const server = http.createServer((_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain' }); + res.end('packager-status:running'); + }); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const address = server.address(); + assert.ok(address && typeof address === 'object'); + return { + port: address.port, + close: async () => + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ), + }; +} diff --git a/src/daemon/handlers/session-doctor-metro.ts b/src/daemon/handlers/session-doctor-metro.ts index d3cc0a11a..109606947 100644 --- a/src/daemon/handlers/session-doctor-metro.ts +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -1,27 +1,50 @@ import type { DoctorCheck, DoctorKind } from './session-doctor-types.ts'; +import { runCmd } from '../../utils/exec.ts'; const METRO_PROBE_TIMEOUT_MS = 1500; +const METRO_PROCESS_LOOKUP_TIMEOUT_MS = 1500; + +export type MetroProcessInfo = { + pid: number; + cwd?: string; +}; + +type MetroProbeOptions = { + resolveProcessInfo?: (host: string, port: number) => Promise; +}; export async function probeMetro( host: string, port: number, kind: DoctorKind, + options: MetroProbeOptions = {}, ): Promise { const url = `http://${host}:${port}/status`; + const processInfoPromise = (options.resolveProcessInfo ?? resolveMetroProcessInfo)( + host, + port, + ).catch(() => undefined); try { const response = await fetch(url, { signal: AbortSignal.timeout(METRO_PROBE_TIMEOUT_MS) }); const text = await response.text(); const running = response.ok && text.toLowerCase().includes('packager-status:running'); + const processInfo = running ? await processInfoPromise : undefined; return { id: 'metro', status: running ? 'pass' : 'warn', summary: running - ? `Metro is reachable at ${url}.` + ? metroRunningSummary(url, processInfo) : `Metro responded at ${url}, but did not report packager-status:running.`, hint: running ? undefined : 'Verify this is the Metro instance for the target app, or restart Metro.', - evidence: { url, statusCode: response.status, body: text.slice(0, 120), kind }, + evidence: { + url, + statusCode: response.status, + body: text.slice(0, 120), + kind, + ...(processInfo ? { process: processInfo } : {}), + }, }; } catch (error) { return { @@ -34,3 +57,48 @@ export async function probeMetro( }; } } + +function metroRunningSummary(url: string, processInfo: MetroProcessInfo | undefined): string { + if (processInfo?.cwd) { + return `Metro is reachable at ${url} (cwd: ${processInfo.cwd}).`; + } + return `Metro is reachable at ${url}.`; +} + +async function resolveMetroProcessInfo( + host: string, + port: number, +): Promise { + if (!isLocalHost(host)) return undefined; + const pid = await findListeningProcessId(port); + if (pid === undefined) return undefined; + return { pid, cwd: await readProcessCwd(pid) }; +} + +function isLocalHost(host: string): boolean { + return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0.0.0.0'; +} + +async function findListeningProcessId(port: number): Promise { + const result = await runCmd('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fp'], { + allowFailure: true, + timeoutMs: METRO_PROCESS_LOOKUP_TIMEOUT_MS, + }); + if (result.exitCode !== 0) return undefined; + return result.stdout + .split('\n') + .map((line) => (line.startsWith('p') ? Number.parseInt(line.slice(1), 10) : NaN)) + .find((pid) => Number.isInteger(pid) && pid > 0); +} + +async function readProcessCwd(pid: number): Promise { + const result = await runCmd('lsof', ['-nP', '-a', '-p', String(pid), '-d', 'cwd', '-Fn'], { + allowFailure: true, + timeoutMs: METRO_PROCESS_LOOKUP_TIMEOUT_MS, + }); + if (result.exitCode !== 0) return undefined; + return result.stdout + .split('\n') + .find((line) => line.startsWith('n') && line.length > 1) + ?.slice(1); +} diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 303234289..85c8e45cd 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -50,7 +50,7 @@ test('Provider-backed integration doctor infers Android RN/Metro readiness throu ); assertRpcOk(response); const data = response.json.result.data; - assert.equal(data.status, 'pass'); + assert.equal(data.status, 'pass', JSON.stringify(data.checks)); assert.equal(data.kind, 'react-native'); assertDoctorCheck(data, 'device', 'pass'); assertDoctorCheck(data, 'metro', 'pass'); @@ -145,13 +145,21 @@ function writePackageJson(dir: string, value: Record): void { } function assertDoctorCheck( - data: { checks: Array<{ id: string; status: string }> }, + data: { + checks: Array<{ + id: string; + status: string; + summary: string; + evidence?: Record; + }>; + }, id: string, status: string, -): void { +): { id: string; status: string; summary: string; evidence?: Record } { const check = data.checks.find((entry) => entry.id === id); assert.ok(check, `missing ${id}: ${JSON.stringify(data.checks)}`); assert.equal(check.status, status); + return check; } function assertNoDoctorCheck(data: { checks: Array<{ id: string }> }, id: string): void { From 80975b4f3211a1c0d71245135e8a8de353a4fbc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 15:55:14 +0200 Subject: [PATCH 12/12] refactor: simplify metro doctor lookup --- src/daemon/handlers/session-doctor-metro.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/daemon/handlers/session-doctor-metro.ts b/src/daemon/handlers/session-doctor-metro.ts index 109606947..a2d1adef5 100644 --- a/src/daemon/handlers/session-doctor-metro.ts +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -20,15 +20,18 @@ export async function probeMetro( options: MetroProbeOptions = {}, ): Promise { const url = `http://${host}:${port}/status`; - const processInfoPromise = (options.resolveProcessInfo ?? resolveMetroProcessInfo)( - host, - port, - ).catch(() => undefined); try { const response = await fetch(url, { signal: AbortSignal.timeout(METRO_PROBE_TIMEOUT_MS) }); const text = await response.text(); const running = response.ok && text.toLowerCase().includes('packager-status:running'); - const processInfo = running ? await processInfoPromise : undefined; + let processInfo: MetroProcessInfo | undefined; + if (running) { + try { + processInfo = await (options.resolveProcessInfo ?? resolveMetroProcessInfo)(host, port); + } catch { + processInfo = undefined; + } + } return { id: 'metro', status: running ? 'pass' : 'warn',