From ac8f43aa35bfa595e511553fcd500a26f57f399d 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/29] feat: add doctor command --- src/__tests__/cli-network.test.ts | 39 ++ src/__tests__/test-utils/color.ts | 14 + src/__tests__/test-utils/index.ts | 2 + src/cli-doctor-output.ts | 39 ++ src/cli-status-markers.ts | 21 + src/cli/parser/cli-flags.ts | 24 + src/cli/parser/cli-help.ts | 3 + src/client/client-types.ts | 9 + src/client/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 | 91 +++ src/commands/management/output.ts | 36 ++ src/core/command-descriptor/registry.ts | 11 + .../__tests__/daemon-command-registry.test.ts | 5 + src/daemon/client/daemon-client-progress.ts | 4 + src/daemon/handlers/session-doctor.ts | 543 ++++++++++++++++++ src/daemon/handlers/session.ts | 10 + src/daemon/session-store.ts | 6 + src/kernel/device.ts | 63 +- .../apple/core/__tests__/devices.test.ts | 76 +++ src/platforms/apple/core/devices.ts | 32 +- src/utils/__tests__/args.test.ts | 50 ++ src/utils/__tests__/device.test.ts | 46 ++ .../provider-scenarios/doctor.test.ts | 146 +++++ 26 files changed, 1311 insertions(+), 26 deletions(-) create mode 100644 src/__tests__/test-utils/color.ts create mode 100644 src/cli-doctor-output.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-network.test.ts b/src/__tests__/cli-network.test.ts index 784970b94..ed4223a4e 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -149,6 +149,45 @@ test('test command prints suite summary and exits non-zero on failures', async ( assert.match(result.stdout, /Test summary: 1 passed \(3\), 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__/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-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/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index 9dfbbf203..5c9ed858f 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -71,6 +71,9 @@ export type CliFlags = CloudProviderProfileFields & iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; + targetApp?: string; + doctorReactNative?: boolean; + doctorExpo?: boolean; session?: string; metroHost?: string; metroPort?: number; @@ -678,6 +681,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/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 3f588a373..0b92c5443 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -552,6 +552,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/client/client-types.ts b/src/client/client-types.ts index 8a70f01d7..834ad9a97 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -504,6 +504,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; @@ -520,6 +527,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>; }; @@ -910,6 +918,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & metroPort?: number; bundleUrl?: string; launchUrl?: string; + targetApp?: string; appsFilter?: AppsFilter; installSource?: DaemonInstallSource; retainMaterializedPaths?: boolean; diff --git a/src/client/client.ts b/src/client/client.ts index deb4efa4e..f59739a88 100644 --- a/src/client/client.ts +++ b/src/client/client.ts @@ -123,6 +123,7 @@ export function createAgentDeviceClient( 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 48f603af6..a3241e521 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -11,6 +11,7 @@ export const PUBLIC_COMMANDS = { close: 'close', clipboard: 'clipboard', devices: 'devices', + doctor: 'doctor', diff: 'diff', fill: 'fill', find: 'find', @@ -118,6 +119,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 e41c632d4..b044f471f 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -2,6 +2,7 @@ import { defineCommandFamilyFromFacets } from '../family/types.ts'; import { artifactsCommandFacet } from './artifacts.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'; @@ -13,6 +14,7 @@ export const managementCommandFamily = defineCommandFamilyFromFacets({ commands: [ ...deviceManagementCommandFacets, artifactsCommandFacet, + doctorCommandFacet, prepareCommandFacet, appsCommandFacet, sessionCommandFacet, diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index 41941503b..5c2610a63 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -1,5 +1,8 @@ import { describe, expect, test } from 'vitest'; import { managementCliOutputFormatters, openCliOutput } from './output.ts'; +import { doctorCliOutput } 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', () => { @@ -66,3 +69,91 @@ describe('artifactsCliOutput', () => { ); }); }); + +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 3cfb7022b..f6ab4f092 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -19,6 +19,11 @@ import type { import type { CloudArtifactsResult } from '../../cloud-artifacts.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, @@ -115,10 +120,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'], @@ -152,3 +179,12 @@ function formatCloudArtifactsRetryCommand(result: CloudArtifactsResult): string if (!result.providerSessionId) return undefined; return `agent-device artifacts ${result.providerSessionId} --provider ${result.provider} --json`; } + +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/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index fadd8dfdb..c4047c682 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -113,6 +113,17 @@ const RAW_COMMAND_DESCRIPTORS = [ }, batchable: true, }, + { + name: PUBLIC_COMMANDS.doctor, + daemon: { + route: 'session', + sessionKind: 'inventory', + lockPolicySelectorOverride: true, + allowSessionlessDefaultDevice: allowAnyDeviceSessionless, + ...REQUEST_EXECUTION_EXEMPT, + }, + batchable: true, + }, { name: PUBLIC_COMMANDS.apps, daemon: { 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/client/daemon-client-progress.ts b/src/daemon/client/daemon-client-progress.ts index ef5f86e0e..6eedd1349 100644 --- a/src/daemon/client/daemon-client-progress.ts +++ b/src/daemon/client/daemon-client-progress.ts @@ -4,6 +4,7 @@ import { AppError } from '../../kernel/errors.ts'; import type { DaemonRequest, DaemonResponse } from '../types.ts'; import type { RequestProgressEvent, RequestProgressSink } from '../request-progress.ts'; import { consumeTextLines } from '../../utils/line-stream.ts'; +import { markDoctorProgressRendered } from '../../cli-doctor-output.ts'; import { isDaemonProgressEnvelope, isDaemonResponseEnvelope, @@ -19,6 +20,7 @@ type ProgressResponseFormat = 'socket-legacy' | 'ndjson-envelope'; function emitProgressEvent( event: RequestProgressEvent, options: { + req: DaemonRequest; onProgress?: RequestProgressSink; }, ): void { @@ -27,6 +29,7 @@ function emitProgressEvent( return; } if (event.type === 'command') { + if (options.req.command === 'doctor') markDoctorProgressRendered(); process.stderr.write(`${event.message}\n`); } } @@ -71,6 +74,7 @@ function createProgressLineReader(options: { if (isDaemonProgressEnvelope(message)) { try { emitProgressEvent(message.event, { + req: options.req, onProgress: options.onProgress, }); return false; diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts new file mode 100644 index 000000000..8e0185203 --- /dev/null +++ b/src/daemon/handlers/session-doctor.ts @@ -0,0 +1,543 @@ +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: 'command', + status: 'progress', + message: formatDoctorProgressMessage(check), + }); +} + +function formatDoctorProgressMessage(check: DoctorCheck): string { + return [formatDoctorProgressSummary(check), ...formatDoctorProgressDetails(check)].join('\n'); +} + +function formatDoctorProgressSummary(check: DoctorCheck): string { + return `${doctorProgressMarker(check.status)} ${check.id}: ${check.summary}`; +} + +function formatDoctorProgressDetails(check: DoctorCheck): string[] { + if (check.status !== 'fail' && check.status !== 'warn') return []; + if (check.command) return [` run: ${check.command}`]; + if (check.hint) return [` hint: ${check.hint}`]; + return []; +} + +function doctorProgressMarker(status: DoctorStatus): string { + if (status === 'pass') return '✓'; + if (status === 'fail') return '⨯'; + if (status === 'warn') return '!'; + return '-'; +} + +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 e158343c8..632401cf3 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -36,6 +36,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'; import { LeaseRegistry } from '../lease-registry.ts'; import { PREPARE_REQUEST_TIMEOUT_MS } from '../request-timeouts.ts'; @@ -247,6 +248,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/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/kernel/device.ts b/src/kernel/device.ts index 1a431220e..a8ef4bcb2 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -124,6 +124,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'; } @@ -143,6 +152,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)); @@ -190,15 +202,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/src/platforms/apple/core/__tests__/devices.test.ts b/src/platforms/apple/core/__tests__/devices.test.ts index 601c1f01c..d0e121683 100644 --- a/src/platforms/apple/core/__tests__/devices.test.ts +++ b/src/platforms/apple/core/__tests__/devices.test.ts @@ -84,6 +84,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/apple/core/devices.ts b/src/platforms/apple/core/devices.ts index 08ad5a893..116b41d4c 100644 --- a/src/platforms/apple/core/devices.ts +++ b/src/platforms/apple/core/devices.ts @@ -2,7 +2,12 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { AppError } from '../../../kernel/errors.ts'; -import type { AppleOS, DeviceInfo, DeviceTarget } from '../../../kernel/device.ts'; +import { + sortAppleDevicesForSelection, + type AppleOS, + type DeviceInfo, + type DeviceTarget, +} from '../../../kernel/device.ts'; import { resolveIosSimulatorDeviceSetPath } from '../../../utils/device-isolation.ts'; import { buildHostMacDevice } from '../os/macos/devices.ts'; import { buildSimctlArgs } from './simctl.ts'; @@ -190,23 +195,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( @@ -391,7 +383,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([ @@ -400,5 +392,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 ea76bebf0..74f1e3df0 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'], @@ -1820,6 +1862,14 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); assert.match(help, /For app\/package launches, run metro prepare/); + 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 bcf479cc1..a599949f8 100644 --- a/src/utils/__tests__/device.test.ts +++ b/src/utils/__tests__/device.test.ts @@ -189,6 +189,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/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 d33aa9e49b083835c8d870f7ac9be7c2c5c85ffa 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/29] fix: reduce doctor command complexity --- src/daemon/handlers/session-doctor.ts | 152 ++++++++++++++-------- src/kernel/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 8e0185203..cc8b0bb35 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/kernel/device.ts b/src/kernel/device.ts index a8ef4bcb2..17d461253 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -142,81 +142,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( @@ -232,20 +248,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 6b40168333339de82fe48a0cbd40d550049dfb54 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/29] 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 ac7f03e62..3ed6fe060 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'], @@ -305,6 +306,8 @@ function summarizeProviderScenarioFlagExclusions() { 'shardSplit', 'searchPath', 'stepsFile', + 'doctorReactNative', + 'doctorExpo', 'proxyHost', 'proxyPort', ], From 301f7e72c7c248fae055376864171e4459d5a74c 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/29] fix: simplify doctor setup --- scripts/integration-progress-model.ts | 3 - src/cli/parser/cli-flags.ts | 24 ----- src/cli/parser/cli-help.ts | 4 +- src/client/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/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 3ed6fe060..ac7f03e62 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'], @@ -306,8 +305,6 @@ function summarizeProviderScenarioFlagExclusions() { 'shardSplit', 'searchPath', 'stepsFile', - 'doctorReactNative', - 'doctorExpo', 'proxyHost', 'proxyPort', ], diff --git a/src/cli/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index 5c9ed858f..9dfbbf203 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -71,9 +71,6 @@ export type CliFlags = CloudProviderProfileFields & iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; - targetApp?: string; - doctorReactNative?: boolean; - doctorExpo?: boolean; session?: string; metroHost?: string; metroPort?: number; @@ -681,27 +678,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/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 0b92c5443..0595bb72c 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -553,8 +553,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/client/client-types.ts b/src/client/client-types.ts index 834ad9a97..ba1211555 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -504,12 +504,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; @@ -918,7 +913,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 cc8b0bb35..a5ef802f1 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 74f1e3df0..d594a90f3 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'); }, }, { @@ -1862,14 +1839,8 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); assert.match(help, /For app\/package launches, run metro prepare/); - 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/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 37682f51d..8ba90a4a5 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -24,7 +24,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; @@ -65,7 +65,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 }), @@ -129,7 +129,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, @@ -137,7 +137,8 @@ function commandRequest( command, positionals, flags, - meta, + runtime: options.runtime, + meta: options.meta, }; } From 74c93b48fd4899e4d0be50e160576afb4759e21a 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/29] 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 | 56 ++ .../handlers/session-doctor-react-native.ts | 51 ++ src/daemon/handlers/session-doctor-types.ts | 20 + src/daemon/handlers/session-doctor.ts | 584 +----------------- src/daemon/session-store.ts | 6 - 10 files changed, 614 insertions(+), 559 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..653382e3c --- /dev/null +++ b/src/daemon/handlers/session-doctor-output.ts @@ -0,0 +1,56 @@ +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: 'command', + status: 'progress', + message: formatDoctorProgressMessage(check), + }); +} + +function formatDoctorProgressMessage(check: DoctorCheck): string { + return [formatDoctorProgressSummary(check), ...formatDoctorProgressDetails(check)].join('\n'); +} + +function formatDoctorProgressSummary(check: DoctorCheck): string { + return `${doctorProgressMarker(check.status)} ${check.id}: ${check.summary}`; +} + +function formatDoctorProgressDetails(check: DoctorCheck): string[] { + if (check.status !== 'fail' && check.status !== 'warn') return []; + if (check.command) return [` run: ${check.command}`]; + if (check.hint) return [` hint: ${check.hint}`]; + return []; +} + +function doctorProgressMarker(status: DoctorStatus): string { + if (status === 'pass') return '✓'; + if (status === 'fail') return '⨯'; + if (status === 'warn') return '!'; + return '-'; +} 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 a5ef802f1..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,508 +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: 'command', - status: 'progress', - message: formatDoctorProgressMessage(check), - }); -} - -function formatDoctorProgressMessage(check: DoctorCheck): string { - return [formatDoctorProgressSummary(check), ...formatDoctorProgressDetails(check)].join('\n'); -} - -function formatDoctorProgressSummary(check: DoctorCheck): string { - return `${doctorProgressMarker(check.status)} ${check.id}: ${check.summary}`; -} - -function formatDoctorProgressDetails(check: DoctorCheck): string[] { - if (check.status !== 'fail' && check.status !== 'warn') return []; - if (check.command) return [` run: ${check.command}`]; - if (check.hint) return [` hint: ${check.hint}`]; - return []; -} - -function doctorProgressMarker(status: DoctorStatus): string { - if (status === 'pass') return '✓'; - if (status === 'fail') return '⨯'; - if (status === 'warn') return '!'; - return '-'; -} - -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 9767c139573331289e6858def8f885c9ff57fa8e 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/29] 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 e7cd5eb8316404df42537cd53b217b040294d232 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/29] 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 6c74a93bb..49450913b 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -999,6 +999,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 d18a5f7cf..f615597e2 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 7f5f7e09c..fd05cd6ee 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 5dbb09b33..cbe7ce46b 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 b062b6d7845c7639ff0110cf9143c1a1cc9a7c7b 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/29] fix: report doctor device inventory --- src/cli/parser/cli-flags.ts | 8 + src/cli/parser/cli-help.ts | 1 + 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 +- .../provider-scenarios/doctor.test.ts | 59 ++++- 9 files changed, 329 insertions(+), 48 deletions(-) diff --git a/src/cli/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index 9dfbbf203..6c41cb41d 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -71,6 +71,7 @@ export type CliFlags = CloudProviderProfileFields & iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; + remote?: boolean; session?: string; metroHost?: string; metroPort?: number; @@ -678,6 +679,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/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 0595bb72c..06a7e9a62 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -555,6 +555,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/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 d594a90f3..7f6ce98ef 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'); }, @@ -1841,6 +1842,7 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /For app\/package launches, run metro prepare/); 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/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 3610e6c873b4c8e39f1a88eb35535420d71dbed9 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/29] refactor: reuse device inventory selectors --- src/core/dispatch-resolve.ts | 115 +++++++------------ src/daemon/handlers/session-doctor-device.ts | 95 +++++---------- src/kernel/device.ts | 34 +++++- 3 files changed, 98 insertions(+), 146 deletions(-) diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 2def47b95..400d8994d 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -15,7 +15,7 @@ import { } from '../utils/device-isolation.ts'; import type { CliFlags } from '../cli/parser/cli-flags.ts'; import { listLocalDeviceInventory, type DeviceInventoryRequest } from './platform-inventory.ts'; -type ResolveDeviceFlags = Pick< +export type ResolveDeviceFlags = Pick< CliFlags, | 'platform' | 'target' @@ -116,21 +116,11 @@ function hasExplicitAppleDeviceSelector(selector: AppleDeviceSelector): boolean } export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise { - const normalizedPlatform = 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, }; @@ -142,34 +132,7 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise(task: () => Promise): Promise { if (resolveTargetDeviceCacheScope.getStore()) return await task(); return await resolveTargetDeviceCacheScope.run(new Map(), task); @@ -268,26 +257,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, - leaseId: flags.leaseId, - leaseProvider: flags.leaseProvider, - deviceKey: flags.deviceKey, - clientId: flags.clientId, - 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..0b3091586 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -1,14 +1,16 @@ -import { listDeviceInventory } from '../../core/dispatch-resolve.ts'; -import type { DeviceInfo, DeviceTarget, Platform, PlatformSelector } from '../../utils/device.ts'; import { - normalizePlatformSelector, - resolveAppleSimulatorSetPathForSelector, -} from '../../utils/device.ts'; + buildDeviceInventoryRequestFromFlags, + listDeviceInventory, +} from '../../core/dispatch-resolve.ts'; +import type { DeviceInventoryRequest } from '../../core/platform-inventory.ts'; import { - resolveAndroidSerialAllowlist, - resolveIosSimulatorDeviceSetPath, -} from '../../utils/device-isolation.ts'; -import { AppError, normalizeError } from '../../utils/errors.ts'; + matchesDeviceSelector, + type DeviceInfo, + type DeviceTarget, + type Platform, + type PlatformSelector, +} from '../../kernel/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 +94,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 +143,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 +162,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 +188,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/kernel/device.ts b/src/kernel/device.ts index 17d461253..2a6dd7833 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -27,7 +27,7 @@ export type DeviceInfo = { simulatorSetPath?: string; }; -type DeviceSelector = { +export type DeviceSelector = { platform?: PlatformSelector; target?: DeviceTarget; deviceName?: string; @@ -159,9 +159,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 602ed254394f7f24e5abb99d3d8d45f34a54ac6d 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/29] 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 0b3091586..84a359f62 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -28,6 +28,8 @@ type DoctorInventoryFailure = { code?: string; }; +type DoctorInventoryGroup = 'android' | 'apple' | 'linux' | 'web'; + export async function appendDeviceInventoryCheck( checks: DoctorCheck[], req: DaemonRequest, @@ -155,10 +157,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( @@ -175,6 +179,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 ad511e875184195d4c1e1c065b40e400bd99fd76 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/29] 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 b9116e7e3d5169624745ad386d052047cbbaef7a 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/29] 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', From 023a0689d677daf1814386615fc082f6577e1e80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 30 Jun 2026 21:52:55 +0200 Subject: [PATCH 13/29] fix: update doctor imports after apple consolidation --- src/daemon/handlers/session-doctor-android.ts | 4 ++-- src/daemon/handlers/session-doctor-app.ts | 6 +++--- src/daemon/handlers/session-doctor-device.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/daemon/handlers/session-doctor-android.ts b/src/daemon/handlers/session-doctor-android.ts index 6a010ee11..bb915dcce 100644 --- a/src/daemon/handlers/session-doctor-android.ts +++ b/src/daemon/handlers/session-doctor-android.ts @@ -2,8 +2,8 @@ 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 { DeviceInfo } from '../../kernel/device.ts'; +import { normalizeError } from '../../kernel/errors.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; import type { DoctorCheck } from './session-doctor-types.ts'; diff --git a/src/daemon/handlers/session-doctor-app.ts b/src/daemon/handlers/session-doctor-app.ts index d3eb6a562..ef9e96f25 100644 --- a/src/daemon/handlers/session-doctor-app.ts +++ b/src/daemon/handlers/session-doctor-app.ts @@ -1,7 +1,7 @@ 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 { resolveIosApp } from '../../platforms/apple/core/apps.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; +import { normalizeError } from '../../kernel/errors.ts'; import type { SessionState } from '../types.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; import type { DoctorCheck } from './session-doctor-types.ts'; diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index 84a359f62..2b960fac0 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -10,7 +10,7 @@ import { type Platform, type PlatformSelector, } from '../../kernel/device.ts'; -import { normalizeError } from '../../utils/errors.ts'; +import { normalizeError } from '../../kernel/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'; From 49d0aeb0cb570c2b6e14ba932b63b373e2a44a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 07:06:06 +0200 Subject: [PATCH 14/29] feat: make doctor Metro probe controllable and surface hidden toolchain failures Two gaps found while verifying the doctor command on a real environment: - Metro host/port were uncontrollable from the CLI: --metro-host/--metro-port were rejected by allowedFlags, and readDoctorOptions only read them from req.runtime (populated by remote/connection profiles, never a plain CLI flag). The Metro check's own hint told users to 'pass the correct --metro-host/--metro-port', which did not exist. Declare the flags and read them from req.flags (runtime kept as fallback) so the probe can target any endpoint, e.g. from outside an RN/Expo project directory. - A broken per-platform toolchain was silently hidden: readDoctorDeviceInventory dropped inventory failures whenever any other platform returned devices, so a broken Xcode or Android SDK still reported a green 'pass'. Keep the failures and surface each as a warn (device-) when other platforms have devices; scoped --platform runs stay quiet. --- src/commands/management/doctor.ts | 17 +++++- src/daemon/handlers/session-doctor-device.ts | 21 ++++++- src/daemon/handlers/session-doctor-options.ts | 12 +++- .../provider-scenarios/doctor.test.ts | 56 +++++++++++++++++++ 4 files changed, 100 insertions(+), 6 deletions(-) diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index 6446791aa..19f6558c1 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -15,6 +15,11 @@ const doctorCommandMetadata = defineFieldCommandMetadata( remote: commandInput.booleanField( 'Check remote connection setup instead of local device inventory.', ), + metroHost: commandInput.stringField('Metro host to probe (forces a Metro reachability check).'), + metroPort: commandInput.integerField( + 'Metro port to probe (forces a Metro reachability check).', + { min: 1, max: 65535 }, + ), }, ); @@ -23,17 +28,23 @@ const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, ( ); const doctorCliSchema = { - usageOverride: 'doctor [--platform ios|android|macos|linux|web|apple] [--remote]', + usageOverride: + 'doctor [--platform ios|android|macos|linux|web|apple] [--metro-host ] [--metro-port ] [--remote]', helpDescription: - '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.', + '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. Pass --metro-host/--metro-port to force a Metro probe against a specific endpoint (e.g. outside an RN/Expo project directory). 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'], + allowedFlags: ['remote', 'metroHost', 'metroPort'], } as const satisfies CommandSchemaOverride; const doctorCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), remote: flags.remote, + metroHost: flags.metroHost, + metroPort: flags.metroPort, }); +// Both the field-command definition (client.command.doctor) and this facet +// cli reader forward metroHost/metroPort so the Metro probe is controllable +// regardless of which dispatch path executes. 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 2b960fac0..614776c3b 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -51,6 +51,13 @@ export async function appendDeviceInventoryCheck( command: devices.length === 0 ? deviceInventoryCommand(selector) : undefined, evidence: deviceInventoryEvidence(devices, inventory.failures), }); + // When some platforms had devices, the main check passes — but a platform whose + // inventory threw (e.g. a broken Xcode or Android SDK) must not be silently hidden. + if (devices.length > 0) { + for (const failure of inventory.failures) { + appendDoctorCheck(checks, inventoryFailureCheck(failure)); + } + } return { devices, platform: selector.platform, target: selector.target }; } catch (error) { const normalized = normalizeError(error); @@ -132,7 +139,19 @@ async function readDoctorDeviceInventory( failures.push(inventoryFailure(platform, error)); } } - return { devices, failures: devices.length === 0 ? failures : [] }; + return { devices, failures }; +} + +function inventoryFailureCheck(failure: DoctorInventoryFailure): DoctorCheck { + return { + id: `device-${failure.platform}`, + status: 'warn', + summary: `${platformLabel(failure.platform)} device inventory could not be read: ${failure.message}`, + hint: + failure.hint ?? + `Check the ${platformLabel(failure.platform)} toolchain, or scope with --platform to skip it.`, + evidence: { platform: failure.platform, code: failure.code }, + }; } function inventoryFailure(platform: PlatformSelector, error: unknown): DoctorInventoryFailure { diff --git a/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts index 35b068f53..43985e534 100644 --- a/src/daemon/handlers/session-doctor-options.ts +++ b/src/daemon/handlers/session-doctor-options.ts @@ -13,8 +13,14 @@ export function readDoctorOptions( ): 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; + const metroHost = + readNonEmptyString(req.flags?.metroHost) ?? + readNonEmptyString(req.runtime?.metroHost) ?? + DEFAULT_METRO_HOST; + const metroPort = + readPositivePort(req.flags?.metroPort) ?? + readPositivePort(req.runtime?.metroPort) ?? + DEFAULT_METRO_PORT; return { targetApp, metroHost, @@ -110,6 +116,8 @@ export function sessionChecks( function shouldProbeMetro(req: DaemonRequest, kind: DoctorKind): boolean { return ( kind !== 'auto' || + readPositivePort(req.flags?.metroPort) !== undefined || + readNonEmptyString(req.flags?.metroHost) !== undefined || typeof req.runtime?.metroPort === 'number' || typeof req.runtime?.metroHost === 'string' ); diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 85c8e45cd..5521277fc 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -140,6 +140,62 @@ test('Provider-backed integration doctor --remote fails without remote scope', a ); }); +test('Provider-backed integration doctor probes Metro when --metro-port is passed outside an RN project', async () => { + const server = await startMetroStatusServer(); + try { + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], + }), + async (daemon) => { + // No RN/Expo cwd -> kind stays 'auto', so Metro is only probed because + // the explicit --metro-port flag forces it. + const withoutFlag = await daemon.callCommand('doctor', [], { platform: 'ios' }); + assertRpcOk(withoutFlag); + assertNoDoctorCheck(withoutFlag.json.result.data, 'metro'); + + const withFlag = await daemon.callCommand('doctor', [], { + platform: 'ios', + metroPort: server.port, + }); + assertRpcOk(withFlag); + const data = withFlag.json.result.data; + const metro = assertDoctorCheck(data, 'metro', 'pass'); + assert.equal( + (metro.evidence as { url?: string }).url, + `http://127.0.0.1:${server.port}/status`, + ); + }, + ); + } finally { + await server.close(); + } +}); + +test('Provider-backed integration doctor surfaces a platform inventory failure even when another platform has devices', async () => { + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async (request) => { + if (request.platform === 'apple') { + throw new Error('xcrun: error: unable to find utility "simctl"'); + } + return request.platform === 'android' ? [PROVIDER_SCENARIO_ANDROID] : []; + }, + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', []); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'warn', JSON.stringify(data.checks)); + assertDoctorCheck(data, 'device', 'pass'); + const failure = assertDoctorCheck(data, 'device-apple', 'warn'); + assert.match(failure.summary, /simctl/); + }, + ); +}); + function writePackageJson(dir: string, value: Record): void { fs.writeFileSync(`${dir}/package.json`, `${JSON.stringify(value)}\n`); } From 7b7a61d17dd65760b1bc5b34f8838a11c23f369a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 07:35:35 +0200 Subject: [PATCH 15/29] fix: align doctor CI expectations --- scripts/integration-progress-model.ts | 1 + src/__tests__/cli-network.test.ts | 2 +- src/core/command-descriptor/__tests__/parity.test.ts | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index ac7f03e62..1738487f7 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -212,6 +212,7 @@ function summarizeProviderScenarioFlagExclusions() { 'daemonAuthToken', 'daemonTransport', 'daemonServerMode', + 'remote', 'tenant', 'sessionIsolation', 'runId', diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index ed4223a4e..f77a41e09 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -168,7 +168,7 @@ test('doctor command opts into progress rows for human output', async () => { 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.equal(result.calls[0]?.meta?.requestProgress, 'command'); assert.match(result.stdout, /✓ agent-device: agent-device 0\.17\.9 using \/tmp\/agent-device/); }); diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index 77be60eff..81b146769 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -33,6 +33,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.artifacts, PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.doctor, PUBLIC_COMMANDS.gesture, PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.replay, From 9d3c7b9f7b1a4ead4f43969a470a4ef1956ac8bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 08:01:42 +0200 Subject: [PATCH 16/29] feat: extend doctor preflight checks --- src/cli/parser/cli-flags.ts | 8 ++ src/cli/parser/cli-help.ts | 1 + src/client/client-types.ts | 7 +- src/commands/management/doctor.ts | 10 +- src/daemon/handlers/session-doctor-app.ts | 76 +++++++++-- src/daemon/handlers/session-doctor-device.ts | 39 +++++- src/daemon/handlers/session-doctor-options.ts | 2 +- .../handlers/session-doctor-toolchain.ts | 125 ++++++++++++++++++ src/daemon/handlers/session-doctor.ts | 28 +++- src/utils/__tests__/args.test.ts | 4 +- .../provider-scenarios/doctor.test.ts | 64 +++++++++ 11 files changed, 336 insertions(+), 28 deletions(-) create mode 100644 src/daemon/handlers/session-doctor-toolchain.ts diff --git a/src/cli/parser/cli-flags.ts b/src/cli/parser/cli-flags.ts index 6c41cb41d..92072b1ba 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -73,6 +73,7 @@ export type CliFlags = CloudProviderProfileFields & androidDeviceAllowlist?: string; remote?: boolean; session?: string; + targetApp?: string; metroHost?: string; metroPort?: number; bundleUrl?: string; @@ -494,6 +495,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--headless', usageDescription: 'Boot: launch Android emulator without a GUI window', }, + { + key: 'targetApp', + names: ['--app', '--target-app'], + type: 'string', + usageLabel: '--app ', + usageDescription: 'Doctor: verify an installed target app without opening a session', + }, { key: 'metroHost', names: ['--metro-host'], diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 06a7e9a62..3b6f5b68f 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -555,6 +555,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 --platform android --app com.example.app 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: diff --git a/src/client/client-types.ts b/src/client/client-types.ts index ba1211555..77e98a2ef 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -504,7 +504,12 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & { timeoutMs?: number; }; -export type DoctorCommandOptions = DeviceCommandBaseOptions; +export type DoctorCommandOptions = DeviceCommandBaseOptions & { + targetApp?: string; + remote?: boolean; + metroHost?: string; + metroPort?: number; +}; export type ViewportCommandOptions = DeviceCommandBaseOptions & { width: number; diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index 19f6558c1..d9a4b77c7 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -12,6 +12,9 @@ const doctorCommandMetadata = defineFieldCommandMetadata( 'doctor', 'Diagnose device, app, Metro, and React Native readiness before a run.', { + targetApp: commandInput.stringField( + 'Installed app package/bundle id or app name to verify without opening a session.', + ), remote: commandInput.booleanField( 'Check remote connection setup instead of local device inventory.', ), @@ -29,15 +32,16 @@ const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, ( const doctorCliSchema = { usageOverride: - 'doctor [--platform ios|android|macos|linux|web|apple] [--metro-host ] [--metro-port ] [--remote]', + 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--metro-host ] [--metro-port ] [--remote]', helpDescription: - '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. Pass --metro-host/--metro-port to force a Metro probe against a specific endpoint (e.g. outside an RN/Expo project directory). Use --remote to check remote connection setup without probing local devices. 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, optional app discovery, scoped toolchain info, Metro reachability inferred from cwd/runtime, and obvious React Native overlay blockers from the current session snapshot. Pass --app to verify a target app on the one matching booted device without opening a session. Pass --metro-host/--metro-port to force a Metro probe against a specific endpoint (e.g. outside an RN/Expo project directory). 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', 'metroHost', 'metroPort'], + allowedFlags: ['targetApp', 'remote', 'metroHost', 'metroPort'], } as const satisfies CommandSchemaOverride; const doctorCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), + targetApp: flags.targetApp, remote: flags.remote, metroHost: flags.metroHost, metroPort: flags.metroPort, diff --git a/src/daemon/handlers/session-doctor-app.ts b/src/daemon/handlers/session-doctor-app.ts index ef9e96f25..c5865dc71 100644 --- a/src/daemon/handlers/session-doctor-app.ts +++ b/src/daemon/handlers/session-doctor-app.ts @@ -1,7 +1,7 @@ -import { resolveAndroidApp } from '../../platforms/android/app-lifecycle.ts'; -import { resolveIosApp } from '../../platforms/apple/core/apps.ts'; +import { listAndroidApps } from '../../platforms/android/app-lifecycle.ts'; +import { listIosApps } from '../../platforms/apple/core/apps.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; -import { normalizeError } from '../../kernel/errors.ts'; +import { AppError, normalizeError } from '../../kernel/errors.ts'; import type { SessionState } from '../types.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; import type { DoctorCheck } from './session-doctor-types.ts'; @@ -16,16 +16,20 @@ export async function appendAppChecks( } try { - const resolved = - device.platform === 'android' - ? (await resolveAndroidApp(device, targetApp)).value - : device.platform === 'ios' || device.platform === 'macos' - ? await resolveIosApp(device, targetApp) - : targetApp; + const resolved = await resolveInstalledAppForDoctor(device, targetApp); + if (!resolved) { + appendDoctorCheck(checks, { + id: 'target-app', + status: 'info', + summary: `Target app installation checks are not supported for ${device.platform}.`, + evidence: { requested: targetApp, platform: device.platform }, + }); + return; + } appendDoctorCheck(checks, { id: 'target-app', status: 'pass', - summary: `Target app is discoverable: ${resolved}`, + summary: `Target app is launchable: ${resolved}`, evidence: { requested: targetApp, resolved, sessionApp: session?.appBundleId }, }); } catch (error) { @@ -33,10 +37,56 @@ export async function appendAppChecks( 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}`, + summary: `Target app check failed: ${normalized.message}`, + hint: normalized.hint ?? 'Install the app or pass an exact package/bundle id or app name.', + command: `agent-device apps --platform ${device.platform} --all`, evidence: { code: normalized.code, message: normalized.message }, }); } } + +async function resolveInstalledAppForDoctor( + device: DeviceInfo, + targetApp: string, +): Promise { + if (device.platform === 'android') { + const apps = await listAndroidApps(device, 'all'); + const match = resolveUniqueInstalledAppMatch( + targetApp, + apps.map((app) => ({ id: app.package, name: app.name })), + ); + return match?.id; + } + if (device.platform === 'ios' || device.platform === 'macos') { + const apps = await listIosApps(device, 'all'); + const match = resolveUniqueInstalledAppMatch( + targetApp, + apps.map((app) => ({ id: app.bundleId, name: app.name })), + ); + return match?.id; + } + return undefined; +} + +function resolveUniqueInstalledAppMatch( + targetApp: string, + apps: Array<{ id: string; name: string }>, +): { id: string; name: string } | undefined { + const needle = targetApp.trim().toLowerCase(); + const exact = apps.find( + (app) => app.id.toLowerCase() === needle || app.name.toLowerCase() === needle, + ); + if (exact) return exact; + + const matches = apps.filter( + (app) => app.id.toLowerCase().includes(needle) || app.name.toLowerCase().includes(needle), + ); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) { + throw new AppError('AMBIGUOUS_MATCH', `Multiple launchable apps matched "${targetApp}"`, { + matches: matches.map((app) => app.id), + hint: 'Pass an exact package/bundle id from agent-device apps --all.', + }); + } + throw new AppError('APP_NOT_INSTALLED', `No launchable installed app matched "${targetApp}"`); +} diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index 614776c3b..5b5424ff8 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -35,8 +35,8 @@ export async function appendDeviceInventoryCheck( req: DaemonRequest, session: SessionState | undefined, ): Promise { + const selector = deviceInventorySelector(req, session); try { - const selector = deviceInventorySelector(req, session); const inventory = await readDoctorDeviceInventory(selector); const devices = filterInventoryForSelector(inventory.devices, selector); appendDoctorCheck(checks, { @@ -69,10 +69,45 @@ export async function appendDeviceInventoryCheck( command: 'agent-device devices', evidence: { code: normalized.code, details: normalized.details }, }); - return undefined; + return { devices: [], platform: selector.platform, target: selector.target }; } } +export function resolveDoctorDeviceForAppCheck( + checks: DoctorCheck[], + inventory: DoctorDeviceInventory | undefined, + targetApp: string | undefined, +): DeviceInfo | undefined { + if (!targetApp || !inventory) return undefined; + const booted = inventory.devices.filter((device) => device.booted === true); + if (booted.length === 1) return booted[0]; + + appendDoctorCheck(checks, { + id: 'target-app-device', + status: 'fail', + summary: + booted.length === 0 + ? 'Target app check needs one booted device; none matched.' + : `Target app check needs one booted device; ${booted.length} matched.`, + hint: + booted.length === 0 + ? 'Boot a device, or adjust --platform/--target/--device/--udid/--serial.' + : 'Pass --platform/--target/--device/--udid/--serial so doctor checks the intended device.', + command: inventory.platform + ? `agent-device devices --platform ${inventory.platform}` + : 'agent-device devices', + evidence: { + targetApp, + booted: booted.map((device) => ({ + platform: device.platform, + id: device.id, + name: device.name, + })), + }, + }); + return undefined; +} + export function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): DoctorCheck[] { if ( (options.kind === 'react-native' || options.kind === 'expo') && diff --git a/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts index 43985e534..663f66d46 100644 --- a/src/daemon/handlers/session-doctor-options.ts +++ b/src/daemon/handlers/session-doctor-options.ts @@ -12,7 +12,7 @@ export function readDoctorOptions( session: SessionState | undefined, ): DoctorOptions { const kind = detectProjectRuntimeKind(req.meta?.cwd); - const targetApp = session?.appBundleId; + const targetApp = readNonEmptyString(req.flags?.targetApp) ?? session?.appBundleId; const metroHost = readNonEmptyString(req.flags?.metroHost) ?? readNonEmptyString(req.runtime?.metroHost) ?? diff --git a/src/daemon/handlers/session-doctor-toolchain.ts b/src/daemon/handlers/session-doctor-toolchain.ts new file mode 100644 index 000000000..a23bb5aec --- /dev/null +++ b/src/daemon/handlers/session-doctor-toolchain.ts @@ -0,0 +1,125 @@ +import { access } from 'node:fs/promises'; +import path from 'node:path'; +import type { PlatformSelector } from '../../kernel/device.ts'; +import { normalizeError } from '../../kernel/errors.ts'; +import { runCmd, whichCmd, type ExecResult } from '../../utils/exec.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; +import type { DoctorCheck } from './session-doctor-types.ts'; + +const TOOLCHAIN_TIMEOUT_MS = 3_000; + +type SafeExecResult = ExecResult | { error: string }; + +export async function appendToolchainChecks( + checks: DoctorCheck[], + platform: PlatformSelector | undefined, +): Promise { + if (platform === 'android') { + appendDoctorCheck(checks, await androidToolchainCheck()); + return; + } + if (platform === 'ios' || platform === 'macos' || platform === 'apple') { + appendDoctorCheck(checks, await appleToolchainCheck()); + } +} + +async function androidToolchainCheck(): Promise { + const adbAvailable = await whichCmd('adb'); + const sdkRoot = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; + const license = await androidLicenseState(sdkRoot); + if (!adbAvailable) { + return { + id: 'toolchain', + status: 'info', + summary: 'Android toolchain: adb not found on PATH.', + hint: 'Install Android platform-tools or add adb to PATH.', + evidence: { androidHome: sdkRoot ?? null, license }, + }; + } + + const adbVersion = await safeRun('adb', ['version']); + const versionLine = firstStdoutLine(adbVersion); + const sdkSummary = sdkRoot ? 'ANDROID_HOME/ANDROID_SDK_ROOT set' : 'ANDROID_HOME unset'; + return { + id: 'toolchain', + status: versionLine && sdkRoot && license !== 'missing' ? 'pass' : 'info', + summary: versionLine + ? `Android toolchain: ${versionLine}; ${sdkSummary}.` + : 'Android toolchain: adb is present but version check failed.', + hint: + license === 'missing' + ? 'Accept Android SDK licenses before installing/building apps.' + : undefined, + command: license === 'missing' ? 'sdkmanager --licenses' : undefined, + evidence: { androidHome: sdkRoot ?? null, license, adbVersion }, + }; +} + +async function appleToolchainCheck(): Promise { + const xcodeSelectAvailable = await whichCmd('xcode-select'); + const xcodebuildAvailable = await whichCmd('xcodebuild'); + if (!xcodeSelectAvailable && !xcodebuildAvailable) { + return { + id: 'toolchain', + status: 'info', + summary: 'Apple toolchain: xcode-select and xcodebuild not found on PATH.', + hint: 'Install Xcode and select it with xcode-select.', + evidence: { xcodeSelect: false, xcodebuild: false }, + }; + } + + const selectedXcode = xcodeSelectAvailable ? await safeRun('xcode-select', ['-p']) : undefined; + const xcodeVersion = xcodebuildAvailable ? await safeRun('xcodebuild', ['-version']) : undefined; + const firstLaunch = xcodebuildAvailable + ? await safeRun('xcodebuild', ['-checkFirstLaunchStatus']) + : undefined; + const versionLine = firstStdoutLine(xcodeVersion); + const selectedPath = firstStdoutLine(selectedXcode); + const firstLaunchOk = isSuccessful(firstLaunch); + + return { + id: 'toolchain', + status: selectedPath && versionLine && firstLaunchOk ? 'pass' : 'info', + summary: + selectedPath && versionLine + ? `Apple toolchain: ${versionLine}; xcode-select ${selectedPath}.` + : 'Apple toolchain: Xcode selection or version check failed.', + hint: firstLaunchOk + ? undefined + : 'Complete Xcode first launch/license setup before building apps.', + command: firstLaunchOk ? undefined : 'sudo xcodebuild -runFirstLaunch', + evidence: { selectedXcode, xcodeVersion, firstLaunch }, + }; +} + +async function androidLicenseState( + sdkRoot: string | undefined, +): Promise<'accepted' | 'missing' | 'unknown'> { + if (!sdkRoot) return 'unknown'; + try { + await access(path.join(sdkRoot, 'licenses', 'android-sdk-license')); + return 'accepted'; + } catch { + return 'missing'; + } +} + +async function safeRun(cmd: string, args: string[]): Promise { + try { + return await runCmd(cmd, args, { allowFailure: true, timeoutMs: TOOLCHAIN_TIMEOUT_MS }); + } catch (error) { + return { error: normalizeError(error).message }; + } +} + +function firstStdoutLine(result: SafeExecResult | undefined): string | undefined { + if (!result || 'error' in result || result.exitCode !== 0) return undefined; + return result.stdout + .split('\n') + .map((line) => line.trim()) + .find(Boolean); +} + +function isSuccessful(result: SafeExecResult | undefined): boolean { + return Boolean(result && !('error' in result) && result.exitCode === 0); +} diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index a2917722c..8c138179a 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -6,7 +6,11 @@ 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 { appendDeviceInventoryCheck, platformScopeChecks } from './session-doctor-device.ts'; +import { + appendDeviceInventoryCheck, + platformScopeChecks, + resolveDoctorDeviceForAppCheck, +} from './session-doctor-device.ts'; import { probeMetro } from './session-doctor-metro.ts'; import { readDoctorOptions, @@ -21,6 +25,7 @@ import { summarizeDoctorStatus, } from './session-doctor-output.ts'; import { appendReactNativeOverlayCheck } from './session-doctor-react-native.ts'; +import { appendToolchainChecks } from './session-doctor-toolchain.ts'; import type { DoctorCheck } from './session-doctor-types.ts'; export async function handleDoctorCommand(params: { @@ -63,16 +68,25 @@ export async function handleDoctorCommand(params: { } const inventory = await appendDeviceInventoryCheck(checks, req, session); + await appendToolchainChecks(checks, session?.device.platform ?? inventory?.platform); + const appCheckDevice = + session?.device ?? resolveDoctorDeviceForAppCheck(checks, inventory, options.targetApp); const device = session?.device; - if (device) { - appendDoctorChecks(checks, ...platformScopeChecks(device, options)); - await appendAppChecks(checks, { device, session, targetApp: options.targetApp }); + if (appCheckDevice) { + appendDoctorChecks(checks, ...platformScopeChecks(appCheckDevice, options)); + await appendAppChecks(checks, { + device: appCheckDevice, + session, + targetApp: options.targetApp, + }); await appendAndroidChecks(checks, { - device, + device: appCheckDevice, metroPort: options.metroPort, shouldProbeMetro: options.shouldProbeMetro, androidAdbExecutor, }); + } + if (session) { appendReactNativeOverlayCheck(checks, session, options); } if (options.shouldProbeMetro) { @@ -86,8 +100,8 @@ export async function handleDoctorCommand(params: { status, summary: doctorSummary(status), kind: options.kind, - platform: device?.platform ?? inventory?.platform, - target: device?.target ?? inventory?.target, + platform: appCheckDevice?.platform ?? device?.platform ?? inventory?.platform, + target: appCheckDevice?.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 7f6ce98ef..2c84bd010 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -94,11 +94,12 @@ test('parseArgs recognizes command-specific flag combinations', async () => { }, { label: 'doctor android', - argv: ['doctor', '--platform', 'android'], + argv: ['doctor', '--platform', 'android', '--app', 'com.example.demo'], strictFlags: true, assertParsed: (parsed) => { assert.equal(parsed.command, 'doctor'); assert.equal(parsed.flags.platform, 'android'); + assert.equal(parsed.flags.targetApp, 'com.example.demo'); }, }, { @@ -1841,6 +1842,7 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /Help workflow owns the full Expo URL command shapes/); assert.match(help, /For app\/package launches, run metro prepare/); assert.match(help, /agent-device doctor --platform android/); + assert.match(help, /agent-device doctor --platform android --app com\.example\.app/); 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/); diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 5521277fc..8cf7701f3 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -96,6 +96,63 @@ test('Provider-backed integration doctor runs predictably for supported platform ); }); +test('Provider-backed integration doctor --app verifies an installed app without opening a session', async () => { + const adbCalls: string[][] = []; + const adbProvider: AndroidAdbProvider = { + exec: async (args) => { + adbCalls.push([...args]); + return androidDoctorAdbResult(args, 8081); + }, + }; + + 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.demo', + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'pass', JSON.stringify(data.checks)); + const app = assertDoctorCheck(data, 'target-app', 'pass'); + assert.match(app.summary, /com\.example\.demo/); + assert.ok( + adbCalls.some((args) => args.includes('query-activities')), + JSON.stringify(adbCalls), + ); + }, + ); +}); + +test('Provider-backed integration doctor --app asks for a selector when multiple devices are booted', async () => { + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async (request) => { + if (request.platform === 'android') return [PROVIDER_SCENARIO_ANDROID]; + if (request.platform === 'apple') return [PROVIDER_SCENARIO_IOS_SIMULATOR]; + return []; + }, + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', [], { + targetApp: 'com.example.demo', + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'fail', JSON.stringify(data.checks)); + const appDevice = assertDoctorCheck(data, 'target-app-device', 'fail'); + assert.match(appDevice.summary, /2 matched/); + assertNoDoctorCheck(data, 'target-app'); + }, + ); +}); + test('Provider-backed integration doctor --remote skips local device inventory', async () => { let inventoryCalls = 0; @@ -235,6 +292,13 @@ function androidDoctorAdbResult( exitCode: number; } { const command = args.join(' '); + if (args.includes('query-activities')) { + return { + stdout: 'com.example.demo/.MainActivity\ncom.example.settings/.MainActivity\n', + stderr: '', + exitCode: 0, + }; + } if (command === 'reverse --list') { return { stdout: `emulator-5554 tcp:${metroPort} tcp:${metroPort}\n`, From 27b4c696be6e9eca2400ab3e8fa752d6d3d2fa81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 08:10:29 +0200 Subject: [PATCH 17/29] fix: keep doctor checks within ci gates --- scripts/integration-progress-model.ts | 1 + .../handlers/session-doctor-toolchain.ts | 121 +++++++++++++----- src/daemon/handlers/session-doctor.ts | 101 +++++++++------ 3 files changed, 154 insertions(+), 69 deletions(-) diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index 1738487f7..89c58d7f4 100644 --- a/scripts/integration-progress-model.ts +++ b/scripts/integration-progress-model.ts @@ -135,6 +135,7 @@ function summarizeProviderScenarioFlagCoverage(files) { ['iosSimulatorDeviceSet', 'iOS simulator-set scoping reaches inventory resolution'], ['androidDeviceAllowlist', 'Android serial allowlist reaches inventory resolution'], ['session', 'named session routing'], + ['targetApp', 'doctor target app discovery without opening a session'], ['surface', 'macOS app/frontmost/desktop/menubar surfaces'], ['activity', 'Android explicit launch activity'], ['launchConsole', 'iOS simulator launch console capture'], diff --git a/src/daemon/handlers/session-doctor-toolchain.ts b/src/daemon/handlers/session-doctor-toolchain.ts index a23bb5aec..84afef9d5 100644 --- a/src/daemon/handlers/session-doctor-toolchain.ts +++ b/src/daemon/handlers/session-doctor-toolchain.ts @@ -9,6 +9,20 @@ import type { DoctorCheck } from './session-doctor-types.ts'; const TOOLCHAIN_TIMEOUT_MS = 3_000; type SafeExecResult = ExecResult | { error: string }; +type AndroidToolchainProbe = { + adbVersion: SafeExecResult; + license: 'accepted' | 'missing' | 'unknown'; + sdkRoot: string | undefined; + versionLine: string | undefined; +}; +type AppleToolchainProbe = { + firstLaunch: SafeExecResult | undefined; + firstLaunchOk: boolean; + selectedPath: string | undefined; + selectedXcode: SafeExecResult | undefined; + versionLine: string | undefined; + xcodeVersion: SafeExecResult | undefined; +}; export async function appendToolchainChecks( checks: DoctorCheck[], @@ -27,46 +41,55 @@ async function androidToolchainCheck(): Promise { const adbAvailable = await whichCmd('adb'); const sdkRoot = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; const license = await androidLicenseState(sdkRoot); - if (!adbAvailable) { - return { - id: 'toolchain', - status: 'info', - summary: 'Android toolchain: adb not found on PATH.', - hint: 'Install Android platform-tools or add adb to PATH.', - evidence: { androidHome: sdkRoot ?? null, license }, - }; - } + if (!adbAvailable) return missingAndroidAdbCheck(sdkRoot, license); const adbVersion = await safeRun('adb', ['version']); const versionLine = firstStdoutLine(adbVersion); - const sdkSummary = sdkRoot ? 'ANDROID_HOME/ANDROID_SDK_ROOT set' : 'ANDROID_HOME unset'; + return androidAdbCheck({ adbVersion, license, sdkRoot, versionLine }); +} + +function missingAndroidAdbCheck( + sdkRoot: string | undefined, + license: 'accepted' | 'missing' | 'unknown', +): DoctorCheck { + return { + id: 'toolchain', + status: 'info', + summary: 'Android toolchain: adb not found on PATH.', + hint: 'Install Android platform-tools or add adb to PATH.', + evidence: { androidHome: sdkRoot ?? null, license }, + }; +} + +function androidAdbCheck(probe: AndroidToolchainProbe): DoctorCheck { + const sdkSummary = probe.sdkRoot ? 'ANDROID_HOME/ANDROID_SDK_ROOT set' : 'ANDROID_HOME unset'; return { id: 'toolchain', - status: versionLine && sdkRoot && license !== 'missing' ? 'pass' : 'info', - summary: versionLine - ? `Android toolchain: ${versionLine}; ${sdkSummary}.` + status: androidToolchainStatus(probe), + summary: probe.versionLine + ? `Android toolchain: ${probe.versionLine}; ${sdkSummary}.` : 'Android toolchain: adb is present but version check failed.', hint: - license === 'missing' + probe.license === 'missing' ? 'Accept Android SDK licenses before installing/building apps.' : undefined, - command: license === 'missing' ? 'sdkmanager --licenses' : undefined, - evidence: { androidHome: sdkRoot ?? null, license, adbVersion }, + command: probe.license === 'missing' ? 'sdkmanager --licenses' : undefined, + evidence: { + adbVersion: probe.adbVersion, + androidHome: probe.sdkRoot ?? null, + license: probe.license, + }, }; } +function androidToolchainStatus(probe: AndroidToolchainProbe): DoctorCheck['status'] { + return probe.versionLine && probe.sdkRoot && probe.license !== 'missing' ? 'pass' : 'info'; +} + async function appleToolchainCheck(): Promise { const xcodeSelectAvailable = await whichCmd('xcode-select'); const xcodebuildAvailable = await whichCmd('xcodebuild'); - if (!xcodeSelectAvailable && !xcodebuildAvailable) { - return { - id: 'toolchain', - status: 'info', - summary: 'Apple toolchain: xcode-select and xcodebuild not found on PATH.', - hint: 'Install Xcode and select it with xcode-select.', - evidence: { xcodeSelect: false, xcodebuild: false }, - }; - } + if (!xcodeSelectAvailable && !xcodebuildAvailable) return missingAppleToolchainCheck(); const selectedXcode = xcodeSelectAvailable ? await safeRun('xcode-select', ['-p']) : undefined; const xcodeVersion = xcodebuildAvailable ? await safeRun('xcodebuild', ['-version']) : undefined; @@ -76,22 +99,54 @@ async function appleToolchainCheck(): Promise { const versionLine = firstStdoutLine(xcodeVersion); const selectedPath = firstStdoutLine(selectedXcode); const firstLaunchOk = isSuccessful(firstLaunch); + return appleProbeCheck({ + firstLaunch, + firstLaunchOk, + selectedPath, + selectedXcode, + versionLine, + xcodeVersion, + }); +} + +function missingAppleToolchainCheck(): DoctorCheck { + return { + id: 'toolchain', + status: 'info', + summary: 'Apple toolchain: xcode-select and xcodebuild not found on PATH.', + hint: 'Install Xcode and select it with xcode-select.', + evidence: { xcodeSelect: false, xcodebuild: false }, + }; +} +function appleProbeCheck(probe: AppleToolchainProbe): DoctorCheck { return { id: 'toolchain', - status: selectedPath && versionLine && firstLaunchOk ? 'pass' : 'info', - summary: - selectedPath && versionLine - ? `Apple toolchain: ${versionLine}; xcode-select ${selectedPath}.` - : 'Apple toolchain: Xcode selection or version check failed.', - hint: firstLaunchOk + status: appleToolchainStatus(probe), + summary: appleToolchainSummary(probe), + hint: probe.firstLaunchOk ? undefined : 'Complete Xcode first launch/license setup before building apps.', - command: firstLaunchOk ? undefined : 'sudo xcodebuild -runFirstLaunch', - evidence: { selectedXcode, xcodeVersion, firstLaunch }, + command: probe.firstLaunchOk ? undefined : 'sudo xcodebuild -runFirstLaunch', + evidence: { + firstLaunch: probe.firstLaunch, + selectedXcode: probe.selectedXcode, + xcodeVersion: probe.xcodeVersion, + }, }; } +function appleToolchainStatus(probe: AppleToolchainProbe): DoctorCheck['status'] { + return probe.selectedPath && probe.versionLine && probe.firstLaunchOk ? 'pass' : 'info'; +} + +function appleToolchainSummary(probe: AppleToolchainProbe): string { + if (!probe.selectedPath || !probe.versionLine) { + return 'Apple toolchain: Xcode selection or version check failed.'; + } + return `Apple toolchain: ${probe.versionLine}; xcode-select ${probe.selectedPath}.`; +} + async function androidLicenseState( sdkRoot: string | undefined, ): Promise<'accepted' | 'missing' | 'unknown'> { diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index 8c138179a..01a9c6b66 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -1,13 +1,15 @@ import path from 'node:path'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; +import type { DeviceInfo } from '../../kernel/device.ts'; import { readVersion } from '../../utils/version.ts'; -import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import type { DaemonRequest, DaemonResponse, SessionState } from '../types.ts'; import { SessionStore } from '../session-store.ts'; import { appendAndroidChecks } from './session-doctor-android.ts'; import { appendAppChecks } from './session-doctor-app.ts'; import { appendDeviceInventoryCheck, + type DoctorDeviceInventory, platformScopeChecks, resolveDoctorDeviceForAppCheck, } from './session-doctor-device.ts'; @@ -26,7 +28,7 @@ import { } from './session-doctor-output.ts'; import { appendReactNativeOverlayCheck } from './session-doctor-react-native.ts'; import { appendToolchainChecks } from './session-doctor-toolchain.ts'; -import type { DoctorCheck } from './session-doctor-types.ts'; +import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; export async function handleDoctorCommand(params: { req: DaemonRequest; @@ -54,45 +56,76 @@ export async function handleDoctorCommand(params: { ); if (options.remote) { - const status = summarizeDoctorStatus(checks); - return { - ok: true, - data: { - status, - summary: doctorSummary(status), - kind: options.kind, - targetApp: options.targetApp, - checks: sortChecks(checks), - }, - }; + return doctorResponse(checks, options); } const inventory = await appendDeviceInventoryCheck(checks, req, session); await appendToolchainChecks(checks, session?.device.platform ?? inventory?.platform); + const appCheckDevice = await appendLocalDoctorChecks({ + androidAdbExecutor, + checks, + inventory, + options, + session, + }); + return doctorResponse(checks, options, { device: appCheckDevice, includeMetro: true, inventory }); +} + +function resolveDoctorStateDir(sessionStore: SessionStore, sessionName: string): string { + const sessionsDir = path.dirname(sessionStore.resolveSessionDir(sessionName)); + return path.basename(sessionsDir) === 'sessions' ? path.dirname(sessionsDir) : sessionsDir; +} + +async function appendLocalDoctorChecks(params: { + androidAdbExecutor?: AndroidAdbExecutor; + checks: DoctorCheck[]; + inventory: DoctorDeviceInventory | undefined; + options: DoctorOptions; + session: SessionState | undefined; +}): Promise { + const { checks, inventory, options, session, androidAdbExecutor } = params; const appCheckDevice = session?.device ?? resolveDoctorDeviceForAppCheck(checks, inventory, options.targetApp); - const device = session?.device; if (appCheckDevice) { - appendDoctorChecks(checks, ...platformScopeChecks(appCheckDevice, options)); - await appendAppChecks(checks, { + await appendDeviceScopedDoctorChecks(checks, { + androidAdbExecutor, device: appCheckDevice, + options, session, - targetApp: options.targetApp, }); - await appendAndroidChecks(checks, { - device: appCheckDevice, - metroPort: options.metroPort, - shouldProbeMetro: options.shouldProbeMetro, - androidAdbExecutor, - }); - } - if (session) { - appendReactNativeOverlayCheck(checks, session, options); } + if (session) appendReactNativeOverlayCheck(checks, session, options); if (options.shouldProbeMetro) { appendDoctorCheck(checks, await probeMetro(options.metroHost, options.metroPort, options.kind)); } + return appCheckDevice; +} + +async function appendDeviceScopedDoctorChecks( + checks: DoctorCheck[], + params: { + androidAdbExecutor?: AndroidAdbExecutor; + device: DeviceInfo; + options: DoctorOptions; + session: SessionState | undefined; + }, +): Promise { + const { androidAdbExecutor, device, options, session } = params; + appendDoctorChecks(checks, ...platformScopeChecks(device, options)); + await appendAppChecks(checks, { device, session, targetApp: options.targetApp }); + await appendAndroidChecks(checks, { + androidAdbExecutor, + device, + metroPort: options.metroPort, + shouldProbeMetro: options.shouldProbeMetro, + }); +} +function doctorResponse( + checks: DoctorCheck[], + options: DoctorOptions, + scope: { device?: DeviceInfo; includeMetro?: boolean; inventory?: DoctorDeviceInventory } = {}, +): DaemonResponse { const status = summarizeDoctorStatus(checks); return { ok: true, @@ -100,18 +133,14 @@ export async function handleDoctorCommand(params: { status, summary: doctorSummary(status), kind: options.kind, - platform: appCheckDevice?.platform ?? device?.platform ?? inventory?.platform, - target: appCheckDevice?.target ?? device?.target ?? inventory?.target, + platform: scope.device?.platform ?? scope.inventory?.platform, + target: scope.device?.target ?? scope.inventory?.target, targetApp: options.targetApp, - metro: options.shouldProbeMetro - ? { host: options.metroHost, port: options.metroPort } - : undefined, + metro: + scope.includeMetro && options.shouldProbeMetro + ? { host: options.metroHost, port: options.metroPort } + : undefined, checks: sortChecks(checks), }, }; } - -function resolveDoctorStateDir(sessionStore: SessionStore, sessionName: string): string { - const sessionsDir = path.dirname(sessionStore.resolveSessionDir(sessionName)); - return path.basename(sessionsDir) === 'sessions' ? path.dirname(sessionsDir) : sessionsDir; -} From d3202e78679669b2fd3d1ce196efaae177908744 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 09:33:46 +0200 Subject: [PATCH 18/29] fix: simplify doctor metro surface --- src/client/client-types.ts | 2 -- src/commands/management/doctor.ts | 16 +++--------- src/daemon/handlers/session-doctor-metro.ts | 2 +- src/daemon/handlers/session-doctor-options.ts | 12 ++------- .../provider-scenarios/doctor.test.ts | 26 ++++++++++--------- 5 files changed, 20 insertions(+), 38 deletions(-) diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 77e98a2ef..8df24f3bd 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -507,8 +507,6 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & { export type DoctorCommandOptions = DeviceCommandBaseOptions & { targetApp?: string; remote?: boolean; - metroHost?: string; - metroPort?: number; }; export type ViewportCommandOptions = DeviceCommandBaseOptions & { diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index d9a4b77c7..cc0d69794 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -18,11 +18,6 @@ const doctorCommandMetadata = defineFieldCommandMetadata( remote: commandInput.booleanField( 'Check remote connection setup instead of local device inventory.', ), - metroHost: commandInput.stringField('Metro host to probe (forces a Metro reachability check).'), - metroPort: commandInput.integerField( - 'Metro port to probe (forces a Metro reachability check).', - { min: 1, max: 65535 }, - ), }, ); @@ -32,23 +27,18 @@ const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, ( const doctorCliSchema = { usageOverride: - 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--metro-host ] [--metro-port ] [--remote]', + 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--remote]', helpDescription: - 'Read-only preflight for QA and dogfood runs. Reports local device inventory, active sessions, optional app discovery, scoped toolchain info, Metro reachability inferred from cwd/runtime, and obvious React Native overlay blockers from the current session snapshot. Pass --app to verify a target app on the one matching booted device without opening a session. Pass --metro-host/--metro-port to force a Metro probe against a specific endpoint (e.g. outside an RN/Expo project directory). Use --remote to check remote connection setup without probing local devices. 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, optional app discovery, scoped toolchain info, Metro reachability inferred from cwd/runtime, and obvious React Native overlay blockers from the current session snapshot. Pass --app to verify a target app on the one matching booted device without opening a session. 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: ['targetApp', 'remote', 'metroHost', 'metroPort'], + allowedFlags: ['targetApp', 'remote'], } as const satisfies CommandSchemaOverride; const doctorCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), targetApp: flags.targetApp, remote: flags.remote, - metroHost: flags.metroHost, - metroPort: flags.metroPort, }); -// Both the field-command definition (client.command.doctor) and this facet -// cli reader forward metroHost/metroPort so the Metro probe is controllable -// regardless of which dispatch path executes. const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor); diff --git a/src/daemon/handlers/session-doctor-metro.ts b/src/daemon/handlers/session-doctor-metro.ts index a2d1adef5..ae9e50a2e 100644 --- a/src/daemon/handlers/session-doctor-metro.ts +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -54,7 +54,7 @@ export async function probeMetro( 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.', + hint: 'Start Metro for this project. For non-default endpoints, launch with open --metro-host/--metro-port, or run metro prepare with --public-base-url/--proxy-base-url before retrying doctor.', 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 index 663f66d46..a191dcf05 100644 --- a/src/daemon/handlers/session-doctor-options.ts +++ b/src/daemon/handlers/session-doctor-options.ts @@ -13,14 +13,8 @@ export function readDoctorOptions( ): DoctorOptions { const kind = detectProjectRuntimeKind(req.meta?.cwd); const targetApp = readNonEmptyString(req.flags?.targetApp) ?? session?.appBundleId; - const metroHost = - readNonEmptyString(req.flags?.metroHost) ?? - readNonEmptyString(req.runtime?.metroHost) ?? - DEFAULT_METRO_HOST; - const metroPort = - readPositivePort(req.flags?.metroPort) ?? - readPositivePort(req.runtime?.metroPort) ?? - DEFAULT_METRO_PORT; + const metroHost = readNonEmptyString(req.runtime?.metroHost) ?? DEFAULT_METRO_HOST; + const metroPort = readPositivePort(req.runtime?.metroPort) ?? DEFAULT_METRO_PORT; return { targetApp, metroHost, @@ -116,8 +110,6 @@ export function sessionChecks( function shouldProbeMetro(req: DaemonRequest, kind: DoctorKind): boolean { return ( kind !== 'auto' || - readPositivePort(req.flags?.metroPort) !== undefined || - readNonEmptyString(req.flags?.metroHost) !== undefined || typeof req.runtime?.metroPort === 'number' || typeof req.runtime?.metroHost === 'string' ); diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 8cf7701f3..c5b8f64c4 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -197,7 +197,7 @@ test('Provider-backed integration doctor --remote fails without remote scope', a ); }); -test('Provider-backed integration doctor probes Metro when --metro-port is passed outside an RN project', async () => { +test('Provider-backed integration doctor probes Metro when runtime metadata exists outside an RN project', async () => { const server = await startMetroStatusServer(); try { await withProviderScenarioResource( @@ -206,18 +206,20 @@ test('Provider-backed integration doctor probes Metro when --metro-port is passe deviceInventoryProvider: async () => [PROVIDER_SCENARIO_IOS_SIMULATOR], }), async (daemon) => { - // No RN/Expo cwd -> kind stays 'auto', so Metro is only probed because - // the explicit --metro-port flag forces it. - const withoutFlag = await daemon.callCommand('doctor', [], { platform: 'ios' }); - assertRpcOk(withoutFlag); - assertNoDoctorCheck(withoutFlag.json.result.data, 'metro'); + // No RN/Expo cwd -> kind stays 'auto', so Metro is only probed after + // an app/session flow has supplied runtime metadata. + const withoutRuntime = await daemon.callCommand('doctor', [], { platform: 'ios' }); + assertRpcOk(withoutRuntime); + assertNoDoctorCheck(withoutRuntime.json.result.data, 'metro'); - const withFlag = await daemon.callCommand('doctor', [], { - platform: 'ios', - metroPort: server.port, - }); - assertRpcOk(withFlag); - const data = withFlag.json.result.data; + const withRuntime = await daemon.callCommand( + 'doctor', + [], + { platform: 'ios' }, + { runtime: { metroPort: server.port } }, + ); + assertRpcOk(withRuntime); + const data = withRuntime.json.result.data; const metro = assertDoctorCheck(data, 'metro', 'pass'); assert.equal( (metro.evidence as { url?: string }).url, From ffb9c7bae1e3b651f92242c00263649fbc01ec19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 10:23:08 +0200 Subject: [PATCH 19/29] refactor: trim doctor bundle impact --- .../__tests__/session-doctor-metro.test.ts | 54 ------- src/daemon/handlers/session-doctor-app.ts | 4 +- src/daemon/handlers/session-doctor-device.ts | 130 ++-------------- src/daemon/handlers/session-doctor-metro.ts | 68 +-------- .../handlers/session-doctor-toolchain.ts | 144 +++++++++--------- src/daemon/handlers/session-doctor.ts | 2 - src/kernel/device.ts | 105 +++++-------- .../provider-scenarios/doctor.test.ts | 11 +- 8 files changed, 132 insertions(+), 386 deletions(-) delete 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 deleted file mode 100644 index 36edd4746..000000000 --- a/src/daemon/handlers/__tests__/session-doctor-metro.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -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-app.ts b/src/daemon/handlers/session-doctor-app.ts index c5865dc71..62bf03403 100644 --- a/src/daemon/handlers/session-doctor-app.ts +++ b/src/daemon/handlers/session-doctor-app.ts @@ -1,5 +1,3 @@ -import { listAndroidApps } from '../../platforms/android/app-lifecycle.ts'; -import { listIosApps } from '../../platforms/apple/core/apps.ts'; import type { DeviceInfo } from '../../kernel/device.ts'; import { AppError, normalizeError } from '../../kernel/errors.ts'; import type { SessionState } from '../types.ts'; @@ -50,6 +48,7 @@ async function resolveInstalledAppForDoctor( targetApp: string, ): Promise { if (device.platform === 'android') { + const { listAndroidApps } = await import('../../platforms/android/app-lifecycle.ts'); const apps = await listAndroidApps(device, 'all'); const match = resolveUniqueInstalledAppMatch( targetApp, @@ -58,6 +57,7 @@ async function resolveInstalledAppForDoctor( return match?.id; } if (device.platform === 'ios' || device.platform === 'macos') { + const { listIosApps } = await import('../../platforms/apple/core/apps.ts'); const apps = await listIosApps(device, 'all'); const match = resolveUniqueInstalledAppMatch( targetApp, diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index 5b5424ff8..adbda756a 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -7,12 +7,11 @@ import { matchesDeviceSelector, type DeviceInfo, type DeviceTarget, - type Platform, type PlatformSelector, } from '../../kernel/device.ts'; import { normalizeError } from '../../kernel/errors.ts'; import type { DaemonRequest, SessionState } from '../types.ts'; -import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; +import type { DoctorCheck } from './session-doctor-types.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; export type DoctorDeviceInventory = { @@ -21,13 +20,6 @@ export type DoctorDeviceInventory = { target?: DeviceTarget; }; -type DoctorInventoryFailure = { - platform: PlatformSelector; - message: string; - hint?: string; - code?: string; -}; - type DoctorInventoryGroup = 'android' | 'apple' | 'linux' | 'web'; export async function appendDeviceInventoryCheck( @@ -37,27 +29,18 @@ export async function appendDeviceInventoryCheck( ): Promise { const selector = deviceInventorySelector(req, session); try { - const inventory = await readDoctorDeviceInventory(selector); - const devices = filterInventoryForSelector(inventory.devices, selector); + const devices = filterInventoryForSelector(await listDeviceInventory(selector), selector); appendDoctorCheck(checks, { id: 'device', status: devices.length === 0 ? 'fail' : 'pass', - summary: deviceInventorySummary(devices, selector, inventory.failures), + summary: deviceInventorySummary(devices, selector), 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.') + ? '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), + evidence: deviceInventoryEvidence(devices), }); - // When some platforms had devices, the main check passes — but a platform whose - // inventory threw (e.g. a broken Xcode or Android SDK) must not be silently hidden. - if (devices.length > 0) { - for (const failure of inventory.failures) { - appendDoctorCheck(checks, inventoryFailureCheck(failure)); - } - } return { devices, platform: selector.platform, target: selector.target }; } catch (error) { const normalized = normalizeError(error); @@ -108,34 +91,6 @@ export function resolveDoctorDeviceForAppCheck( return undefined; } -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 []; -} - function deviceInventorySelector(req: DaemonRequest, session: SessionState | undefined) { const flags = req.flags ?? {}; return buildDeviceInventoryRequestFromFlags({ @@ -158,56 +113,11 @@ function filterInventoryForSelector( ); } -async function readDoctorDeviceInventory( - selector: DeviceInventoryRequest, -): 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 }; -} - -function inventoryFailureCheck(failure: DoctorInventoryFailure): DoctorCheck { - return { - id: `device-${failure.platform}`, - status: 'warn', - summary: `${platformLabel(failure.platform)} device inventory could not be read: ${failure.message}`, - hint: - failure.hint ?? - `Check the ${platformLabel(failure.platform)} toolchain, or scope with --platform to skip it.`, - evidence: { platform: failure.platform, code: failure.code }, - }; -} - -function inventoryFailure(platform: PlatformSelector, error: unknown): DoctorInventoryFailure { - const normalized = normalizeError(error); - return { - platform, - message: normalized.message, - hint: normalized.hint, - code: normalized.code, - }; -} - function deviceInventorySummary( devices: DeviceInfo[], selector: Pick, - 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; @@ -226,13 +136,6 @@ function deviceInventoryLabel( 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 deviceInventorySummaryBreakdown( devices: DeviceInfo[], selector: Pick, @@ -288,23 +191,16 @@ function deviceInventoryCommand(selector: Pick { - 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); - } +function deviceInventoryEvidence(devices: DeviceInfo[]): Record { + const groups = deviceInventoryGroups(devices); 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 } : {}), + byPlatform: { + android: groups.android, + apple: groups.apple, + linux: groups.linux, + web: groups.web, + }, }; } diff --git a/src/daemon/handlers/session-doctor-metro.ts b/src/daemon/handlers/session-doctor-metro.ts index ae9e50a2e..a00f38a9e 100644 --- a/src/daemon/handlers/session-doctor-metro.ts +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -1,42 +1,22 @@ 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`; 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'); - let processInfo: MetroProcessInfo | undefined; - if (running) { - try { - processInfo = await (options.resolveProcessInfo ?? resolveMetroProcessInfo)(host, port); - } catch { - processInfo = undefined; - } - } return { id: 'metro', status: running ? 'pass' : 'warn', summary: running - ? metroRunningSummary(url, processInfo) + ? `Metro is reachable at ${url}.` : `Metro responded at ${url}, but did not report packager-status:running.`, hint: running ? undefined @@ -46,7 +26,6 @@ export async function probeMetro( statusCode: response.status, body: text.slice(0, 120), kind, - ...(processInfo ? { process: processInfo } : {}), }, }; } catch (error) { @@ -60,48 +39,3 @@ 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/src/daemon/handlers/session-doctor-toolchain.ts b/src/daemon/handlers/session-doctor-toolchain.ts index 84afef9d5..e1f7f5ae7 100644 --- a/src/daemon/handlers/session-doctor-toolchain.ts +++ b/src/daemon/handlers/session-doctor-toolchain.ts @@ -1,27 +1,21 @@ import { access } from 'node:fs/promises'; import path from 'node:path'; import type { PlatformSelector } from '../../kernel/device.ts'; -import { normalizeError } from '../../kernel/errors.ts'; -import { runCmd, whichCmd, type ExecResult } from '../../utils/exec.ts'; +import { runCmd, whichCmd } from '../../utils/exec.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; import type { DoctorCheck } from './session-doctor-types.ts'; const TOOLCHAIN_TIMEOUT_MS = 3_000; - -type SafeExecResult = ExecResult | { error: string }; +type AndroidLicenseState = 'accepted' | 'missing' | 'unknown'; type AndroidToolchainProbe = { - adbVersion: SafeExecResult; - license: 'accepted' | 'missing' | 'unknown'; + license: AndroidLicenseState; sdkRoot: string | undefined; versionLine: string | undefined; }; type AppleToolchainProbe = { - firstLaunch: SafeExecResult | undefined; firstLaunchOk: boolean; selectedPath: string | undefined; - selectedXcode: SafeExecResult | undefined; versionLine: string | undefined; - xcodeVersion: SafeExecResult | undefined; }; export async function appendToolchainChecks( @@ -43,31 +37,19 @@ async function androidToolchainCheck(): Promise { const license = await androidLicenseState(sdkRoot); if (!adbAvailable) return missingAndroidAdbCheck(sdkRoot, license); - const adbVersion = await safeRun('adb', ['version']); - const versionLine = firstStdoutLine(adbVersion); - return androidAdbCheck({ adbVersion, license, sdkRoot, versionLine }); -} - -function missingAndroidAdbCheck( - sdkRoot: string | undefined, - license: 'accepted' | 'missing' | 'unknown', -): DoctorCheck { - return { - id: 'toolchain', - status: 'info', - summary: 'Android toolchain: adb not found on PATH.', - hint: 'Install Android platform-tools or add adb to PATH.', - evidence: { androidHome: sdkRoot ?? null, license }, - }; + return androidAdbCheck({ + license, + sdkRoot, + versionLine: await commandFirstLine('adb', ['version']), + }); } function androidAdbCheck(probe: AndroidToolchainProbe): DoctorCheck { - const sdkSummary = probe.sdkRoot ? 'ANDROID_HOME/ANDROID_SDK_ROOT set' : 'ANDROID_HOME unset'; return { id: 'toolchain', status: androidToolchainStatus(probe), summary: probe.versionLine - ? `Android toolchain: ${probe.versionLine}; ${sdkSummary}.` + ? `Android toolchain: ${probe.versionLine}; ${androidSdkSummary(probe.sdkRoot)}.` : 'Android toolchain: adb is present but version check failed.', hint: probe.license === 'missing' @@ -75,7 +57,7 @@ function androidAdbCheck(probe: AndroidToolchainProbe): DoctorCheck { : undefined, command: probe.license === 'missing' ? 'sdkmanager --licenses' : undefined, evidence: { - adbVersion: probe.adbVersion, + adbVersion: probe.versionLine ?? null, androidHome: probe.sdkRoot ?? null, license: probe.license, }, @@ -86,39 +68,39 @@ function androidToolchainStatus(probe: AndroidToolchainProbe): DoctorCheck['stat return probe.versionLine && probe.sdkRoot && probe.license !== 'missing' ? 'pass' : 'info'; } +function androidSdkSummary(sdkRoot: string | undefined): string { + return sdkRoot ? 'ANDROID_HOME/ANDROID_SDK_ROOT set' : 'ANDROID_HOME unset'; +} + +function missingAndroidAdbCheck( + sdkRoot: string | undefined, + license: AndroidLicenseState, +): DoctorCheck { + return { + id: 'toolchain', + status: 'info', + summary: 'Android toolchain: adb not found on PATH.', + hint: 'Install Android platform-tools or add adb to PATH.', + evidence: { androidHome: sdkRoot ?? null, license }, + }; +} + async function appleToolchainCheck(): Promise { const xcodeSelectAvailable = await whichCmd('xcode-select'); const xcodebuildAvailable = await whichCmd('xcodebuild'); if (!xcodeSelectAvailable && !xcodebuildAvailable) return missingAppleToolchainCheck(); - const selectedXcode = xcodeSelectAvailable ? await safeRun('xcode-select', ['-p']) : undefined; - const xcodeVersion = xcodebuildAvailable ? await safeRun('xcodebuild', ['-version']) : undefined; - const firstLaunch = xcodebuildAvailable - ? await safeRun('xcodebuild', ['-checkFirstLaunchStatus']) - : undefined; - const versionLine = firstStdoutLine(xcodeVersion); - const selectedPath = firstStdoutLine(selectedXcode); - const firstLaunchOk = isSuccessful(firstLaunch); return appleProbeCheck({ - firstLaunch, - firstLaunchOk, - selectedPath, - selectedXcode, - versionLine, - xcodeVersion, + firstLaunchOk: xcodebuildAvailable + ? await commandOk('xcodebuild', ['-checkFirstLaunchStatus']) + : false, + selectedPath: xcodeSelectAvailable ? await commandFirstLine('xcode-select', ['-p']) : undefined, + versionLine: xcodebuildAvailable + ? await commandFirstLine('xcodebuild', ['-version']) + : undefined, }); } -function missingAppleToolchainCheck(): DoctorCheck { - return { - id: 'toolchain', - status: 'info', - summary: 'Apple toolchain: xcode-select and xcodebuild not found on PATH.', - hint: 'Install Xcode and select it with xcode-select.', - evidence: { xcodeSelect: false, xcodebuild: false }, - }; -} - function appleProbeCheck(probe: AppleToolchainProbe): DoctorCheck { return { id: 'toolchain', @@ -129,9 +111,9 @@ function appleProbeCheck(probe: AppleToolchainProbe): DoctorCheck { : 'Complete Xcode first launch/license setup before building apps.', command: probe.firstLaunchOk ? undefined : 'sudo xcodebuild -runFirstLaunch', evidence: { - firstLaunch: probe.firstLaunch, - selectedXcode: probe.selectedXcode, - xcodeVersion: probe.xcodeVersion, + selectedPath: probe.selectedPath ?? null, + xcodeVersion: probe.versionLine ?? null, + firstLaunchOk: probe.firstLaunchOk, }, }; } @@ -141,15 +123,23 @@ function appleToolchainStatus(probe: AppleToolchainProbe): DoctorCheck['status'] } function appleToolchainSummary(probe: AppleToolchainProbe): string { - if (!probe.selectedPath || !probe.versionLine) { - return 'Apple toolchain: Xcode selection or version check failed.'; + if (probe.selectedPath && probe.versionLine) { + return `Apple toolchain: ${probe.versionLine}; xcode-select ${probe.selectedPath}.`; } - return `Apple toolchain: ${probe.versionLine}; xcode-select ${probe.selectedPath}.`; + return 'Apple toolchain: Xcode selection or version check failed.'; } -async function androidLicenseState( - sdkRoot: string | undefined, -): Promise<'accepted' | 'missing' | 'unknown'> { +function missingAppleToolchainCheck(): DoctorCheck { + return { + id: 'toolchain', + status: 'info', + summary: 'Apple toolchain: xcode-select and xcodebuild not found on PATH.', + hint: 'Install Xcode and select it with xcode-select.', + evidence: { xcodeSelect: false, xcodebuild: false }, + }; +} + +async function androidLicenseState(sdkRoot: string | undefined): Promise { if (!sdkRoot) return 'unknown'; try { await access(path.join(sdkRoot, 'licenses', 'android-sdk-license')); @@ -159,22 +149,26 @@ async function androidLicenseState( } } -async function safeRun(cmd: string, args: string[]): Promise { +async function commandFirstLine(cmd: string, args: string[]): Promise { try { - return await runCmd(cmd, args, { allowFailure: true, timeoutMs: TOOLCHAIN_TIMEOUT_MS }); - } catch (error) { - return { error: normalizeError(error).message }; + const result = await runCmd(cmd, args, { allowFailure: true, timeoutMs: TOOLCHAIN_TIMEOUT_MS }); + if (result.exitCode !== 0) return undefined; + return result.stdout + .split('\n') + .map((line) => line.trim()) + .find(Boolean); + } catch { + return undefined; } } -function firstStdoutLine(result: SafeExecResult | undefined): string | undefined { - if (!result || 'error' in result || result.exitCode !== 0) return undefined; - return result.stdout - .split('\n') - .map((line) => line.trim()) - .find(Boolean); -} - -function isSuccessful(result: SafeExecResult | undefined): boolean { - return Boolean(result && !('error' in result) && result.exitCode === 0); +async function commandOk(cmd: string, args: string[]): Promise { + try { + return ( + (await runCmd(cmd, args, { allowFailure: true, timeoutMs: TOOLCHAIN_TIMEOUT_MS })) + .exitCode === 0 + ); + } catch { + return false; + } } diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index 01a9c6b66..80eb9ded5 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -10,7 +10,6 @@ import { appendAppChecks } from './session-doctor-app.ts'; import { appendDeviceInventoryCheck, type DoctorDeviceInventory, - platformScopeChecks, resolveDoctorDeviceForAppCheck, } from './session-doctor-device.ts'; import { probeMetro } from './session-doctor-metro.ts'; @@ -111,7 +110,6 @@ async function appendDeviceScopedDoctorChecks( }, ): Promise { const { androidAdbExecutor, device, options, session } = params; - appendDoctorChecks(checks, ...platformScopeChecks(device, options)); await appendAppChecks(checks, { device, session, targetApp: options.targetApp }); await appendAndroidChecks(checks, { androidAdbExecutor, diff --git a/src/kernel/device.ts b/src/kernel/device.ts index 2a6dd7833..5d4454f21 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -142,9 +142,36 @@ export async function resolveDevice( selector: DeviceSelector, context: DeviceSelectionContext = {}, ): Promise { - const candidates = sortDeviceCandidatesForSelection(filterDeviceCandidates(devices, selector)); - const explicitSelection = resolveExplicitDeviceSelection(candidates, selector); - if (explicitSelection) return explicitSelection; + let candidates = devices.filter((device) => matchesDeviceSelector(device, selector)); + + if (selector.udid) { + const match = candidates.find( + (device) => device.id === selector.udid && isApplePlatform(device.platform), + ); + if (!match) + throw new AppError('DEVICE_NOT_FOUND', `No Apple device with UDID ${selector.udid}`); + return match; + } + + if (selector.serial) { + const match = candidates.find( + (device) => device.id === selector.serial && device.platform === 'android', + ); + if (!match) + throw new AppError('DEVICE_NOT_FOUND', `No Android device with serial ${selector.serial}`); + return match; + } + + if (selector.deviceName) { + const normalizedName = normalizeDeviceName(selector.deviceName); + const match = candidates.find((device) => normalizeDeviceName(device.name) === normalizedName); + if (!match) throw new AppError('DEVICE_NOT_FOUND', `No device named ${selector.deviceName}`); + return match; + } + + if (isAppleDeviceCandidateSet(candidates)) { + candidates = sortAppleDevicesForSelection(candidates); + } const onlyCandidate = candidates[0]; if (onlyCandidate !== undefined && candidates.length === 1) return onlyCandidate; @@ -153,15 +180,20 @@ export async function resolveDevice( throwNoDevicesFound(selector, context); } - const selected = selectDefaultDevice(candidates); - if (selected === undefined) throwNoDevicesFound(selector, context); + const virtual = candidates.filter((device) => device.kind !== 'device'); + const selectable = virtual.length > 0 ? virtual : candidates; + const booted = selectable.filter((device) => device.booted); + const onlyBooted = booted[0]; + if (onlyBooted && booted.length === 1 && !isAppleDeviceCandidateSet(selectable)) { + return onlyBooted; + } + const selected = isAppleDeviceCandidateSet(selectable) + ? selectable[0] + : (booted[0] ?? selectable[0]); + if (!selected) throwNoDevicesFound(selector, context); return selected; } -function filterDeviceCandidates(devices: DeviceInfo[], selector: DeviceSelector): DeviceInfo[] { - return devices.filter((device) => matchesDeviceSelector(device, selector)); -} - export function matchesDeviceSelector( device: DeviceInfo, selector: DeviceSelector, @@ -190,61 +222,6 @@ function matchesExplicitDeviceSelector(device: DeviceInfo, selector: DeviceSelec return true; } -function sortDeviceCandidatesForSelection(candidates: DeviceInfo[]): DeviceInfo[] { - return isAppleDeviceCandidateSet(candidates) - ? sortAppleDevicesForSelection(candidates) - : candidates; -} - -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; -} - -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; -} - -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; -} - -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)) { diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index c5b8f64c4..faa0fcc5b 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -134,6 +134,8 @@ test('Provider-backed integration doctor --app asks for a selector when multiple async () => await createProviderScenarioHarness({ deviceInventoryProvider: async (request) => { + if (!request.platform) + return [PROVIDER_SCENARIO_ANDROID, PROVIDER_SCENARIO_IOS_SIMULATOR]; if (request.platform === 'android') return [PROVIDER_SCENARIO_ANDROID]; if (request.platform === 'apple') return [PROVIDER_SCENARIO_IOS_SIMULATOR]; return []; @@ -232,7 +234,7 @@ test('Provider-backed integration doctor probes Metro when runtime metadata exis } }); -test('Provider-backed integration doctor surfaces a platform inventory failure even when another platform has devices', async () => { +test('Provider-backed integration doctor surfaces a selected platform inventory failure', async () => { await withProviderScenarioResource( async () => await createProviderScenarioHarness({ @@ -244,12 +246,11 @@ test('Provider-backed integration doctor surfaces a platform inventory failure e }, }), async (daemon) => { - const response = await daemon.callCommand('doctor', []); + const response = await daemon.callCommand('doctor', [], { platform: 'apple' }); assertRpcOk(response); const data = response.json.result.data; - assert.equal(data.status, 'warn', JSON.stringify(data.checks)); - assertDoctorCheck(data, 'device', 'pass'); - const failure = assertDoctorCheck(data, 'device-apple', 'warn'); + assert.equal(data.status, 'fail', JSON.stringify(data.checks)); + const failure = assertDoctorCheck(data, 'device', 'fail'); assert.match(failure.summary, /simctl/); }, ); From 648338c57bc8e2914628e4d79a0caebdfcc351db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 10:36:36 +0200 Subject: [PATCH 20/29] fix: restore useful doctor diagnostics --- .../__tests__/session-doctor-metro.test.ts | 54 +++++++++ src/daemon/handlers/session-doctor-device.ts | 113 +++++++++++++++--- src/daemon/handlers/session-doctor-metro.ts | 75 +++++++++++- .../provider-scenarios/doctor.test.ts | 9 +- 4 files changed, 231 insertions(+), 20 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-device.ts b/src/daemon/handlers/session-doctor-device.ts index adbda756a..ca8156c08 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -7,6 +7,7 @@ import { matchesDeviceSelector, type DeviceInfo, type DeviceTarget, + type Platform, type PlatformSelector, } from '../../kernel/device.ts'; import { normalizeError } from '../../kernel/errors.ts'; @@ -21,6 +22,12 @@ export type DoctorDeviceInventory = { }; type DoctorInventoryGroup = 'android' | 'apple' | 'linux' | 'web'; +type DoctorInventoryFailure = { + platform: PlatformSelector; + message: string; + hint?: string; + code?: string; +}; export async function appendDeviceInventoryCheck( checks: DoctorCheck[], @@ -29,18 +36,19 @@ export async function appendDeviceInventoryCheck( ): Promise { const selector = deviceInventorySelector(req, session); try { - const devices = filterInventoryForSelector(await listDeviceInventory(selector), selector); + const inventory = await readDoctorDeviceInventory(selector); + const devices = filterInventoryForSelector(inventory.devices, selector); appendDoctorCheck(checks, { id: 'device', status: devices.length === 0 ? 'fail' : 'pass', - summary: deviceInventorySummary(devices, selector), - hint: - devices.length === 0 - ? 'Start or create a simulator/emulator, connect a device, or adjust --platform/--target/--device selectors.' - : undefined, + summary: deviceInventorySummary(devices, selector, inventory.failures), + hint: devices.length === 0 ? deviceInventoryFailureHint(inventory.failures) : undefined, command: devices.length === 0 ? deviceInventoryCommand(selector) : undefined, - evidence: deviceInventoryEvidence(devices), + evidence: deviceInventoryEvidence(devices, inventory.failures), }); + if (devices.length > 0) { + appendInventoryFailureChecks(checks, inventory.failures); + } return { devices, platform: selector.platform, target: selector.target }; } catch (error) { const normalized = normalizeError(error); @@ -113,11 +121,65 @@ function filterInventoryForSelector( ); } +async function readDoctorDeviceInventory( + selector: DeviceInventoryRequest, +): 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 }; +} + +function appendInventoryFailureChecks( + checks: DoctorCheck[], + failures: DoctorInventoryFailure[], +): void { + for (const failure of failures) { + appendDoctorCheck(checks, inventoryFailureCheck(failure)); + } +} + +function inventoryFailureCheck(failure: DoctorInventoryFailure): DoctorCheck { + return { + id: `device-${failure.platform}`, + status: 'warn', + summary: `${platformLabel(failure.platform)} device inventory could not be read: ${failure.message}`, + hint: + failure.hint ?? + `Check the ${platformLabel(failure.platform)} toolchain, or scope with --platform to skip it.`, + evidence: { platform: failure.platform, code: failure.code }, + }; +} + +function inventoryFailure(platform: PlatformSelector, error: unknown): DoctorInventoryFailure { + const normalized = normalizeError(error); + return { + platform, + message: normalized.message, + hint: normalized.hint, + code: normalized.code, + }; +} + function deviceInventorySummary( devices: DeviceInfo[], selector: Pick, + 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; @@ -136,6 +198,20 @@ function deviceInventoryLabel( 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 deviceInventoryFailureHint(failures: DoctorInventoryFailure[]): string { + return ( + failures.find((failure) => failure.hint)?.hint ?? + 'Start or create a simulator/emulator, connect a device, or adjust --platform/--target/--device selectors.' + ); +} + function deviceInventorySummaryBreakdown( devices: DeviceInfo[], selector: Pick, @@ -191,16 +267,23 @@ function deviceInventoryCommand(selector: Pick { - const groups = deviceInventoryGroups(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: { - android: groups.android, - apple: groups.apple, - linux: groups.linux, - web: groups.web, - }, + byPlatform: Object.fromEntries( + [...byPlatform.entries()].sort(([a], [b]) => a.localeCompare(b)), + ), + ...(failures.length > 0 ? { failures } : {}), }; } diff --git a/src/daemon/handlers/session-doctor-metro.ts b/src/daemon/handlers/session-doctor-metro.ts index a00f38a9e..0c0cb2725 100644 --- a/src/daemon/handlers/session-doctor-metro.ts +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -1,22 +1,37 @@ 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`; 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 resolveMetroProcessInfoSafely(host, port, options) + : 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 @@ -26,6 +41,7 @@ export async function probeMetro( statusCode: response.status, body: text.slice(0, 120), kind, + ...(processInfo ? { process: processInfo } : {}), }, }; } catch (error) { @@ -39,3 +55,60 @@ export async function probeMetro( }; } } + +async function resolveMetroProcessInfoSafely( + host: string, + port: number, + options: MetroProbeOptions, +): Promise { + try { + return await (options.resolveProcessInfo ?? resolveMetroProcessInfo)(host, port); + } catch { + return undefined; + } +} + +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 faa0fcc5b..ec37296cb 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -234,7 +234,7 @@ test('Provider-backed integration doctor probes Metro when runtime metadata exis } }); -test('Provider-backed integration doctor surfaces a selected platform inventory failure', async () => { +test('Provider-backed integration doctor surfaces a platform inventory failure even when another platform has devices', async () => { await withProviderScenarioResource( async () => await createProviderScenarioHarness({ @@ -246,11 +246,12 @@ test('Provider-backed integration doctor surfaces a selected platform inventory }, }), async (daemon) => { - const response = await daemon.callCommand('doctor', [], { platform: 'apple' }); + const response = await daemon.callCommand('doctor', []); assertRpcOk(response); const data = response.json.result.data; - assert.equal(data.status, 'fail', JSON.stringify(data.checks)); - const failure = assertDoctorCheck(data, 'device', 'fail'); + assert.equal(data.status, 'warn', JSON.stringify(data.checks)); + assertDoctorCheck(data, 'device', 'pass'); + const failure = assertDoctorCheck(data, 'device-apple', 'warn'); assert.match(failure.summary, /simctl/); }, ); From 33bba6cf25a4f7f7fea307235b1798b1c3828ed2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 11:39:11 +0200 Subject: [PATCH 21/29] refactor: reuse doctor output helpers --- src/cli-doctor-output.ts | 28 +--------------- src/daemon/handlers/session-doctor-output.ts | 25 +++----------- src/doctor-output.ts | 35 ++++++++++++++++++++ src/metro/client-metro.ts | 25 ++++++-------- src/replay/test/progress.ts | 16 +++------ src/utils/project-runtime.ts | 11 ++++-- 6 files changed, 63 insertions(+), 77 deletions(-) create mode 100644 src/doctor-output.ts diff --git a/src/cli-doctor-output.ts b/src/cli-doctor-output.ts index 2cf80d905..e82ba8ce0 100644 --- a/src/cli-doctor-output.ts +++ b/src/cli-doctor-output.ts @@ -1,4 +1,4 @@ -import { formatCliStatusMarker, type CliStatusMarkerStatus } from './cli-status-markers.ts'; +export { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } from './doctor-output.ts'; let renderedDoctorProgress = false; @@ -11,29 +11,3 @@ export function consumeDoctorProgressRendered(): boolean { 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/daemon/handlers/session-doctor-output.ts b/src/daemon/handlers/session-doctor-output.ts index 653382e3c..ffb5134ae 100644 --- a/src/daemon/handlers/session-doctor-output.ts +++ b/src/daemon/handlers/session-doctor-output.ts @@ -1,4 +1,5 @@ import { emitRequestProgress } from '../request-progress.ts'; +import { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } from '../../doctor-output.ts'; import type { DoctorCheck, DoctorStatus } from './session-doctor-types.ts'; export function summarizeDoctorStatus(checks: DoctorCheck[]): 'pass' | 'warn' | 'fail' { @@ -29,28 +30,10 @@ export function appendDoctorCheck(checks: DoctorCheck[], check: DoctorCheck): vo emitRequestProgress({ type: 'command', status: 'progress', - message: formatDoctorProgressMessage(check), + message: formatDoctorCheckProgressMessage(check), }); } -function formatDoctorProgressMessage(check: DoctorCheck): string { - return [formatDoctorProgressSummary(check), ...formatDoctorProgressDetails(check)].join('\n'); -} - -function formatDoctorProgressSummary(check: DoctorCheck): string { - return `${doctorProgressMarker(check.status)} ${check.id}: ${check.summary}`; -} - -function formatDoctorProgressDetails(check: DoctorCheck): string[] { - if (check.status !== 'fail' && check.status !== 'warn') return []; - if (check.command) return [` run: ${check.command}`]; - if (check.hint) return [` hint: ${check.hint}`]; - return []; -} - -function doctorProgressMarker(status: DoctorStatus): string { - if (status === 'pass') return '✓'; - if (status === 'fail') return '⨯'; - if (status === 'warn') return '!'; - return '-'; +function formatDoctorCheckProgressMessage(check: DoctorCheck): string { + return [formatDoctorCheckSummaryLine(check), ...formatDoctorCheckDetailLines(check)].join('\n'); } diff --git a/src/doctor-output.ts b/src/doctor-output.ts new file mode 100644 index 000000000..46f0fcaf2 --- /dev/null +++ b/src/doctor-output.ts @@ -0,0 +1,35 @@ +import { formatCliStatusMarker, type CliStatusMarkerStatus } from './cli-status-markers.ts'; + +export type DoctorLineCheck = { + id?: unknown; + status?: unknown; + summary?: unknown; + command?: unknown; + hint?: unknown; +}; + +export function formatDoctorCheckSummaryLine(check: DoctorLineCheck): string { + const statusMarker = formatCliStatusMarker(doctorStatusMarker(check.status)); + return `${statusMarker} ${formatDoctorCheckLabel(check)}`; +} + +export function formatDoctorCheckDetailLines(check: DoctorLineCheck): 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: DoctorLineCheck): 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/metro/client-metro.ts b/src/metro/client-metro.ts index c529127fd..49493282c 100644 --- a/src/metro/client-metro.ts +++ b/src/metro/client-metro.ts @@ -13,6 +13,11 @@ import { AppError } from '../kernel/errors.ts'; import { runCmdSync, runCmdDetached } from '../utils/exec.ts'; import { resolveUserPath } from '../utils/path-resolution.ts'; import { waitForProcessExit } from '../utils/process-identity.ts'; +import { + detectProjectRuntimeKindFromPackageJson, + readProjectPackageJson, + type PackageJsonShape, +} from '../utils/project-runtime.ts'; import { buildBundleUrl, normalizeBaseUrl } from '../utils/url.ts'; import { resolveRuntimeTransportHints, @@ -33,11 +38,6 @@ export type { MetroBridgeScope, } from '../client/client-companion-tunnel-contract.ts'; -type PackageJsonShape = { - dependencies?: Record; - devDependencies?: Record; -}; - type PackageManagerConfig = { command: string; installArgs: string[]; @@ -169,11 +169,11 @@ function directoryExists(dirPath: string): boolean { function readPackageJson(projectRoot: string): PackageJsonShape { const packageJsonPath = path.join(projectRoot, 'package.json'); - if (!fileExists(packageJsonPath)) { + const packageJson = readProjectPackageJson(projectRoot); + if (!packageJson) { throw new AppError('INVALID_ARGS', `package.json not found at ${packageJsonPath}`); } - - return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJsonShape; + return packageJson; } function detectPackageManager(projectRoot: string): PackageManagerConfig { @@ -191,13 +191,8 @@ function detectMetroKind(projectRoot: string, requestedKind: MetroPrepareKind): return requestedKind; } - const packageJson = readPackageJson(projectRoot); - const dependencies = { - ...(packageJson.dependencies ?? {}), - ...(packageJson.devDependencies ?? {}), - }; - - return typeof dependencies.expo === 'string' ? 'expo' : 'react-native'; + const detected = detectProjectRuntimeKindFromPackageJson(readPackageJson(projectRoot)); + return detected === 'expo' ? 'expo' : 'react-native'; } function parseTimeout( diff --git a/src/replay/test/progress.ts b/src/replay/test/progress.ts index ea934d802..4bea98914 100644 --- a/src/replay/test/progress.ts +++ b/src/replay/test/progress.ts @@ -6,6 +6,7 @@ import type { ReplayTestResult, ReplayTestStep, } from './reporters/types.ts'; +import { formatCliStatusMarker } from '../../cli-status-markers.ts'; import { formatDurationSeconds } from '../../utils/duration-format.ts'; import { colorize, supportsColor } from '../../utils/output.ts'; @@ -72,12 +73,6 @@ function formatReplayTestProgressEvent( return lines.join('\n'); } -function replayTestStatusIcon(status: ReplayTestResult['status']): string { - if (status === 'pass') return '✓'; - if (status === 'fail') return '⨯'; - return '-'; -} - function formatReplayTestLiveProgressLine( event: ReplayTestStep, options: ReplayTestProgressFormatOptions, @@ -174,13 +169,12 @@ function formatReplayTestProgressName(event: ReplayTestResult | ReplayTestStep): } function formatReplayTestProgressStatusLabel(event: ReplayTestResult): string { - const useColor = supportsColor(process.stderr); - const icon = replayTestStatusIcon(event.status); if (event.status === 'pass') { - const format = event.attempt && event.attempt > 1 ? 'yellow' : 'green'; - return useColor ? colorizeProgressMarker(icon, format) : icon; + return formatCliStatusMarker('pass', { + passFormat: event.attempt && event.attempt > 1 ? 'yellow' : 'green', + }); } - return useColor ? colorizeProgressMarker(icon, event.status === 'fail' ? 'red' : 'dim') : icon; + return formatCliStatusMarker(event.status === 'fail' ? 'fail' : 'skip'); } function colorizeProgressMarker(text: string, format: Parameters[1]): string { diff --git a/src/utils/project-runtime.ts b/src/utils/project-runtime.ts index 76da5dc5a..3e538ee88 100644 --- a/src/utils/project-runtime.ts +++ b/src/utils/project-runtime.ts @@ -3,15 +3,20 @@ import path from 'node:path'; export type ProjectRuntimeKind = 'auto' | 'react-native' | 'expo'; -type PackageJsonShape = { +export type PackageJsonShape = { dependencies?: Record; devDependencies?: Record; }; export function detectProjectRuntimeKind(cwd: string | undefined): ProjectRuntimeKind { - const packageJson = readPackageJson(cwd); + const packageJson = readProjectPackageJson(cwd); if (!packageJson) return 'auto'; + return detectProjectRuntimeKindFromPackageJson(packageJson); +} +export function detectProjectRuntimeKindFromPackageJson( + packageJson: PackageJsonShape, +): ProjectRuntimeKind { const dependencies = { ...(packageJson.dependencies ?? {}), ...(packageJson.devDependencies ?? {}), @@ -21,7 +26,7 @@ export function detectProjectRuntimeKind(cwd: string | undefined): ProjectRuntim return 'auto'; } -function readPackageJson(cwd: string | undefined): PackageJsonShape | undefined { +export function readProjectPackageJson(cwd: string | undefined): PackageJsonShape | undefined { if (!cwd) return undefined; const packageJsonPath = path.join(cwd, 'package.json'); try { From d97c260b92e0d8f1794bcb181657ef65c3f92cdd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 11:43:30 +0200 Subject: [PATCH 22/29] refactor: share device inventory grouping --- src/core/platform-inventory.ts | 60 ++++++++++++-------- src/daemon/handlers/session-doctor-device.ts | 38 ++++++------- 2 files changed, 53 insertions(+), 45 deletions(-) diff --git a/src/core/platform-inventory.ts b/src/core/platform-inventory.ts index 969793ff1..3428e4fd2 100644 --- a/src/core/platform-inventory.ts +++ b/src/core/platform-inventory.ts @@ -1,5 +1,7 @@ import type { DeviceInfo, DeviceTarget, PlatformSelector } from '../kernel/device.ts'; +export const LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS = ['android', 'apple', 'linux'] as const; + export type DeviceInventoryRequest = { platform?: PlatformSelector; target?: DeviceTarget; @@ -14,6 +16,12 @@ export type DeviceInventoryRequest = { androidSerialAllowlist?: string[]; }; +export type DeviceInventoryGroup = 'android' | 'apple' | 'linux' | 'web'; +export type DeviceInventoryGroupCounts = Record< + DeviceInventoryGroup, + { available: number; booted: number } +>; + // Exported so the web platform-plugin's `discoverDevices` reuses the SAME static // device instance instead of carrying a divergent copy. export const WEB_DESKTOP_DEVICE: DeviceInfo = { @@ -60,34 +68,40 @@ export async function listLocalDeviceInventory( } const devices: DeviceInfo[] = []; - try { - const { listAndroidDevices } = await import('../platforms/android/devices.ts'); - devices.push( - ...(await listAndroidDevices({ - serialAllowlist: request.androidSerialAllowlist - ? new Set(request.androidSerialAllowlist) - : undefined, - })), - ); - } catch {} - try { - const { listAppleDevices } = await import('../platforms/apple/core/devices.ts'); - devices.push( - ...(await listAppleDevices({ - simulatorSetPath: request.iosSimulatorSetPath, - udid: request.udid, - })), - ); - } catch {} // Linux local device is appended last so it does not displace // connected Android/Apple devices in implicit auto-selection. - try { - const { listLinuxDevices } = await import('../platforms/linux/devices.ts'); - devices.push(...(await listLinuxDevices())); - } catch {} + for (const platform of LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS) { + try { + devices.push(...(await listLocalDeviceInventory({ ...request, platform }))); + } catch {} + } return devices; } +export function countDeviceInventoryByGroup(devices: DeviceInfo[]): DeviceInventoryGroupCounts { + const counts = emptyDeviceInventoryGroupCounts(); + for (const device of devices) { + const group = deviceInventoryGroupForDevice(device); + counts[group].available += 1; + if (device.booted === true) counts[group].booted += 1; + } + return counts; +} + +function emptyDeviceInventoryGroupCounts(): DeviceInventoryGroupCounts { + return { + android: { available: 0, booted: 0 }, + apple: { available: 0, booted: 0 }, + linux: { available: 0, booted: 0 }, + web: { available: 0, booted: 0 }, + }; +} + +function deviceInventoryGroupForDevice(device: DeviceInfo): DeviceInventoryGroup { + if (device.platform === 'ios' || device.platform === 'macos') return 'apple'; + return device.platform; +} + // Exported so the Apple platform-plugin's `discoverDevices` reuses the SAME // host-mac fast-path predicate instead of carrying a divergent copy. export function shouldUseHostMacFastPath(selector: { diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts index ca8156c08..c49c1a17d 100644 --- a/src/daemon/handlers/session-doctor-device.ts +++ b/src/daemon/handlers/session-doctor-device.ts @@ -2,7 +2,12 @@ import { buildDeviceInventoryRequestFromFlags, listDeviceInventory, } from '../../core/dispatch-resolve.ts'; -import type { DeviceInventoryRequest } from '../../core/platform-inventory.ts'; +import { + countDeviceInventoryByGroup, + LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS, + type DeviceInventoryRequest, + type DeviceInventoryGroup, +} from '../../core/platform-inventory.ts'; import { matchesDeviceSelector, type DeviceInfo, @@ -21,7 +26,6 @@ export type DoctorDeviceInventory = { target?: DeviceTarget; }; -type DoctorInventoryGroup = 'android' | 'apple' | 'linux' | 'web'; type DoctorInventoryFailure = { platform: PlatformSelector; message: string; @@ -130,7 +134,7 @@ async function readDoctorDeviceInventory( const devices: DeviceInfo[] = []; const failures: DoctorInventoryFailure[] = []; - for (const platform of ['android', 'apple', 'linux'] as const) { + for (const platform of LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS) { try { devices.push(...(await listDeviceInventory({ ...selector, platform }))); } catch (error) { @@ -217,35 +221,25 @@ function deviceInventorySummaryBreakdown( selector: Pick, ): string | undefined { if (selector.platform || selector.target) return undefined; - const groups = deviceInventoryGroups(devices); + const groups = countDeviceInventoryByGroup(devices); + const labels = deviceInventoryGroupLabels(); 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`] + ? [`${labels[group]} ${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 }, +function deviceInventoryGroupLabels(): Record { + return { + android: 'Android', + apple: 'Apple', + linux: 'Linux', + web: 'web', }; - 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 { From 143c1543d141f59e7aa0639c641c5358bc632361 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 11:46:28 +0200 Subject: [PATCH 23/29] refactor: keep doctor focused on preflight checks --- src/commands/management/doctor.ts | 2 +- .../handlers/session-doctor-react-native.ts | 51 ------------------- src/daemon/handlers/session-doctor.ts | 2 - 3 files changed, 1 insertion(+), 54 deletions(-) delete mode 100644 src/daemon/handlers/session-doctor-react-native.ts diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index cc0d69794..c1afbb3d0 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -29,7 +29,7 @@ const doctorCliSchema = { usageOverride: 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--remote]', helpDescription: - 'Read-only preflight for QA and dogfood runs. Reports local device inventory, active sessions, optional app discovery, scoped toolchain info, Metro reachability inferred from cwd/runtime, and obvious React Native overlay blockers from the current session snapshot. Pass --app to verify a target app on the one matching booted device without opening a session. Use --remote to check remote connection setup without probing local devices. 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, optional app discovery, scoped toolchain info, and Metro reachability inferred from cwd/runtime. Pass --app to verify a target app on the one matching booted device without opening a session. 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: ['targetApp', 'remote'], } as const satisfies CommandSchemaOverride; diff --git a/src/daemon/handlers/session-doctor-react-native.ts b/src/daemon/handlers/session-doctor-react-native.ts deleted file mode 100644 index 4d51991e2..000000000 --- a/src/daemon/handlers/session-doctor-react-native.ts +++ /dev/null @@ -1,51 +0,0 @@ -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.ts b/src/daemon/handlers/session-doctor.ts index 80eb9ded5..d4f91950b 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -25,7 +25,6 @@ import { sortChecks, summarizeDoctorStatus, } from './session-doctor-output.ts'; -import { appendReactNativeOverlayCheck } from './session-doctor-react-native.ts'; import { appendToolchainChecks } from './session-doctor-toolchain.ts'; import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; @@ -93,7 +92,6 @@ async function appendLocalDoctorChecks(params: { session, }); } - if (session) appendReactNativeOverlayCheck(checks, session, options); if (options.shouldProbeMetro) { appendDoctorCheck(checks, await probeMetro(options.metroHost, options.metroPort, options.kind)); } From 5f7d29d067ba27e68d548ee7c65dadd6479f3b5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 11:50:24 +0200 Subject: [PATCH 24/29] refactor: simplify doctor toolchain probes --- .../handlers/session-doctor-toolchain.ts | 47 +++++-------------- 1 file changed, 12 insertions(+), 35 deletions(-) diff --git a/src/daemon/handlers/session-doctor-toolchain.ts b/src/daemon/handlers/session-doctor-toolchain.ts index e1f7f5ae7..c5746b4f5 100644 --- a/src/daemon/handlers/session-doctor-toolchain.ts +++ b/src/daemon/handlers/session-doctor-toolchain.ts @@ -1,7 +1,7 @@ import { access } from 'node:fs/promises'; import path from 'node:path'; import type { PlatformSelector } from '../../kernel/device.ts'; -import { runCmd, whichCmd } from '../../utils/exec.ts'; +import { runCmd } from '../../utils/exec.ts'; import { appendDoctorCheck } from './session-doctor-output.ts'; import type { DoctorCheck } from './session-doctor-types.ts'; @@ -13,7 +13,6 @@ type AndroidToolchainProbe = { versionLine: string | undefined; }; type AppleToolchainProbe = { - firstLaunchOk: boolean; selectedPath: string | undefined; versionLine: string | undefined; }; @@ -32,15 +31,15 @@ export async function appendToolchainChecks( } async function androidToolchainCheck(): Promise { - const adbAvailable = await whichCmd('adb'); const sdkRoot = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; const license = await androidLicenseState(sdkRoot); - if (!adbAvailable) return missingAndroidAdbCheck(sdkRoot, license); + const versionLine = await commandFirstLine('adb', ['version']); + if (!versionLine) return missingAndroidAdbCheck(sdkRoot, license); return androidAdbCheck({ license, sdkRoot, - versionLine: await commandFirstLine('adb', ['version']), + versionLine, }); } @@ -86,18 +85,12 @@ function missingAndroidAdbCheck( } async function appleToolchainCheck(): Promise { - const xcodeSelectAvailable = await whichCmd('xcode-select'); - const xcodebuildAvailable = await whichCmd('xcodebuild'); - if (!xcodeSelectAvailable && !xcodebuildAvailable) return missingAppleToolchainCheck(); + const versionLine = await commandFirstLine('xcodebuild', ['-version']); + if (!versionLine) return missingAppleToolchainCheck(); return appleProbeCheck({ - firstLaunchOk: xcodebuildAvailable - ? await commandOk('xcodebuild', ['-checkFirstLaunchStatus']) - : false, - selectedPath: xcodeSelectAvailable ? await commandFirstLine('xcode-select', ['-p']) : undefined, - versionLine: xcodebuildAvailable - ? await commandFirstLine('xcodebuild', ['-version']) - : undefined, + selectedPath: await commandFirstLine('xcode-select', ['-p']), + versionLine, }); } @@ -106,20 +99,15 @@ function appleProbeCheck(probe: AppleToolchainProbe): DoctorCheck { id: 'toolchain', status: appleToolchainStatus(probe), summary: appleToolchainSummary(probe), - hint: probe.firstLaunchOk - ? undefined - : 'Complete Xcode first launch/license setup before building apps.', - command: probe.firstLaunchOk ? undefined : 'sudo xcodebuild -runFirstLaunch', evidence: { selectedPath: probe.selectedPath ?? null, xcodeVersion: probe.versionLine ?? null, - firstLaunchOk: probe.firstLaunchOk, }, }; } function appleToolchainStatus(probe: AppleToolchainProbe): DoctorCheck['status'] { - return probe.selectedPath && probe.versionLine && probe.firstLaunchOk ? 'pass' : 'info'; + return probe.versionLine ? 'pass' : 'info'; } function appleToolchainSummary(probe: AppleToolchainProbe): string { @@ -133,9 +121,9 @@ function missingAppleToolchainCheck(): DoctorCheck { return { id: 'toolchain', status: 'info', - summary: 'Apple toolchain: xcode-select and xcodebuild not found on PATH.', - hint: 'Install Xcode and select it with xcode-select.', - evidence: { xcodeSelect: false, xcodebuild: false }, + summary: 'Apple toolchain: xcodebuild version check failed.', + hint: 'Install/select Xcode and complete first launch/license setup if xcodebuild reports it.', + command: 'xcodebuild -version', }; } @@ -161,14 +149,3 @@ async function commandFirstLine(cmd: string, args: string[]): Promise { - try { - return ( - (await runCmd(cmd, args, { allowFailure: true, timeoutMs: TOOLCHAIN_TIMEOUT_MS })) - .exitCode === 0 - ); - } catch { - return false; - } -} From 7adaf3ab51a61c7b0cab5bc67c8535c9b1406c6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 11:52:36 +0200 Subject: [PATCH 25/29] fix: keep scoped simulator hint generic --- src/kernel/device.ts | 2 +- src/utils/__tests__/device.test.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/kernel/device.ts b/src/kernel/device.ts index 5d4454f21..5a1b13e26 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -227,7 +227,7 @@ function throwNoDevicesFound(selector: DeviceSelector, context: DeviceSelectionC 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`, + hint: `The simulator set at "${simulatorSetPath}" appears to be empty. Create a compatible simulator first with xcrun simctl --set "${simulatorSetPath}" create, or remove the scoped simulator set.`, selector, }); } diff --git a/src/utils/__tests__/device.test.ts b/src/utils/__tests__/device.test.ts index a599949f8..2aa301acd 100644 --- a/src/utils/__tests__/device.test.ts +++ b/src/utils/__tests__/device.test.ts @@ -113,6 +113,7 @@ test('resolveDevice throws DEVICE_NOT_FOUND with scoped set guidance when simula assert.ok(typeof err.details?.hint === 'string'); assert.match(err.details.hint as string, /simctl --set/); assert.match(err.details.hint as string, /create/); + assert.doesNotMatch(err.details.hint as string, /iPhone 16|SimRuntime\.iOS-18-0/); }); test('resolveDevice throws generic DEVICE_NOT_FOUND when no simulatorSetPath and no devices found', async () => { From 96004d02cb3a7c164747ecbe7db12aed2d14bc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 11:54:00 +0200 Subject: [PATCH 26/29] fix: clarify doctor Xcode selection context --- src/daemon/handlers/session-doctor-toolchain.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/daemon/handlers/session-doctor-toolchain.ts b/src/daemon/handlers/session-doctor-toolchain.ts index c5746b4f5..d1f3720ac 100644 --- a/src/daemon/handlers/session-doctor-toolchain.ts +++ b/src/daemon/handlers/session-doctor-toolchain.ts @@ -111,10 +111,11 @@ function appleToolchainStatus(probe: AppleToolchainProbe): DoctorCheck['status'] } function appleToolchainSummary(probe: AppleToolchainProbe): string { - if (probe.selectedPath && probe.versionLine) { - return `Apple toolchain: ${probe.versionLine}; xcode-select ${probe.selectedPath}.`; + if (!probe.versionLine) return 'Apple toolchain: xcodebuild version check failed.'; + if (!probe.selectedPath) { + return `Apple toolchain: ${probe.versionLine}; xcode-select path unavailable.`; } - return 'Apple toolchain: Xcode selection or version check failed.'; + return `Apple toolchain: ${probe.versionLine}; xcode-select ${probe.selectedPath}.`; } function missingAppleToolchainCheck(): DoctorCheck { From e3eb13298460b6f39e56712611bd187fccc5dcaf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 13:11:37 +0200 Subject: [PATCH 27/29] fix: recognize provider scope in remote doctor --- src/commands/management/output.test.ts | 3 +- src/daemon/handlers/session-doctor-options.ts | 30 ++++++++++++++--- .../provider-scenarios/doctor.test.ts | 33 +++++++++++++++++++ 3 files changed, 59 insertions(+), 7 deletions(-) diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index 5c2610a63..400994592 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -1,6 +1,5 @@ import { describe, expect, test } from 'vitest'; -import { managementCliOutputFormatters, openCliOutput } from './output.ts'; -import { doctorCliOutput } from './output.ts'; +import { doctorCliOutput, managementCliOutputFormatters, openCliOutput } from './output.ts'; import { markDoctorProgressRendered } from '../../cli-doctor-output.ts'; import { withNoColor } from '../../__tests__/test-utils/index.ts'; diff --git a/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts index a191dcf05..85ca805ea 100644 --- a/src/daemon/handlers/session-doctor-options.ts +++ b/src/daemon/handlers/session-doctor-options.ts @@ -5,7 +5,27 @@ import type { DoctorCheck, DoctorKind, DoctorOptions } from './session-doctor-ty const DEFAULT_METRO_HOST = '127.0.0.1'; const DEFAULT_METRO_PORT = 8081; -const REMOTE_CONNECTION_FLAG_KEYS = ['daemonBaseUrl', 'tenant', 'runId', 'leaseId'] as const; +const REMOTE_CONNECTION_FLAG_KEYS = [ + 'daemonBaseUrl', + 'tenant', + 'runId', + 'leaseId', + 'leaseProvider', +] as const; +const REMOTE_PROVIDER_FLAG_KEYS = [ + 'provider', + 'providerSessionId', + 'providerApp', + 'providerOsVersion', + 'providerProject', + 'providerBuild', + 'providerSessionName', + 'awsProjectArn', + 'awsDeviceArn', + 'awsAppArn', + 'awsRegion', + 'awsInteractionMode', +] as const; export function readDoctorOptions( req: DaemonRequest, @@ -36,8 +56,8 @@ export function remoteConnectionChecks( { 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.', + summary: 'No remote daemon/session or provider scope is configured.', + hint: 'Use connect, --remote-config , or direct remote/provider flags for the command.', }, ]; } @@ -45,7 +65,7 @@ export function remoteConnectionChecks( { id: 'remote-connection', status: options.required ? 'pass' : 'info', - summary: 'Remote daemon/session scope is configured.', + summary: 'Remote daemon/session or provider scope is configured.', evidence, }, ]; @@ -117,7 +137,7 @@ function shouldProbeMetro(req: DaemonRequest, kind: DoctorKind): boolean { function remoteConnectionEvidence(req: DaemonRequest): Record | undefined { const configured = Object.fromEntries( - REMOTE_CONNECTION_FLAG_KEYS.flatMap((key) => + [...REMOTE_CONNECTION_FLAG_KEYS, ...REMOTE_PROVIDER_FLAG_KEYS].flatMap((key) => typeof req.flags?.[key] === 'string' ? [[key, '']] : [], ), ); diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index ec37296cb..54787f67f 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -182,6 +182,39 @@ test('Provider-backed integration doctor --remote skips local device inventory', ); }); +test('Provider-backed integration doctor --remote accepts provider profile scope', 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, + leaseProvider: 'browserstack', + providerApp: 'bs://app-id', + providerOsVersion: '14.0', + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'pass'); + const remote = assertDoctorCheck(data, 'remote-connection', 'pass'); + assert.deepEqual(remote.evidence, { + leaseProvider: '', + providerApp: '', + providerOsVersion: '', + }); + assertNoDoctorCheck(data, 'device'); + assert.equal(inventoryCalls, 0); + }, + ); +}); + test('Provider-backed integration doctor --remote fails without remote scope', async () => { await withProviderScenarioResource( async () => From 59880fb31652f3da3334b5f19316f6ce5326717c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 13:38:02 +0200 Subject: [PATCH 28/29] fix: address doctor review gaps --- src/client/client-types.ts | 2 + src/commands/management/doctor.ts | 8 +++- src/core/dispatch-resolve.ts | 26 ++++++++++--- src/daemon/handlers/session-device-utils.ts | 5 ++- src/daemon/handlers/session-doctor-options.ts | 12 +++++- src/daemon/handlers/session-state.ts | 1 + src/kernel/device.ts | 14 +++++++ src/utils/__tests__/args.test.ts | 19 ++++++++++ src/utils/__tests__/device.test.ts | 37 +++++++++++++++++++ .../provider-scenarios/doctor.test.ts | 9 ++++- 10 files changed, 121 insertions(+), 12 deletions(-) diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 8df24f3bd..77e98a2ef 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -507,6 +507,8 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & { export type DoctorCommandOptions = DeviceCommandBaseOptions & { targetApp?: string; remote?: boolean; + metroHost?: string; + metroPort?: number; }; export type ViewportCommandOptions = DeviceCommandBaseOptions & { diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index c1afbb3d0..f2bbba76c 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -18,6 +18,8 @@ const doctorCommandMetadata = defineFieldCommandMetadata( remote: commandInput.booleanField( 'Check remote connection setup instead of local device inventory.', ), + metroHost: commandInput.stringField('Metro host to probe.'), + metroPort: commandInput.integerField('Metro port to probe.', { min: 1, max: 65_535 }), }, ); @@ -27,17 +29,19 @@ const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, ( const doctorCliSchema = { usageOverride: - 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--remote]', + 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--remote] [--metro-host ] [--metro-port ]', helpDescription: 'Read-only preflight for QA and dogfood runs. Reports local device inventory, active sessions, optional app discovery, scoped toolchain info, and Metro reachability inferred from cwd/runtime. Pass --app to verify a target app on the one matching booted device without opening a session. 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: ['targetApp', 'remote'], + allowedFlags: ['targetApp', 'remote', 'metroHost', 'metroPort'], } as const satisfies CommandSchemaOverride; const doctorCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), targetApp: flags.targetApp, remote: flags.remote, + metroHost: flags.metroHost, + metroPort: flags.metroPort, }); const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor); diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 400d8994d..f0cc62cf2 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -48,6 +48,10 @@ type AppleDeviceSelector = { serial?: string; }; +type ResolveTargetDeviceOptions = { + allowStoppedAndroidAvdPlaceholders?: boolean; +}; + /** * Resolves the best iOS device given pre-fetched candidates. When no explicit * device selector was used, physical devices are rejected in favour of a @@ -115,10 +119,17 @@ function hasExplicitAppleDeviceSelector(selector: AppleDeviceSelector): boolean return Boolean(selector.udid || selector.serial || selector.deviceName); } -export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise { +export async function resolveTargetDevice( + flags: ResolveDeviceFlags, + options: ResolveTargetDeviceOptions = {}, +): Promise { const inventoryRequest = buildDeviceInventoryRequestFromFlags(flags); const { iosSimulatorSetPath, ...selector } = inventoryRequest; - const cacheKey = buildResolveTargetDeviceCacheKey(inventoryRequest); + const cacheKey = buildResolveTargetDeviceCacheKey(inventoryRequest, options); + const selectionContext = { + simulatorSetPath: iosSimulatorSetPath, + allowStoppedAndroidAvdPlaceholders: options.allowStoppedAndroidAvdPlaceholders, + }; const diagnosticData = { platform: inventoryRequest.platform, target: flags.target, @@ -144,7 +155,7 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise { const shouldUseExplicitSelector = hasExplicitDeviceSelector(params.flags); const device = shouldUseExplicitSelector || !params.session - ? await resolveTargetDevice(params.flags ?? {}) + ? await resolveTargetDevice(params.flags ?? {}, { + allowStoppedAndroidAvdPlaceholders: params.allowStoppedAndroidAvdPlaceholders, + }) : await refreshSessionDeviceIfNeeded(params.session.device); if (params.ensureReady !== false) { await ensureDeviceReady(device); diff --git a/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts index 85ca805ea..683a60ee8 100644 --- a/src/daemon/handlers/session-doctor-options.ts +++ b/src/daemon/handlers/session-doctor-options.ts @@ -33,8 +33,14 @@ export function readDoctorOptions( ): DoctorOptions { const kind = detectProjectRuntimeKind(req.meta?.cwd); const targetApp = readNonEmptyString(req.flags?.targetApp) ?? session?.appBundleId; - const metroHost = readNonEmptyString(req.runtime?.metroHost) ?? DEFAULT_METRO_HOST; - const metroPort = readPositivePort(req.runtime?.metroPort) ?? DEFAULT_METRO_PORT; + const metroHost = + readNonEmptyString(req.flags?.metroHost) ?? + readNonEmptyString(req.runtime?.metroHost) ?? + DEFAULT_METRO_HOST; + const metroPort = + readPositivePort(req.flags?.metroPort) ?? + readPositivePort(req.runtime?.metroPort) ?? + DEFAULT_METRO_PORT; return { targetApp, metroHost, @@ -130,6 +136,8 @@ export function sessionChecks( function shouldProbeMetro(req: DaemonRequest, kind: DoctorKind): boolean { return ( kind !== 'auto' || + typeof req.flags?.metroPort === 'number' || + typeof req.flags?.metroHost === 'string' || typeof req.runtime?.metroPort === 'number' || typeof req.runtime?.metroHost === 'string' ); diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index f615597e2..d4623722a 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -171,6 +171,7 @@ export async function handleSessionStateCommands(params: { session, flags, ensureReady: false, + allowStoppedAndroidAvdPlaceholders: true, }); } catch (error) { const appErr = asAppError(error); diff --git a/src/kernel/device.ts b/src/kernel/device.ts index 5a1b13e26..4eb3146b0 100644 --- a/src/kernel/device.ts +++ b/src/kernel/device.ts @@ -37,6 +37,7 @@ export type DeviceSelector = { type DeviceSelectionContext = { simulatorSetPath?: string; + allowStoppedAndroidAvdPlaceholders?: boolean; }; export function isApplePlatform( @@ -162,6 +163,10 @@ export async function resolveDevice( return match; } + if (context.allowStoppedAndroidAvdPlaceholders !== true) { + candidates = candidates.filter((device) => !isStoppedAndroidAvdPlaceholder(device)); + } + if (selector.deviceName) { const normalizedName = normalizeDeviceName(selector.deviceName); const match = candidates.find((device) => normalizeDeviceName(device.name) === normalizedName); @@ -194,6 +199,15 @@ export async function resolveDevice( return selected; } +function isStoppedAndroidAvdPlaceholder(device: DeviceInfo): boolean { + return ( + device.platform === 'android' && + device.kind === 'emulator' && + device.booted === false && + !/^emulator-\d+$/.test(device.id) + ); +} + export function matchesDeviceSelector( device: DeviceInfo, selector: DeviceSelector, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 2c84bd010..c7f573d8d 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -113,6 +113,25 @@ test('parseArgs recognizes command-specific flag combinations', async () => { assert.equal(parsed.flags.remoteConfig, './remote.json'); }, }, + { + label: 'doctor metro override', + argv: [ + 'doctor', + '--platform', + 'android', + '--metro-host', + '127.0.0.1', + '--metro-port', + '9090', + ], + strictFlags: true, + assertParsed: (parsed) => { + assert.equal(parsed.command, 'doctor'); + assert.equal(parsed.flags.platform, 'android'); + assert.equal(parsed.flags.metroHost, '127.0.0.1'); + assert.equal(parsed.flags.metroPort, 9090); + }, + }, { label: 'open --platform apple alias', argv: ['open', 'Settings', '--platform', 'apple', '--target', 'tv'], diff --git a/src/utils/__tests__/device.test.ts b/src/utils/__tests__/device.test.ts index 2aa301acd..6589d0621 100644 --- a/src/utils/__tests__/device.test.ts +++ b/src/utils/__tests__/device.test.ts @@ -135,6 +135,43 @@ test('resolveDevice does not apply scoped set guidance for non-iOS platform with assert.equal(err.details?.simulatorSetPath, undefined); }); +test('resolveDevice ignores stopped Android AVD placeholders for adb-backed selection', async () => { + const stoppedAvd: DeviceInfo = { + platform: 'android', + id: 'Pixel_9_Pro_XL', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: false, + }; + const bootingEmulator: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_8', + kind: 'emulator', + target: 'mobile', + booted: false, + }; + + const implicit = await resolveDevice([stoppedAvd, bootingEmulator], { platform: 'android' }); + assert.equal(implicit.id, 'emulator-5554'); + + const explicit = await resolveDevice([stoppedAvd], { + platform: 'android', + deviceName: 'Pixel_9_Pro_XL', + }).catch((e) => e); + assert.ok(explicit instanceof AppError); + assert.equal(explicit.code, 'DEVICE_NOT_FOUND'); + assert.equal(explicit.message, 'No device named Pixel_9_Pro_XL'); + + const bootSelection = await resolveDevice( + [stoppedAvd], + { platform: 'android', deviceName: 'Pixel_9_Pro_XL' }, + { allowStoppedAndroidAvdPlaceholders: true }, + ); + assert.equal(bootSelection.id, 'Pixel_9_Pro_XL'); +}); + test('resolveDevice applies scoped set guidance when no platform selector specified and simulatorSetPath is set', async () => { const setPath = '/path/to/sessions/abc/Simulators'; const err = await resolveDevice([], {}, { simulatorSetPath: setPath }).catch((e) => e); diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 54787f67f..d45907d40 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -247,6 +247,13 @@ test('Provider-backed integration doctor probes Metro when runtime metadata exis assertRpcOk(withoutRuntime); assertNoDoctorCheck(withoutRuntime.json.result.data, 'metro'); + const withFlagRuntime = await daemon.callCommand('doctor', [], { + platform: 'ios', + metroPort: server.port, + }); + assertRpcOk(withFlagRuntime); + assertDoctorCheck(withFlagRuntime.json.result.data, 'metro', 'pass'); + const withRuntime = await daemon.callCommand( 'doctor', [], @@ -265,7 +272,7 @@ test('Provider-backed integration doctor probes Metro when runtime metadata exis } finally { await server.close(); } -}); +}, 10_000); test('Provider-backed integration doctor surfaces a platform inventory failure even when another platform has devices', async () => { await withProviderScenarioResource( From 0f70cec1221a767c2ff222dd9b33db97d0c04f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 1 Jul 2026 13:53:19 +0200 Subject: [PATCH 29/29] fix: keep doctor metro checks inferred --- src/client/client-types.ts | 2 -- src/commands/management/doctor.ts | 8 ++--- src/daemon/handlers/session-doctor-options.ts | 12 ++------ src/utils/__tests__/args.test.ts | 29 +++++++------------ .../provider-scenarios/doctor.test.ts | 7 ----- 5 files changed, 14 insertions(+), 44 deletions(-) diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 77e98a2ef..8df24f3bd 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -507,8 +507,6 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & { export type DoctorCommandOptions = DeviceCommandBaseOptions & { targetApp?: string; remote?: boolean; - metroHost?: string; - metroPort?: number; }; export type ViewportCommandOptions = DeviceCommandBaseOptions & { diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts index f2bbba76c..c1afbb3d0 100644 --- a/src/commands/management/doctor.ts +++ b/src/commands/management/doctor.ts @@ -18,8 +18,6 @@ const doctorCommandMetadata = defineFieldCommandMetadata( remote: commandInput.booleanField( 'Check remote connection setup instead of local device inventory.', ), - metroHost: commandInput.stringField('Metro host to probe.'), - metroPort: commandInput.integerField('Metro port to probe.', { min: 1, max: 65_535 }), }, ); @@ -29,19 +27,17 @@ const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, ( const doctorCliSchema = { usageOverride: - 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--remote] [--metro-host ] [--metro-port ]', + 'doctor [--platform ios|android|macos|linux|web|apple] [--app ] [--remote]', helpDescription: 'Read-only preflight for QA and dogfood runs. Reports local device inventory, active sessions, optional app discovery, scoped toolchain info, and Metro reachability inferred from cwd/runtime. Pass --app to verify a target app on the one matching booted device without opening a session. 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: ['targetApp', 'remote', 'metroHost', 'metroPort'], + allowedFlags: ['targetApp', 'remote'], } as const satisfies CommandSchemaOverride; const doctorCliReader: CliReader = (_positionals, flags) => ({ ...commonInputFromFlags(flags), targetApp: flags.targetApp, remote: flags.remote, - metroHost: flags.metroHost, - metroPort: flags.metroPort, }); const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor); diff --git a/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts index 683a60ee8..85ca805ea 100644 --- a/src/daemon/handlers/session-doctor-options.ts +++ b/src/daemon/handlers/session-doctor-options.ts @@ -33,14 +33,8 @@ export function readDoctorOptions( ): DoctorOptions { const kind = detectProjectRuntimeKind(req.meta?.cwd); const targetApp = readNonEmptyString(req.flags?.targetApp) ?? session?.appBundleId; - const metroHost = - readNonEmptyString(req.flags?.metroHost) ?? - readNonEmptyString(req.runtime?.metroHost) ?? - DEFAULT_METRO_HOST; - const metroPort = - readPositivePort(req.flags?.metroPort) ?? - readPositivePort(req.runtime?.metroPort) ?? - DEFAULT_METRO_PORT; + const metroHost = readNonEmptyString(req.runtime?.metroHost) ?? DEFAULT_METRO_HOST; + const metroPort = readPositivePort(req.runtime?.metroPort) ?? DEFAULT_METRO_PORT; return { targetApp, metroHost, @@ -136,8 +130,6 @@ export function sessionChecks( function shouldProbeMetro(req: DaemonRequest, kind: DoctorKind): boolean { return ( kind !== 'auto' || - typeof req.flags?.metroPort === 'number' || - typeof req.flags?.metroHost === 'string' || typeof req.runtime?.metroPort === 'number' || typeof req.runtime?.metroHost === 'string' ); diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index c7f573d8d..a2d8b551b 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -113,25 +113,6 @@ test('parseArgs recognizes command-specific flag combinations', async () => { assert.equal(parsed.flags.remoteConfig, './remote.json'); }, }, - { - label: 'doctor metro override', - argv: [ - 'doctor', - '--platform', - 'android', - '--metro-host', - '127.0.0.1', - '--metro-port', - '9090', - ], - strictFlags: true, - assertParsed: (parsed) => { - assert.equal(parsed.command, 'doctor'); - assert.equal(parsed.flags.platform, 'android'); - assert.equal(parsed.flags.metroHost, '127.0.0.1'); - assert.equal(parsed.flags.metroPort, 9090); - }, - }, { label: 'open --platform apple alias', argv: ['open', 'Settings', '--platform', 'apple', '--target', 'tv'], @@ -1963,6 +1944,16 @@ test('strict mode rejects unsupported pilot-command flags', () => { ); }); +test('strict mode rejects Metro override flags on doctor', () => { + assert.throws( + () => parseArgs(['doctor', '--metro-port', '9090'], { strictFlags: true }), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message.includes('not supported for command doctor'), + ); +}); + test('strict mode rejects removed secondary alias', () => { assert.throws( () => parseArgs(['click', '@e5', '--secondary'], { strictFlags: true }), diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index d45907d40..476cc6cca 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -247,13 +247,6 @@ test('Provider-backed integration doctor probes Metro when runtime metadata exis assertRpcOk(withoutRuntime); assertNoDoctorCheck(withoutRuntime.json.result.data, 'metro'); - const withFlagRuntime = await daemon.callCommand('doctor', [], { - platform: 'ios', - metroPort: server.port, - }); - assertRpcOk(withFlagRuntime); - assertDoctorCheck(withFlagRuntime.json.result.data, 'metro', 'pass'); - const withRuntime = await daemon.callCommand( 'doctor', [],