diff --git a/scripts/integration-progress-model.ts b/scripts/integration-progress-model.ts index ac7f03e62..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'], @@ -212,6 +213,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 784970b94..f77a41e09 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, 'command'); + 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..e82ba8ce0 --- /dev/null +++ b/src/cli-doctor-output.ts @@ -0,0 +1,13 @@ +export { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } from './doctor-output.ts'; + +let renderedDoctorProgress = false; + +export function markDoctorProgressRendered(): void { + renderedDoctorProgress = true; +} + +export function consumeDoctorProgressRendered(): boolean { + const rendered = renderedDoctorProgress; + renderedDoctorProgress = false; + return rendered; +} 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..92072b1ba 100644 --- a/src/cli/parser/cli-flags.ts +++ b/src/cli/parser/cli-flags.ts @@ -71,7 +71,9 @@ export type CliFlags = CloudProviderProfileFields & iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; + remote?: boolean; session?: string; + targetApp?: string; metroHost?: string; metroPort?: number; bundleUrl?: string; @@ -493,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'], @@ -678,6 +687,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 3f588a373..3b6f5b68f 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -552,6 +552,11 @@ 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 + 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: agent-device metro reload diff --git a/src/client/client-types.ts b/src/client/client-types.ts index 8a70f01d7..8df24f3bd 100644 --- a/src/client/client-types.ts +++ b/src/client/client-types.ts @@ -504,6 +504,11 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & { timeoutMs?: number; }; +export type DoctorCommandOptions = DeviceCommandBaseOptions & { + targetApp?: string; + remote?: boolean; +}; + export type ViewportCommandOptions = DeviceCommandBaseOptions & { width: number; height: number; @@ -520,6 +525,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>; }; 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..c1afbb3d0 --- /dev/null +++ b/src/commands/management/doctor.ts @@ -0,0 +1,53 @@ +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'; +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: 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.', + ), + }, +); + +const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, (client, input) => + client.command.doctor(input), +); + +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, 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; + +const doctorCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + targetApp: flags.targetApp, + remote: flags.remote, +}); + +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, +}); 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..400994592 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'vitest'; -import { managementCliOutputFormatters, openCliOutput } 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'; describe('openCliOutput', () => { test('prints session state directory on a second line', () => { @@ -66,3 +68,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/__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, 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/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 2def47b95..f0cc62cf2 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' @@ -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,22 +119,19 @@ function hasExplicitAppleDeviceSelector(selector: AppleDeviceSelector): boolean return Boolean(selector.udid || selector.serial || selector.deviceName); } -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, - }); +export async function resolveTargetDevice( + flags: ResolveDeviceFlags, + options: ResolveTargetDeviceOptions = {}, +): Promise { + const inventoryRequest = buildDeviceInventoryRequestFromFlags(flags); + const { iosSimulatorSetPath, ...selector } = inventoryRequest; + const cacheKey = buildResolveTargetDeviceCacheKey(inventoryRequest, options); + const selectionContext = { + simulatorSetPath: iosSimulatorSetPath, + allowStoppedAndroidAvdPlaceholders: options.allowStoppedAndroidAvdPlaceholders, + }; const diagnosticData = { - platform: normalizedPlatform, + platform: inventoryRequest.platform, target: flags.target, cacheHit: false, }; @@ -142,34 +143,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 +268,9 @@ 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, + options: ResolveTargetDeviceOptions, +): string { + return JSON.stringify({ request, options }); } 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/__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/__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/__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-device-utils.ts b/src/daemon/handlers/session-device-utils.ts index f9c38a62a..ef3294df7 100644 --- a/src/daemon/handlers/session-device-utils.ts +++ b/src/daemon/handlers/session-device-utils.ts @@ -41,11 +41,14 @@ export async function resolveCommandDevice(params: { session: SessionState | undefined; flags: DaemonRequest['flags'] | undefined; ensureReady?: boolean; + allowStoppedAndroidAvdPlaceholders?: boolean; }): 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-android.ts b/src/daemon/handlers/session-doctor-android.ts new file mode 100644 index 000000000..bb915dcce --- /dev/null +++ b/src/daemon/handlers/session-doctor-android.ts @@ -0,0 +1,60 @@ +import { + resolveAndroidAdbExecutor, + type AndroidAdbExecutor, +} from '../../platforms/android/adb-executor.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'; + +const ANDROID_PROBE_TIMEOUT_MS = 2000; + +export async function appendAndroidChecks( + checks: DoctorCheck[], + params: { + device: DeviceInfo; + metroPort: number; + shouldProbeMetro: boolean; + androidAdbExecutor?: AndroidAdbExecutor; + }, +): Promise { + const { device, metroPort, shouldProbeMetro, androidAdbExecutor } = params; + if (device.platform !== 'android' || !shouldProbeMetro) return; + const adb = resolveAndroidAdbExecutor(device, androidAdbExecutor); + appendDoctorCheck(checks, await probeAndroidReverse(adb, device.id, metroPort)); +} + +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 }, + }; + } +} diff --git a/src/daemon/handlers/session-doctor-app.ts b/src/daemon/handlers/session-doctor-app.ts new file mode 100644 index 000000000..62bf03403 --- /dev/null +++ b/src/daemon/handlers/session-doctor-app.ts @@ -0,0 +1,92 @@ +import type { DeviceInfo } from '../../kernel/device.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'; + +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 = 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 launchable: ${resolved}`, + evidence: { requested: targetApp, resolved, sessionApp: session?.appBundleId }, + }); + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'target-app', + status: 'fail', + 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 { listAndroidApps } = await import('../../platforms/android/app-lifecycle.ts'); + 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 { listIosApps } = await import('../../platforms/apple/core/apps.ts'); + 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 new file mode 100644 index 000000000..c49c1a17d --- /dev/null +++ b/src/daemon/handlers/session-doctor-device.ts @@ -0,0 +1,283 @@ +import { + buildDeviceInventoryRequestFromFlags, + listDeviceInventory, +} from '../../core/dispatch-resolve.ts'; +import { + countDeviceInventoryByGroup, + LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS, + type DeviceInventoryRequest, + type DeviceInventoryGroup, +} from '../../core/platform-inventory.ts'; +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 } from './session-doctor-types.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; + +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 { + const selector = deviceInventorySelector(req, session); + try { + 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, inventory.failures), + hint: devices.length === 0 ? deviceInventoryFailureHint(inventory.failures) : undefined, + command: devices.length === 0 ? deviceInventoryCommand(selector) : undefined, + 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); + appendDoctorCheck(checks, { + id: 'device', + status: 'fail', + summary: normalized.message, + hint: normalized.hint, + command: 'agent-device devices', + evidence: { code: normalized.code, details: normalized.details }, + }); + 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; +} + +function deviceInventorySelector(req: DaemonRequest, session: SessionState | undefined) { + const flags = req.flags ?? {}; + return buildDeviceInventoryRequestFromFlags({ + platform: flags.platform ?? session?.device.platform, + target: flags.target ?? session?.device.target, + device: flags.device, + udid: flags.udid, + serial: flags.serial, + iosSimulatorDeviceSet: flags.iosSimulatorDeviceSet, + androidDeviceAllowlist: flags.androidDeviceAllowlist, + }); +} + +function filterInventoryForSelector( + devices: DeviceInfo[], + selector: DeviceInventoryRequest, +): DeviceInfo[] { + return devices.filter((device) => + matchesDeviceSelector(device, selector, { includeExplicitSelectors: true }), + ); +} + +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 LOCAL_DEVICE_INVENTORY_PLATFORM_SELECTORS) { + 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; + const summary = `${devices.length} ${deviceInventoryLabel(selector)} ${plural( + devices.length, + 'device', + )} available; ${booted} booted`; + const platformBreakdown = deviceInventorySummaryBreakdown(devices, selector); + return platformBreakdown ? `${summary} (${platformBreakdown}).` : `${summary}.`; +} + +function deviceInventoryLabel( + selector: Pick, +): 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 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, +): string | undefined { + if (selector.platform || selector.target) return undefined; + const groups = countDeviceInventoryByGroup(devices); + const labels = deviceInventoryGroupLabels(); + return (['android', 'apple', 'linux', 'web'] as const) + .flatMap((group) => { + const entry = groups[group]; + return entry.available > 0 + ? [`${labels[group]} ${entry.available} available, ${entry.booted} booted`] + : []; + }) + .join('; '); +} + +function deviceInventoryGroupLabels(): Record { + return { + android: 'Android', + apple: 'Apple', + linux: 'Linux', + web: 'web', + }; +} + +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): 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-metro.ts b/src/daemon/handlers/session-doctor-metro.ts new file mode 100644 index 000000000..0c0cb2725 --- /dev/null +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -0,0 +1,114 @@ +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 + ? 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, + ...(processInfo ? { process: processInfo } : {}), + }, + }; + } catch (error) { + return { + id: 'metro', + status: kind === 'auto' ? 'warn' : 'fail', + summary: `Metro is not reachable at ${url}.`, + 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 }, + }; + } +} + +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/src/daemon/handlers/session-doctor-options.ts b/src/daemon/handlers/session-doctor-options.ts new file mode 100644 index 000000000..85ca805ea --- /dev/null +++ b/src/daemon/handlers/session-doctor-options.ts @@ -0,0 +1,157 @@ +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', + '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, + session: SessionState | undefined, +): 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; + return { + targetApp, + metroHost, + metroPort, + kind, + remote: req.flags?.remote === true, + shouldProbeMetro: shouldProbeMetro(req, kind), + }; +} + +export function remoteConnectionChecks( + req: DaemonRequest, + options: { required?: boolean } = {}, +): DoctorCheck[] { + const evidence = remoteConnectionEvidence(req); + if (!evidence) { + if (!options.required) return []; + return [ + { + id: 'remote-connection', + status: 'fail', + summary: 'No remote daemon/session or provider scope is configured.', + hint: 'Use connect, --remote-config , or direct remote/provider flags for the command.', + }, + ]; + } + return [ + { + id: 'remote-connection', + status: options.required ? 'pass' : 'info', + summary: 'Remote daemon/session or provider scope is configured.', + evidence, + }, + ]; +} + +export function sessionChecks( + sessionStore: SessionStore, + sessionName: string, + session: SessionState | undefined, + options: { remote?: boolean } = {}, +): 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: 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.', + }, + ]; + } + + 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, ...REMOTE_PROVIDER_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..ffb5134ae --- /dev/null +++ b/src/daemon/handlers/session-doctor-output.ts @@ -0,0 +1,39 @@ +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' { + 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: formatDoctorCheckProgressMessage(check), + }); +} + +function formatDoctorCheckProgressMessage(check: DoctorCheck): string { + return [formatDoctorCheckSummaryLine(check), ...formatDoctorCheckDetailLines(check)].join('\n'); +} diff --git a/src/daemon/handlers/session-doctor-toolchain.ts b/src/daemon/handlers/session-doctor-toolchain.ts new file mode 100644 index 000000000..d1f3720ac --- /dev/null +++ b/src/daemon/handlers/session-doctor-toolchain.ts @@ -0,0 +1,152 @@ +import { access } from 'node:fs/promises'; +import path from 'node:path'; +import type { PlatformSelector } from '../../kernel/device.ts'; +import { runCmd } 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 AndroidLicenseState = 'accepted' | 'missing' | 'unknown'; +type AndroidToolchainProbe = { + license: AndroidLicenseState; + sdkRoot: string | undefined; + versionLine: string | undefined; +}; +type AppleToolchainProbe = { + selectedPath: string | undefined; + versionLine: string | undefined; +}; + +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 sdkRoot = process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT; + const license = await androidLicenseState(sdkRoot); + const versionLine = await commandFirstLine('adb', ['version']); + if (!versionLine) return missingAndroidAdbCheck(sdkRoot, license); + + return androidAdbCheck({ + license, + sdkRoot, + versionLine, + }); +} + +function androidAdbCheck(probe: AndroidToolchainProbe): DoctorCheck { + return { + id: 'toolchain', + status: androidToolchainStatus(probe), + summary: probe.versionLine + ? `Android toolchain: ${probe.versionLine}; ${androidSdkSummary(probe.sdkRoot)}.` + : 'Android toolchain: adb is present but version check failed.', + hint: + probe.license === 'missing' + ? 'Accept Android SDK licenses before installing/building apps.' + : undefined, + command: probe.license === 'missing' ? 'sdkmanager --licenses' : undefined, + evidence: { + adbVersion: probe.versionLine ?? null, + androidHome: probe.sdkRoot ?? null, + license: probe.license, + }, + }; +} + +function androidToolchainStatus(probe: AndroidToolchainProbe): DoctorCheck['status'] { + 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 versionLine = await commandFirstLine('xcodebuild', ['-version']); + if (!versionLine) return missingAppleToolchainCheck(); + + return appleProbeCheck({ + selectedPath: await commandFirstLine('xcode-select', ['-p']), + versionLine, + }); +} + +function appleProbeCheck(probe: AppleToolchainProbe): DoctorCheck { + return { + id: 'toolchain', + status: appleToolchainStatus(probe), + summary: appleToolchainSummary(probe), + evidence: { + selectedPath: probe.selectedPath ?? null, + xcodeVersion: probe.versionLine ?? null, + }, + }; +} + +function appleToolchainStatus(probe: AppleToolchainProbe): DoctorCheck['status'] { + return probe.versionLine ? 'pass' : 'info'; +} + +function appleToolchainSummary(probe: AppleToolchainProbe): string { + 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: ${probe.versionLine}; xcode-select ${probe.selectedPath}.`; +} + +function missingAppleToolchainCheck(): DoctorCheck { + return { + id: 'toolchain', + status: 'info', + summary: 'Apple toolchain: xcodebuild version check failed.', + hint: 'Install/select Xcode and complete first launch/license setup if xcodebuild reports it.', + command: 'xcodebuild -version', + }; +} + +async function androidLicenseState(sdkRoot: string | undefined): Promise { + if (!sdkRoot) return 'unknown'; + try { + await access(path.join(sdkRoot, 'licenses', 'android-sdk-license')); + return 'accepted'; + } catch { + return 'missing'; + } +} + +async function commandFirstLine(cmd: string, args: string[]): Promise { + try { + 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; + } +} diff --git a/src/daemon/handlers/session-doctor-types.ts b/src/daemon/handlers/session-doctor-types.ts new file mode 100644 index 000000000..1a46c114c --- /dev/null +++ b/src/daemon/handlers/session-doctor-types.ts @@ -0,0 +1,21 @@ +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; + remote: 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 new file mode 100644 index 000000000..d4f91950b --- /dev/null +++ b/src/daemon/handlers/session-doctor.ts @@ -0,0 +1,142 @@ +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, 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, + resolveDoctorDeviceForAppCheck, +} 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 { appendToolchainChecks } from './session-doctor-toolchain.ts'; +import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; + +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 stateDir = resolveDoctorStateDir(sessionStore, sessionName); + const checks: DoctorCheck[] = []; + appendDoctorChecks( + checks, + { + id: 'agent-device', + status: 'pass', + summary: `agent-device ${readVersion()} using ${stateDir}`, + evidence: { version: readVersion(), stateDir }, + }, + ...remoteConnectionChecks(req, { required: options.remote }), + ...sessionChecks(sessionStore, sessionName, session, { remote: options.remote }), + ); + + if (options.remote) { + 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); + if (appCheckDevice) { + await appendDeviceScopedDoctorChecks(checks, { + androidAdbExecutor, + device: appCheckDevice, + options, + session, + }); + } + 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; + 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, + data: { + status, + summary: doctorSummary(status), + kind: options.kind, + platform: scope.device?.platform ?? scope.inventory?.platform, + target: scope.device?.target ?? scope.inventory?.target, + targetApp: options.targetApp, + metro: + scope.includeMetro && options.shouldProbeMetro + ? { host: options.metroHost, port: options.metroPort } + : undefined, + checks: sortChecks(checks), + }, + }; +} diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index d18a5f7cf..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); @@ -233,6 +234,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/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/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/kernel/device.ts b/src/kernel/device.ts index 1a431220e..4eb3146b0 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; @@ -37,6 +37,7 @@ type DeviceSelector = { type DeviceSelectionContext = { simulatorSetPath?: string; + allowStoppedAndroidAvdPlaceholders?: boolean; }; export function isApplePlatform( @@ -124,6 +125,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'; } @@ -133,72 +143,153 @@ 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(); - - if (selector.platform) { - candidates = candidates.filter((d) => matchesPlatformSelector(d.platform, selector.platform)); - } - if (selector.target) { - candidates = candidates.filter((d) => (d.target ?? 'mobile') === selector.target); - } + let candidates = devices.filter((device) => matchesDeviceSelector(device, selector)); if (selector.udid) { - const match = candidates.find((d) => d.id === selector.udid && isApplePlatform(d.platform)); - if (!match) { + 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((d) => d.id === selector.serial && d.platform === 'android'); + 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 (context.allowStoppedAndroidAvdPlaceholders !== true) { + candidates = candidates.filter((device) => !isStoppedAndroidAvdPlaceholder(device)); + } + 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}`); - } + 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; 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 }); + throwNoDevicesFound(selector, context); } - // 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; + 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; +} - const booted = candidates.filter((d) => d.booted); - const onlyBooted = booted[0]; - if (onlyBooted !== undefined && booted.length === 1) return onlyBooted; +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, + options: { includeExplicitSelectors?: boolean } = {}, +): boolean { + return ( + matchesPlatformSelector(device.platform, selector.platform) && + (!selector.target || (device.target ?? 'mobile') === selector.target) && + (!options.includeExplicitSelectors || matchesExplicitDeviceSelector(device, selector)) + ); +} - // 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]; - if (selected === undefined) { - throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector }); +function matchesExplicitDeviceSelector(device: DeviceInfo, selector: DeviceSelector): boolean { + if (selector.udid && !(device.id === selector.udid && isApplePlatform(device.platform))) { + return false; } - return selected; + 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 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 compatible simulator first with xcrun simctl --set "${simulatorSetPath}" create, or remove the scoped simulator set.`, + selector, + }); + } + 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( + 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 { + 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)); +} + +function isIpadDeviceName(name: string): boolean { + return /\bipad\b/i.test(name); } 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/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, 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/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/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index ea76bebf0..a2d8b551b 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -92,6 +92,27 @@ test('parseArgs recognizes command-specific flag combinations', async () => { assert.equal(parsed.flags.platform, 'ios'); }, }, + { + label: 'doctor 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'); + }, + }, + { + label: 'doctor remote session', + 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'); + }, + }, { label: 'open --platform apple alias', argv: ['open', 'Settings', '--platform', 'apple', '--target', 'tv'], @@ -1820,6 +1841,10 @@ 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/); + 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/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); @@ -1919,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/src/utils/__tests__/device.test.ts b/src/utils/__tests__/device.test.ts index bcf479cc1..6589d0621 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 () => { @@ -134,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); @@ -189,6 +227,52 @@ test('resolveDevice prefers booted simulator over physical device', async () => assert.equal(result.id, 'sim-1'); }); +test('resolveDevice keeps Apple simulator family priority ahead of boot state', async () => { + const tvSimulator: DeviceInfo = { + platform: 'ios', + id: 'tv-sim', + name: 'Apple TV 4K', + kind: 'simulator', + target: 'tv', + booted: true, + }; + const iphoneSimulator: DeviceInfo = { + platform: 'ios', + id: 'iphone-sim', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: false, + }; + + const result = await resolveDevice([tvSimulator, iphoneSimulator], { platform: 'ios' }); + + assert.equal(result.id, 'iphone-sim'); +}); + +test('resolveDevice prefers booted Apple simulator within the same family', async () => { + const shutdownIphone: DeviceInfo = { + platform: 'ios', + id: 'iphone-shutdown', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: false, + }; + const bootedIphone: DeviceInfo = { + platform: 'ios', + id: 'iphone-booted', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + + const result = await resolveDevice([shutdownIphone, bootedIphone], { platform: 'ios' }); + + assert.equal(result.id, 'iphone-booted'); +}); + test('resolveDevice returns physical device when explicitly selected by deviceName', async () => { const physical: DeviceInfo = { platform: 'ios', diff --git a/src/utils/project-runtime.ts b/src/utils/project-runtime.ts new file mode 100644 index 000000000..3e538ee88 --- /dev/null +++ b/src/utils/project-runtime.ts @@ -0,0 +1,37 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type ProjectRuntimeKind = 'auto' | 'react-native' | 'expo'; + +export type PackageJsonShape = { + dependencies?: Record; + devDependencies?: Record; +}; + +export function detectProjectRuntimeKind(cwd: string | undefined): ProjectRuntimeKind { + const packageJson = readProjectPackageJson(cwd); + if (!packageJson) return 'auto'; + return detectProjectRuntimeKindFromPackageJson(packageJson); +} + +export function detectProjectRuntimeKindFromPackageJson( + packageJson: PackageJsonShape, +): ProjectRuntimeKind { + const dependencies = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.devDependencies ?? {}), + }; + if (typeof dependencies.expo === 'string') return 'expo'; + if (typeof dependencies['react-native'] === 'string') return 'react-native'; + return 'auto'; +} + +export function readProjectPackageJson(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 new file mode 100644 index 000000000..476cc6cca --- /dev/null +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -0,0 +1,364 @@ +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'; +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, + withProviderScenarioTempDir, +} from './harness.ts'; + +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 = { + exec: async (args) => { + adbCalls.push([...args]); + return androidDoctorAdbResult(args, server.port); + }, + }; + + try { + 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', JSON.stringify(data.checks)); + assert.equal(data.kind, 'react-native'); + assertDoctorCheck(data, 'device', 'pass'); + assertDoctorCheck(data, 'metro', 'pass'); + assertNoDoctorCheck(data, 'android-reverse'); + assert.deepEqual(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, + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.platform, device.platform); + assert.ok(Array.isArray(data.checks), `${device.platform} checks`); + } + }, + ); +}); + +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) + 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 []; + }, + }), + 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; + + 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 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 () => + 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'); + }, + ); +}); + +test('Provider-backed integration doctor probes Metro when runtime metadata exists 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 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 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, + `http://127.0.0.1:${server.port}/status`, + ); + }, + ); + } 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( + 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`); +} + +function assertDoctorCheck( + data: { + checks: Array<{ + id: string; + status: string; + summary: string; + evidence?: Record; + }>; + }, + id: string, + status: string, +): { 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 { + assert.equal( + data.checks.some((entry) => entry.id === id), + false, + `unexpected ${id}: ${JSON.stringify(data.checks)}`, + ); +} + +function androidDoctorAdbResult( + args: string[], + metroPort: number, +): { + stdout: string; + stderr: string; + 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`, + 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())), + ), + }; +} 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, }; }