diff --git a/src/__tests__/cli-doctor-progress.test.ts b/src/__tests__/cli-doctor-progress.test.ts new file mode 100644 index 000000000..b0e814b0d --- /dev/null +++ b/src/__tests__/cli-doctor-progress.test.ts @@ -0,0 +1,72 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import { formatDoctorProgressEvent } from '../cli-doctor-progress.ts'; +import type { RequestProgressEvent } from '../daemon/request-progress.ts'; +import { withNoColor } from './test-utils/index.ts'; + +test('formatDoctorProgressEvent ignores non-doctor progress events', () => { + const line = formatDoctorProgressEvent({ + type: 'replay-test-suite', + status: 'start', + total: 1, + runnable: 1, + skipped: 0, + artifactsDir: '/tmp/replay-suite', + }); + + assert.equal(line, undefined); +}); + +test('formatDoctorProgressEvent renders doctor pass, info, warn, and fail checks', () => { + const cases: Array<{ event: RequestProgressEvent; expected: string }> = [ + { + event: { + type: 'doctor-check', + id: 'device', + status: 'pass', + summary: 'Selected Pixel (android/mobile)', + index: 1, + }, + expected: '✓ device: Selected Pixel (android/mobile)', + }, + { + event: { + type: 'doctor-check', + id: 'session', + status: 'info', + summary: 'No active session named default. Doctor will use the selected device.', + index: 2, + }, + expected: '- session: No active session named default. Doctor will use the selected device.', + }, + { + event: { + type: 'doctor-check', + id: 'android-reverse', + status: 'warn', + summary: 'Android adb reverse is missing for Metro port 8081.', + index: 3, + command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + expected: + '! android-reverse: Android adb reverse is missing for Metro port 8081.\n run: adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + { + event: { + type: 'doctor-check', + id: 'device', + status: 'fail', + summary: 'No devices found.', + index: 4, + command: 'agent-device devices', + }, + expected: '⨯ device: No devices found.\n run: agent-device devices', + }, + ]; + + withNoColor(() => { + for (const { event, expected } of cases) { + assert.equal(formatDoctorProgressEvent(event), expected); + } + }); +}); diff --git a/src/__tests__/cli-network.test.ts b/src/__tests__/cli-network.test.ts index bdbef7d7f..8fe355e85 100644 --- a/src/__tests__/cli-network.test.ts +++ b/src/__tests__/cli-network.test.ts @@ -124,6 +124,45 @@ test('test command prints suite summary and exits non-zero on failures', async ( assert.match(result.stdout, /Test summary: 1 passed, 1 failed in 0\.025s/); }); +test('doctor command opts into progress rows for human output', async () => { + const result = await runCliCapture(['doctor'], async () => ({ + ok: true, + data: { + status: 'pass', + summary: 'No blockers found.', + checks: [ + { + id: 'agent-device', + status: 'pass', + summary: 'agent-device 0.17.9 using /tmp/agent-device', + }, + ], + }, + })); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.command, 'doctor'); + assert.equal(result.calls[0]?.meta?.requestProgress, 'doctor'); + assert.match(result.stdout, /✓ agent-device: agent-device 0\.17\.9 using \/tmp\/agent-device/); +}); + +test('doctor command keeps json output non-streaming', async () => { + const result = await runCliCapture(['doctor', '--json'], async () => ({ + ok: true, + data: { + status: 'pass', + summary: 'No blockers found.', + checks: [], + }, + })); + + assert.equal(result.code, null); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.meta?.requestProgress, undefined); + assert.match(result.stdout, /"success": true/); +}); + test('test command --verbose prints all test statuses', async () => { const result = await runCliCapture(['test', './suite', '--verbose'], async () => makeReplaySuiteResponse(), diff --git a/src/__tests__/daemon-client-progress.test.ts b/src/__tests__/daemon-client-progress.test.ts index 0a552409d..4a02577f9 100644 --- a/src/__tests__/daemon-client-progress.test.ts +++ b/src/__tests__/daemon-client-progress.test.ts @@ -129,6 +129,66 @@ test('readDaemonSocketProgressResponse parses split progress lines before respon } }); +test('readDaemonSocketProgressResponse prints doctor progress before response envelopes', async () => { + const socket = createMockSocket(); + const req: DaemonRequest = { + session: 'default', + command: 'doctor', + positionals: [], + flags: {}, + token: 'secret', + meta: { requestId: 'req-doctor-progress', requestProgress: 'doctor' }, + }; + let stderr = ''; + const originalStderrWrite = process.stderr.write.bind(process.stderr); + const originalForceColor = process.env.FORCE_COLOR; + const originalNoColor = process.env.NO_COLOR; + + try { + delete process.env.FORCE_COLOR; + process.env.NO_COLOR = '1'; + (process.stderr as any).write = ((chunk: unknown) => { + stderr += String(chunk); + return true; + }) as typeof process.stderr.write; + + const responsePromise = readSocketProgressResponse(socket, req); + const progressLine = JSON.stringify({ + type: 'progress', + event: { + type: 'doctor-check', + id: 'android-reverse', + status: 'warn', + summary: 'Android adb reverse is missing for Metro port 8081.', + index: 3, + command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + }); + const responseLine = JSON.stringify({ + type: 'response', + response: { ok: true, data: { status: 'warn', checks: [] } }, + }); + + socket.emit('data', `${progressLine}\n${responseLine}\n`); + + assert.deepEqual(await responsePromise, { ok: true, data: { status: 'warn', checks: [] } }); + assert.equal( + stderr, + [ + '! android-reverse: Android adb reverse is missing for Metro port 8081.', + ' run: adb -s emulator-5554 reverse tcp:8081 tcp:8081', + '', + ].join('\n'), + ); + } finally { + if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor; + else delete process.env.FORCE_COLOR; + if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor; + else delete process.env.NO_COLOR; + process.stderr.write = originalStderrWrite; + } +}); + test('readDaemonSocketProgressResponse rewrites live progress and clears it for final result', async () => { const socket = createMockSocket(); const req: DaemonRequest = { diff --git a/src/__tests__/test-utils/color.ts b/src/__tests__/test-utils/color.ts new file mode 100644 index 000000000..3221893f6 --- /dev/null +++ b/src/__tests__/test-utils/color.ts @@ -0,0 +1,14 @@ +export function withNoColor(run: () => T): T { + const originalForceColor = process.env.FORCE_COLOR; + const originalNoColor = process.env.NO_COLOR; + delete process.env.FORCE_COLOR; + process.env.NO_COLOR = '1'; + try { + return run(); + } finally { + if (typeof originalForceColor === 'string') process.env.FORCE_COLOR = originalForceColor; + else delete process.env.FORCE_COLOR; + if (typeof originalNoColor === 'string') process.env.NO_COLOR = originalNoColor; + else delete process.env.NO_COLOR; + } +} diff --git a/src/__tests__/test-utils/index.ts b/src/__tests__/test-utils/index.ts index a648875a7..c3f7c30c2 100644 --- a/src/__tests__/test-utils/index.ts +++ b/src/__tests__/test-utils/index.ts @@ -18,6 +18,8 @@ export { export { makeSnapshotState } from './snapshot-builders.ts'; +export { withNoColor } from './color.ts'; + export { closeLoopbackServer, listenOnLoopback, diff --git a/src/cli-doctor-output.ts b/src/cli-doctor-output.ts new file mode 100644 index 000000000..2cf80d905 --- /dev/null +++ b/src/cli-doctor-output.ts @@ -0,0 +1,39 @@ +import { formatCliStatusMarker, type CliStatusMarkerStatus } from './cli-status-markers.ts'; + +let renderedDoctorProgress = false; + +export function markDoctorProgressRendered(): void { + renderedDoctorProgress = true; +} + +export function consumeDoctorProgressRendered(): boolean { + const rendered = renderedDoctorProgress; + renderedDoctorProgress = false; + return rendered; +} + +export function formatDoctorCheckSummaryLine(check: Record): string { + const statusMarker = formatCliStatusMarker(doctorStatusMarker(check.status)); + return `${statusMarker} ${formatDoctorCheckLabel(check)}`; +} + +export function formatDoctorCheckDetailLines(check: Record): string[] { + if (check.status !== 'fail' && check.status !== 'warn') return []; + if (typeof check.command === 'string') return [` run: ${check.command}`]; + if (typeof check.hint === 'string') return [` hint: ${check.hint}`]; + return []; +} + +function doctorStatusMarker(status: unknown): CliStatusMarkerStatus { + if (status === 'pass') return 'pass'; + if (status === 'fail') return 'fail'; + if (status === 'warn') return 'warn'; + return 'skip'; +} + +function formatDoctorCheckLabel(check: Record): string { + const id = typeof check.id === 'string' && check.id.length > 0 ? check.id : 'check'; + const summary = + typeof check.summary === 'string' && check.summary.length > 0 ? check.summary : id; + return summary === id ? id : `${id}: ${summary}`; +} diff --git a/src/cli-doctor-progress.ts b/src/cli-doctor-progress.ts new file mode 100644 index 000000000..99e7e347c --- /dev/null +++ b/src/cli-doctor-progress.ts @@ -0,0 +1,40 @@ +import type { RequestProgressEvent } from './daemon/request-progress.ts'; +import { + markDoctorProgressRendered, + formatDoctorCheckDetailLines, + formatDoctorCheckSummaryLine, +} from './cli-doctor-output.ts'; + +type DoctorCheckProgressEvent = Extract; + +export type DoctorProgressRenderer = { + render(event: RequestProgressEvent): { text: string; newline: true } | undefined; +}; + +export function createDoctorProgressRenderer(): DoctorProgressRenderer { + const completed = new Set(); + return { + render(event) { + if (event.type !== 'doctor-check') return undefined; + const key = doctorCheckProgressKey(event); + if (completed.has(key)) return undefined; + completed.add(key); + markDoctorProgressRendered(); + const text = formatDoctorProgressEvent(event); + if (!text) return undefined; + return { + text, + newline: true, + }; + }, + }; +} + +export function formatDoctorProgressEvent(event: RequestProgressEvent): string | undefined { + if (event.type !== 'doctor-check') return undefined; + return [formatDoctorCheckSummaryLine(event), ...formatDoctorCheckDetailLines(event)].join('\n'); +} + +function doctorCheckProgressKey(event: DoctorCheckProgressEvent): string { + return [event.index, event.id, event.status, event.summary].join('\0'); +} diff --git a/src/cli-status-markers.ts b/src/cli-status-markers.ts new file mode 100644 index 000000000..e975fe1b0 --- /dev/null +++ b/src/cli-status-markers.ts @@ -0,0 +1,21 @@ +import { colorize, supportsColor } from './utils/output.ts'; + +export type CliStatusMarkerStatus = 'pass' | 'fail' | 'warn' | 'skip'; + +export function formatCliStatusMarker( + status: CliStatusMarkerStatus, + options: { passFormat?: 'green' | 'yellow' } = {}, +): string { + const useColor = supportsColor(process.stderr); + if (status === 'pass') { + const format = options.passFormat ?? 'green'; + return useColor ? colorizeStatusMarker('✓', format) : '✓'; + } + if (status === 'fail') return useColor ? colorizeStatusMarker('⨯', 'red') : '⨯'; + if (status === 'warn') return useColor ? colorizeStatusMarker('!', 'yellow') : '!'; + return useColor ? colorizeStatusMarker('-', 'dim') : '-'; +} + +function colorizeStatusMarker(text: string, format: Parameters[1]): string { + return colorize(text, format, { validateStream: false }); +} diff --git a/src/cli-test-progress.ts b/src/cli-test-progress.ts index 4a135613f..b56d7363c 100644 --- a/src/cli-test-progress.ts +++ b/src/cli-test-progress.ts @@ -3,7 +3,7 @@ import type { RequestProgressEvent } from './daemon/request-progress.ts'; import { replayTestStepLines } from './cli-test-trace.ts'; import type { ReplaySuiteTestResult } from './daemon/types.ts'; import { formatDurationSeconds } from './utils/duration-format.ts'; -import { colorize, supportsColor } from './utils/output.ts'; +import { formatCliStatusMarker } from './cli-status-markers.ts'; type ReplayTestCaseProgressEvent = Extract; type ReplayTestProgressFormatOptions = { @@ -137,18 +137,14 @@ function formatReplayTestProgressName(event: ReplayTestCaseProgressEvent): strin } function formatReplayTestProgressStatusLabel(event: ReplayTestCaseProgressEvent): string { - const useColor = supportsColor(process.stderr); if (event.status === 'pass') { - const format = event.attempt && event.attempt > 1 ? 'yellow' : 'green'; - return useColor ? colorizeProgressMarker('✓', format) : '✓'; + return formatCliStatusMarker('pass', { + passFormat: event.attempt && event.attempt > 1 ? 'yellow' : 'green', + }); } - if (event.status === 'fail') return useColor ? colorizeProgressMarker('⨯', 'red') : '⨯'; + if (event.status === 'fail') return formatCliStatusMarker('fail'); if (event.status === 'progress') return '⊙'; - return useColor ? colorizeProgressMarker('-', 'dim') : '-'; -} - -function colorizeProgressMarker(text: string, format: Parameters[1]): string { - return colorize(text, format, { validateStream: false }); + return formatCliStatusMarker('skip'); } function formatReplayTestProgressShardSuffix(event: ReplayTestCaseProgressEvent): string { diff --git a/src/cli.ts b/src/cli.ts index 13307a124..a66d02d39 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -542,13 +542,16 @@ function createCliDaemonTransport(options: { transport: AgentDeviceDaemonTransport; }): AgentDeviceDaemonTransport { const { command, flags, transport } = options; - if (command !== 'test' || flags.json) return transport; + if (flags.json) return transport; + const requestProgress = + command === 'test' ? 'replay-test' : command === 'doctor' ? 'doctor' : undefined; + if (!requestProgress) return transport; return async (req) => await transport({ ...req, meta: { ...req.meta, - requestProgress: 'replay-test', + requestProgress, }, }); } diff --git a/src/client-types.ts b/src/client-types.ts index 4885e47f6..1a9a6139e 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -509,6 +509,8 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & { timeoutMs?: number; }; +export type DoctorCommandOptions = DeviceCommandBaseOptions; + export type ViewportCommandOptions = DeviceCommandBaseOptions & { width: number; height: number; @@ -530,6 +532,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.ts b/src/client.ts index efca6d455..8b2af4664 100644 --- a/src/client.ts +++ b/src/client.ts @@ -108,6 +108,7 @@ export function createAgentDeviceClient( keyboard: async (options = {}) => await executeCommand('keyboard', options), clipboard: async (options) => await executeCommand('clipboard', options), reactNative: async (options) => await executeCommand('react-native', options), + doctor: async (options = {}) => await executeCommand('doctor', options), prepare: async (options) => await executeCommand('prepare', options), viewport: async (options) => await executeCommand('viewport', options), }, diff --git a/src/command-catalog.ts b/src/command-catalog.ts index 8d5ccf331..fb26e89ee 100644 --- a/src/command-catalog.ts +++ b/src/command-catalog.ts @@ -10,6 +10,7 @@ export const PUBLIC_COMMANDS = { close: 'close', clipboard: 'clipboard', devices: 'devices', + doctor: 'doctor', diff: 'diff', fill: 'fill', find: 'find', @@ -116,6 +117,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet( PUBLIC_COMMANDS.prepare, PUBLIC_COMMANDS.batch, PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.doctor, PUBLIC_COMMANDS.gesture, PUBLIC_COMMANDS.replay, PUBLIC_COMMANDS.test, diff --git a/src/commands/management/doctor.ts b/src/commands/management/doctor.ts new file mode 100644 index 000000000..6446791aa --- /dev/null +++ b/src/commands/management/doctor.ts @@ -0,0 +1,48 @@ +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.', + { + 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] [--remote]', + helpDescription: + 'Read-only preflight for QA and dogfood runs. Reports local device inventory, active sessions, app discovery from the active session, Metro reachability inferred from cwd/runtime, and obvious React Native overlay blockers from the current session snapshot. Use --remote to check remote connection setup without probing local devices. Default output is compact; use --json for full checks and evidence.', + summary: 'Preflight device, app, Metro, and RN/Expo readiness', + allowedFlags: ['remote'], +} as const satisfies CommandSchemaOverride; + +const doctorCliReader: CliReader = (_positionals, flags) => ({ + ...commonInputFromFlags(flags), + remote: flags.remote, +}); + +const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor); + +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 1d63dae3c..34d5c1d68 100644 --- a/src/commands/management/index.ts +++ b/src/commands/management/index.ts @@ -1,6 +1,7 @@ import { defineCommandFamilyFromFacets } from '../family/types.ts'; import { appsCommandFacet, closeCommandFacet, openCommandFacet } from './app.ts'; import { deviceManagementCommandFacets } from './device.ts'; +import { doctorCommandFacet } from './doctor.ts'; import { installManagementCommandFacets } from './install.ts'; import { prepareCommandFacet } from './prepare.ts'; import { pushManagementCommandFacets } from './push.ts'; @@ -11,6 +12,7 @@ export const managementCommandFamily = defineCommandFamilyFromFacets({ name: 'management', commands: [ ...deviceManagementCommandFacets, + doctorCommandFacet, prepareCommandFacet, appsCommandFacet, sessionCommandFacet, diff --git a/src/commands/management/output.test.ts b/src/commands/management/output.test.ts index dfe7e176f..b628ae43b 100644 --- a/src/commands/management/output.test.ts +++ b/src/commands/management/output.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from 'vitest'; -import { openCliOutput } from './output.ts'; +import { doctorCliOutput, openCliOutput } from './output.ts'; +import { markDoctorProgressRendered } from '../../cli-doctor-output.ts'; +import { withNoColor } from '../../__tests__/test-utils/index.ts'; describe('openCliOutput', () => { test('prints session state directory on a second line', () => { @@ -18,3 +20,91 @@ describe('openCliOutput', () => { }); }); }); + +describe('doctorCliOutput', () => { + test('prints passing checks by default using test-style status markers', () => { + const output = withNoColor(() => + doctorCliOutput({ + status: 'pass', + summary: 'No blockers found.', + checks: [ + { + id: 'agent-device', + status: 'pass', + summary: 'agent-device 0.17.9 using /tmp/agent-device', + }, + { + id: 'device', + status: 'pass', + summary: 'Selected Pixel (android)', + }, + { + id: 'session', + status: 'info', + summary: 'No active session named default. Doctor will use the selected device.', + }, + ], + }), + ); + + expect(output.text).toBe( + [ + 'Doctor: pass', + '✓ agent-device: agent-device 0.17.9 using /tmp/agent-device', + '✓ device: Selected Pixel (android)', + '- session: No active session named default. Doctor will use the selected device.', + ].join('\n'), + ); + }); + + test('keeps warning and failure recovery details under the relevant row', () => { + const output = withNoColor(() => + doctorCliOutput({ + status: 'fail', + checks: [ + { + id: 'device', + status: 'fail', + summary: 'No devices found.', + command: 'agent-device devices', + }, + { + id: 'android-reverse', + status: 'warn', + summary: 'Android adb reverse is missing for Metro port 8081.', + command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081', + }, + ], + }), + ); + + expect(output.text).toBe( + [ + 'Doctor: fail', + '⨯ device: No devices found.', + ' run: agent-device devices', + '! android-reverse: Android adb reverse is missing for Metro port 8081.', + ' run: adb -s emulator-5554 reverse tcp:8081 tcp:8081', + ].join('\n'), + ); + }); + + test('prints only the summary after streamed progress rendered the checks', () => { + const output = withNoColor(() => { + markDoctorProgressRendered(); + return doctorCliOutput({ + status: 'pass', + summary: 'No blockers found.', + checks: [ + { + id: 'device', + status: 'pass', + summary: 'Selected Pixel (android)', + }, + ], + }); + }); + + expect(output.text).toBe(['Doctor: pass', 'No blockers found.'].join('\n')); + }); +}); diff --git a/src/commands/management/output.ts b/src/commands/management/output.ts index ac1bf3cde..64f21f874 100644 --- a/src/commands/management/output.ts +++ b/src/commands/management/output.ts @@ -18,6 +18,11 @@ import type { } from '../../client-types.ts'; import { readCommandMessage } from '../../utils/success-text.ts'; import type { CliOutput } from '../command-contract.ts'; +import { + consumeDoctorProgressRendered, + formatDoctorCheckDetailLines, + formatDoctorCheckSummaryLine, +} from '../../cli-doctor-output.ts'; import { messageCliOutput, messageOutput, @@ -101,10 +106,32 @@ function shutdownCliOutput(result: CommandRequestResult): CliOutput { return { data, text: `${status}: ${device} (${platform})` }; } +export function doctorCliOutput(result: CommandRequestResult): CliOutput { + const data = result as Record; + const status = typeof data.status === 'string' ? data.status : 'unknown'; + const lines = [`Doctor: ${status}`]; + const checks = readDoctorChecks(data.checks); + + if (consumeDoctorProgressRendered()) { + const summary = typeof data.summary === 'string' ? data.summary : undefined; + if (summary) lines.push(summary); + } else if (checks.length === 0) { + const summary = typeof data.summary === 'string' ? data.summary : 'No blockers found.'; + lines.push(summary); + } else { + for (const check of checks) { + lines.push(formatDoctorCheckSummaryLine(check)); + lines.push(...formatDoctorCheckDetailLines(check)); + } + } + return { data, text: lines.join('\n') }; +} + export const managementCliOutputFormatters = { boot: resultOutput(bootCliOutput), shutdown: resultOutput(shutdownCliOutput), devices: resultOutput(devicesCliOutput), + doctor: resultOutput(doctorCliOutput), apps: ({ input, result }) => appsCliOutput({ result: result as Parameters[0]['result'], @@ -126,3 +153,12 @@ function formatDeviceLine(device: AgentDeviceDevice): string { const booted = typeof device.booted === 'boolean' ? ` booted=${device.booted}` : ''; return `${device.name} (${device.platform}${kind}${target})${booted}`; } + +function readDoctorChecks(value: unknown): Array> { + return Array.isArray(value) + ? value.filter( + (check): check is Record => + Boolean(check) && typeof check === 'object' && !Array.isArray(check), + ) + : []; +} diff --git a/src/contracts.ts b/src/contracts.ts index c2a3a514c..e6b6069c1 100644 --- a/src/contracts.ts +++ b/src/contracts.ts @@ -77,7 +77,7 @@ export type DaemonRequestMeta = { materializationId?: string; lockPolicy?: DaemonLockPolicy; lockPlatform?: PlatformSelector; - requestProgress?: 'replay-test'; + requestProgress?: 'replay-test' | 'doctor'; }; export type DaemonRequest = { diff --git a/src/core/dispatch-resolve.ts b/src/core/dispatch-resolve.ts index 0499de222..3fb6ae305 100644 --- a/src/core/dispatch-resolve.ts +++ b/src/core/dispatch-resolve.ts @@ -16,7 +16,7 @@ import { } from '../utils/device-isolation.ts'; import type { CliFlags } from '../utils/cli-flags.ts'; import { listLocalDeviceInventory, type DeviceInventoryRequest } from './platform-inventory.ts'; -type ResolveDeviceFlags = Pick< +export type ResolveDeviceFlags = Pick< CliFlags, | 'platform' | 'target' @@ -112,21 +112,11 @@ function hasExplicitAppleDeviceSelector(selector: AppleDeviceSelector): boolean } export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise { - const normalizedPlatform = normalizePlatformSelector(flags.platform); - const iosSimulatorSetPath = resolveAppleSimulatorSetPathForSelector({ - simulatorSetPath: resolveIosSimulatorDeviceSetPath(flags.iosSimulatorDeviceSet), - platform: normalizedPlatform, - target: flags.target, - }); - const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags.androidDeviceAllowlist); - const cacheKey = buildResolveTargetDeviceCacheKey({ - flags, - normalizedPlatform, - iosSimulatorSetPath, - androidSerialAllowlist, - }); + const inventoryRequest = buildDeviceInventoryRequestFromFlags(flags); + const { iosSimulatorSetPath, ...selector } = inventoryRequest; + const cacheKey = buildResolveTargetDeviceCacheKey(inventoryRequest); const diagnosticData = { - platform: normalizedPlatform, + platform: inventoryRequest.platform, target: flags.target, cacheHit: false, }; @@ -138,27 +128,7 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise(task: () => Promise): Promise { if (resolveTargetDeviceCacheScope.getStore()) return await task(); return await resolveTargetDeviceCacheScope.run(new Map(), task); @@ -256,22 +249,6 @@ function cacheResolvedTargetDevice(cacheKey: string, device: DeviceInfo): Device return device; } -function buildResolveTargetDeviceCacheKey(params: { - flags: ResolveDeviceFlags; - normalizedPlatform?: PlatformSelector; - iosSimulatorSetPath?: string; - androidSerialAllowlist?: ReadonlySet; -}): string { - const { flags, normalizedPlatform, iosSimulatorSetPath, androidSerialAllowlist } = params; - return JSON.stringify({ - platform: normalizedPlatform, - target: flags.target, - device: flags.device, - udid: flags.udid, - serial: flags.serial, - iosSimulatorSetPath, - androidSerialAllowlist: androidSerialAllowlist - ? Array.from(androidSerialAllowlist).sort() - : undefined, - }); +function buildResolveTargetDeviceCacheKey(request: DeviceInventoryRequest): string { + return JSON.stringify(request); } diff --git a/src/daemon-client-progress.ts b/src/daemon-client-progress.ts index 26a647027..bbef51251 100644 --- a/src/daemon-client-progress.ts +++ b/src/daemon-client-progress.ts @@ -8,13 +8,20 @@ import { createReplayTestProgressRenderer, type ReplayTestProgressRenderer, } from './cli-test-progress.ts'; +import { + createDoctorProgressRenderer, + type DoctorProgressRenderer, +} from './cli-doctor-progress.ts'; import { isDaemonProgressEnvelope, isDaemonResponseEnvelope, shouldStreamRequestProgress, } from './daemon/request-progress-protocol.ts'; -function createRequestProgressRenderer(req: DaemonRequest): ReplayTestProgressRenderer { +type RequestProgressRenderer = ReplayTestProgressRenderer | DoctorProgressRenderer; + +function createRequestProgressRenderer(req: DaemonRequest): RequestProgressRenderer { + if (req.meta?.requestProgress === 'doctor') return createDoctorProgressRenderer(); return createReplayTestProgressRenderer({ verbose: Boolean(req.flags?.verbose || req.meta?.debug), liveProgress: shouldRenderLiveProgress(), @@ -24,7 +31,7 @@ function createRequestProgressRenderer(req: DaemonRequest): ReplayTestProgressRe function writeRequestProgressEvent( event: RequestProgressEvent, - renderer: ReplayTestProgressRenderer, + renderer: RequestProgressRenderer, ): void { const output = renderer.render(event); if (!output) return; diff --git a/src/daemon/__tests__/daemon-command-registry.test.ts b/src/daemon/__tests__/daemon-command-registry.test.ts index 269e39a6b..660cee28c 100644 --- a/src/daemon/__tests__/daemon-command-registry.test.ts +++ b/src/daemon/__tests__/daemon-command-registry.test.ts @@ -40,6 +40,7 @@ test('daemon command registry owns specialized handler routes', () => { test('daemon command registry owns session handler subroutes', () => { assert.equal(getSessionCommandKind(INTERNAL_COMMANDS.sessionList), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.devices), 'inventory'); + assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.doctor), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.apps), 'inventory'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.boot), 'state'); assert.equal(getSessionCommandKind(PUBLIC_COMMANDS.shutdown), 'state'); @@ -53,6 +54,7 @@ test('daemon command registry preserves request admission traits', () => { for (const command of [ INTERNAL_COMMANDS.sessionList, PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.doctor, INTERNAL_COMMANDS.releaseMaterializedPaths, INTERNAL_COMMANDS.leaseAllocate, INTERNAL_COMMANDS.leaseHeartbeat, @@ -65,6 +67,7 @@ test('daemon command registry preserves request admission traits', () => { for (const command of [ INTERNAL_COMMANDS.sessionList, PUBLIC_COMMANDS.devices, + PUBLIC_COMMANDS.doctor, INTERNAL_COMMANDS.releaseMaterializedPaths, ]) { assert.equal(shouldValidateSessionSelector(command), false, `${command} selector`); @@ -139,6 +142,7 @@ test('daemon command registry preserves Android modal and lock-policy traits', ( assert.equal(shouldGuardAndroidBlockingDialog(PUBLIC_COMMANDS.get), false); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.apps), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.devices), true); + assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.doctor), true); assert.equal(canOverrideLockPolicySelector(PUBLIC_COMMANDS.open), false); }); @@ -152,6 +156,7 @@ test('daemon command registry preserves provider device resolution traits', () = false, ); assert.equal(usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.open)), true); + assert.equal(usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.doctor)), true); assert.equal( usesSessionlessDefaultProviderDevice(makeRequest(PUBLIC_COMMANDS.record, ['start'])), true, diff --git a/src/daemon/daemon-command-registry.ts b/src/daemon/daemon-command-registry.ts index e535f797a..1f6de93be 100644 --- a/src/daemon/daemon-command-registry.ts +++ b/src/daemon/daemon-command-registry.ts @@ -64,6 +64,12 @@ const DAEMON_COMMAND_DESCRIPTORS = [ lockPolicySelectorOverride: true, ...REQUEST_EXECUTION_EXEMPT, }), + descriptor(PUBLIC_COMMANDS.doctor, 'session', { + sessionKind: 'inventory', + lockPolicySelectorOverride: true, + allowSessionlessDefaultDevice: () => true, + ...REQUEST_EXECUTION_EXEMPT, + }), descriptor(PUBLIC_COMMANDS.apps, 'session', { sessionKind: 'inventory', lockPolicySelectorOverride: true, diff --git a/src/daemon/handlers/__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 77d2164a6..cfc334843 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -937,6 +937,56 @@ test('boot launches Android emulator with GUI when no running device matches', a } }); +test('boot launches stopped Android emulator selected from inventory', async () => { + const sessionStore = makeSessionStore(); + mockResolveTargetDevice.mockResolvedValue({ + platform: 'android', + id: 'Pixel_9_Pro_XL', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: false, + }); + const launchCalls: Array<{ avdName: string; serial?: string; headless?: boolean }> = []; + mockEnsureAndroidEmulatorBooted.mockImplementation(async ({ avdName, serial, headless }) => { + launchCalls.push({ avdName, serial, headless }); + return { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel_9_Pro_XL', + kind: 'emulator', + target: 'mobile', + booted: true, + }; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'boot', + positionals: [], + flags: { platform: 'android' }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(launchCalls).toEqual([{ avdName: 'Pixel_9_Pro_XL', serial: undefined, headless: false }]); + expect(mockEnsureDeviceReady).toHaveBeenCalledWith( + expect.objectContaining({ id: 'emulator-5554', booted: true }), + ); + if (response && response.ok) { + expect(response.data?.platform).toBe('android'); + expect(response.data?.id).toBe('emulator-5554'); + expect(response.data?.device).toBe('Pixel_9_Pro_XL'); + } +}); + test('boot --headless requires avd selector when device cannot be resolved', async () => { const sessionStore = makeSessionStore(); mockResolveTargetDevice.mockRejectedValue(new AppError('DEVICE_NOT_FOUND', 'No device found')); diff --git a/src/daemon/handlers/session-doctor-android.ts b/src/daemon/handlers/session-doctor-android.ts new file mode 100644 index 000000000..6a010ee11 --- /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 '../../utils/device.ts'; +import { normalizeError } from '../../utils/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..d3eb6a562 --- /dev/null +++ b/src/daemon/handlers/session-doctor-app.ts @@ -0,0 +1,42 @@ +import { resolveAndroidApp } from '../../platforms/android/app-lifecycle.ts'; +import { resolveIosApp } from '../../platforms/ios/apps.ts'; +import type { DeviceInfo } from '../../utils/device.ts'; +import { normalizeError } from '../../utils/errors.ts'; +import type { SessionState } from '../types.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; +import type { DoctorCheck } from './session-doctor-types.ts'; + +export async function appendAppChecks( + checks: DoctorCheck[], + params: { device: DeviceInfo; session: SessionState | undefined; targetApp?: string }, +): Promise { + const { device, targetApp, session } = params; + if (!targetApp) { + return; + } + + try { + const resolved = + device.platform === 'android' + ? (await resolveAndroidApp(device, targetApp)).value + : device.platform === 'ios' || device.platform === 'macos' + ? await resolveIosApp(device, targetApp) + : targetApp; + appendDoctorCheck(checks, { + id: 'target-app', + status: 'pass', + summary: `Target app is discoverable: ${resolved}`, + evidence: { requested: targetApp, resolved, sessionApp: session?.appBundleId }, + }); + } catch (error) { + const normalized = normalizeError(error); + appendDoctorCheck(checks, { + id: 'target-app', + status: 'fail', + summary: `Target app is not discoverable: ${targetApp}`, + hint: normalized.hint ?? 'Install the app or pass the exact package/bundle id.', + command: `agent-device apps --platform ${device.platform}`, + evidence: { code: normalized.code, message: normalized.message }, + }); + } +} diff --git a/src/daemon/handlers/session-doctor-device.ts b/src/daemon/handlers/session-doctor-device.ts new file mode 100644 index 000000000..077127215 --- /dev/null +++ b/src/daemon/handlers/session-doctor-device.ts @@ -0,0 +1,251 @@ +import { + buildDeviceInventoryRequestFromFlags, + listDeviceInventory, +} from '../../core/dispatch-resolve.ts'; +import type { DeviceInventoryRequest } from '../../core/platform-inventory.ts'; +import type { DeviceInfo, DeviceTarget, Platform, PlatformSelector } from '../../utils/device.ts'; +import { matchesDeviceSelector } from '../../utils/device.ts'; +import { normalizeError } from '../../utils/errors.ts'; +import type { DaemonRequest, SessionState } from '../types.ts'; +import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; + +export type DoctorDeviceInventory = { + devices: DeviceInfo[]; + platform?: PlatformSelector; + target?: DeviceTarget; +}; + +type DoctorInventoryFailure = { + platform: PlatformSelector; + message: string; + hint?: string; + code?: string; +}; + +type DoctorInventoryGroup = 'android' | 'apple' | 'linux' | 'web'; + +export async function appendDeviceInventoryCheck( + checks: DoctorCheck[], + req: DaemonRequest, + session: SessionState | undefined, +): Promise { + try { + const selector = deviceInventorySelector(req, session); + 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 + ? (inventory.failures.find((failure) => failure.hint)?.hint ?? + 'Start or create a simulator/emulator, connect a device, or adjust --platform/--target/--device selectors.') + : undefined, + command: devices.length === 0 ? deviceInventoryCommand(selector) : undefined, + evidence: deviceInventoryEvidence(devices, inventory.failures), + }); + return { 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 undefined; + } +} + +export function platformScopeChecks(device: DeviceInfo, options: DoctorOptions): DoctorCheck[] { + if ( + (options.kind === 'react-native' || options.kind === 'expo') && + device.platform !== 'ios' && + device.platform !== 'android' + ) { + return [ + { + id: 'platform-scope', + status: 'info', + summary: `${options.kind} checks are app-mobile focused; ${device.platform} doctor covers device/session readiness only.`, + }, + ]; + } + if (device.platform === 'android' && options.kind !== 'auto') { + return [ + { + id: 'android-routing', + status: 'info', + summary: + 'Android URL opens can use host localhost automatically; package launches may still need adb reverse.', + command: `adb -s ${device.id} reverse tcp:${options.metroPort} tcp:${options.metroPort}`, + }, + ]; + } + return []; +} + +function deviceInventorySelector(req: DaemonRequest, session: SessionState | undefined) { + const flags = req.flags ?? {}; + return buildDeviceInventoryRequestFromFlags({ + 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 ['android', 'apple', 'linux'] as const) { + try { + devices.push(...(await listDeviceInventory({ ...selector, platform }))); + } catch (error) { + failures.push(inventoryFailure(platform, error)); + } + } + return { devices, failures: devices.length === 0 ? failures : [] }; +} + +function inventoryFailure(platform: PlatformSelector, error: unknown): DoctorInventoryFailure { + const normalized = normalizeError(error); + return { + platform, + message: normalized.message, + hint: normalized.hint, + code: normalized.code, + }; +} + +function 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 deviceInventorySummaryBreakdown( + devices: DeviceInfo[], + selector: Pick, +): string | undefined { + if (selector.platform || selector.target) return undefined; + const groups = deviceInventoryGroups(devices); + return (['android', 'apple', 'linux', 'web'] as const) + .flatMap((group) => { + const entry = groups[group]; + return entry.available > 0 + ? [`${entry.label} ${entry.available} available, ${entry.booted} booted`] + : []; + }) + .join('; '); +} + +function deviceInventoryGroups( + devices: DeviceInfo[], +): Record { + const groups = { + android: { label: 'Android', available: 0, booted: 0 }, + apple: { label: 'Apple', available: 0, booted: 0 }, + linux: { label: 'Linux', available: 0, booted: 0 }, + web: { label: 'web', available: 0, booted: 0 }, + }; + for (const device of devices) { + const group = + device.platform === 'ios' || device.platform === 'macos' + ? groups.apple + : groups[device.platform]; + group.available += 1; + if (device.booted === true) group.booted += 1; + } + return groups; +} + +function platformLabel(platform: PlatformSelector): string { + if (platform === 'ios') return 'iOS'; + if (platform === 'macos') return 'macOS'; + 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..a2d1adef5 --- /dev/null +++ b/src/daemon/handlers/session-doctor-metro.ts @@ -0,0 +1,107 @@ +import type { DoctorCheck, DoctorKind } from './session-doctor-types.ts'; +import { runCmd } from '../../utils/exec.ts'; + +const METRO_PROBE_TIMEOUT_MS = 1500; +const METRO_PROCESS_LOOKUP_TIMEOUT_MS = 1500; + +export type MetroProcessInfo = { + pid: number; + cwd?: string; +}; + +type MetroProbeOptions = { + resolveProcessInfo?: (host: string, port: number) => Promise; +}; + +export async function probeMetro( + host: string, + port: number, + kind: DoctorKind, + options: MetroProbeOptions = {}, +): Promise { + const url = `http://${host}:${port}/status`; + try { + const response = await fetch(url, { signal: AbortSignal.timeout(METRO_PROBE_TIMEOUT_MS) }); + const text = await response.text(); + const running = response.ok && text.toLowerCase().includes('packager-status:running'); + let processInfo: MetroProcessInfo | undefined; + if (running) { + try { + processInfo = await (options.resolveProcessInfo ?? resolveMetroProcessInfo)(host, port); + } catch { + processInfo = undefined; + } + } + return { + id: 'metro', + status: running ? 'pass' : 'warn', + summary: running + ? metroRunningSummary(url, processInfo) + : `Metro 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, pass the correct --metro-host/--metro-port, or use a remote Metro profile.', + command: `curl -fsS ${url}`, + evidence: { url, error: error instanceof Error ? error.message : String(error), kind }, + }; + } +} + +function 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..35b068f53 --- /dev/null +++ b/src/daemon/handlers/session-doctor-options.ts @@ -0,0 +1,137 @@ +import { detectProjectRuntimeKind } from '../../utils/project-runtime.ts'; +import type { SessionStore } from '../session-store.ts'; +import type { DaemonRequest, SessionState } from '../types.ts'; +import type { DoctorCheck, DoctorKind, DoctorOptions } from './session-doctor-types.ts'; + +const DEFAULT_METRO_HOST = '127.0.0.1'; +const DEFAULT_METRO_PORT = 8081; +const REMOTE_CONNECTION_FLAG_KEYS = ['daemonBaseUrl', 'tenant', 'runId', 'leaseId'] as const; + +export function readDoctorOptions( + req: DaemonRequest, + session: SessionState | undefined, +): DoctorOptions { + const kind = detectProjectRuntimeKind(req.meta?.cwd); + const targetApp = session?.appBundleId; + const metroHost = readNonEmptyString(req.runtime?.metroHost) ?? DEFAULT_METRO_HOST; + const metroPort = readPositivePort(req.runtime?.metroPort) ?? DEFAULT_METRO_PORT; + return { + targetApp, + metroHost, + metroPort, + kind, + 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 scope is configured.', + hint: 'Use connect --remote-config , --remote-config , or direct --daemon-base-url/--daemon-auth-token flags.', + }, + ]; + } + return [ + { + id: 'remote-connection', + status: options.required ? 'pass' : 'info', + summary: 'Remote daemon/session 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.flatMap((key) => + typeof req.flags?.[key] === 'string' ? [[key, '']] : [], + ), + ); + const evidence = { + ...configured, + ...(req.flags?.sessionIsolation === 'tenant' ? { sessionIsolation: 'tenant' } : {}), + }; + return Object.keys(evidence).length > 0 ? evidence : undefined; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function readPositivePort(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +} diff --git a/src/daemon/handlers/session-doctor-output.ts b/src/daemon/handlers/session-doctor-output.ts new file mode 100644 index 000000000..ea1ca17d7 --- /dev/null +++ b/src/daemon/handlers/session-doctor-output.ts @@ -0,0 +1,38 @@ +import { emitRequestProgress } from '../request-progress.ts'; +import type { DoctorCheck, DoctorStatus } from './session-doctor-types.ts'; + +export function summarizeDoctorStatus(checks: DoctorCheck[]): 'pass' | 'warn' | 'fail' { + if (checks.some((check) => check.status === 'fail')) return 'fail'; + if (checks.some((check) => check.status === 'warn')) return 'warn'; + return 'pass'; +} + +export function doctorSummary(status: 'pass' | 'warn' | 'fail'): string { + if (status === 'fail') return 'Blockers found before the run.'; + if (status === 'warn') return 'No hard blockers found, but warnings need attention.'; + return 'No blockers found.'; +} + +export function sortChecks(checks: DoctorCheck[]): DoctorCheck[] { + const order: Record = { fail: 0, warn: 1, pass: 2, info: 3 }; + return [...checks].sort((a, b) => order[a.status] - order[b.status]); +} + +export function appendDoctorChecks(checks: DoctorCheck[], ...items: DoctorCheck[]): void { + for (const check of items) { + appendDoctorCheck(checks, check); + } +} + +export function appendDoctorCheck(checks: DoctorCheck[], check: DoctorCheck): void { + checks.push(check); + emitRequestProgress({ + type: 'doctor-check', + id: check.id, + status: check.status, + summary: check.summary, + index: checks.length, + hint: check.hint, + command: check.command, + }); +} diff --git a/src/daemon/handlers/session-doctor-react-native.ts b/src/daemon/handlers/session-doctor-react-native.ts new file mode 100644 index 000000000..4d51991e2 --- /dev/null +++ b/src/daemon/handlers/session-doctor-react-native.ts @@ -0,0 +1,51 @@ +import { analyzeReactNativeOverlay } from '../../core/react-native-overlay.ts'; +import type { SessionState } from '../types.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; +import type { DoctorCheck, DoctorOptions } from './session-doctor-types.ts'; + +export function appendReactNativeOverlayCheck( + checks: DoctorCheck[], + session: SessionState | undefined, + options: DoctorOptions, +): void { + const check = reactNativeOverlayCheck(session, options); + if (check) appendDoctorCheck(checks, check); +} + +function reactNativeOverlayCheck( + session: SessionState | undefined, + options: DoctorOptions, +): DoctorCheck | undefined { + if (shouldSkipReactNativeOverlayCheck(session, options)) return undefined; + if (!session?.snapshot) return missingSnapshotOverlayCheck(); + + const overlay = analyzeReactNativeOverlay(session.snapshot.nodes); + return { + id: 'rn-overlay', + status: overlay.detected ? 'warn' : 'pass', + summary: overlay.detected + ? `React Native ${overlay.redBox ? 'RedBox' : 'LogBox'} overlay appears in the current snapshot.` + : 'No React Native overlay detected in the current snapshot.', + command: overlay.detected ? 'agent-device react-native dismiss-overlay' : undefined, + evidence: { + redBox: overlay.redBox, + dismissTargets: overlay.dismissNodes.length + overlay.collapsedNodes.length, + }, + }; +} + +function shouldSkipReactNativeOverlayCheck( + session: SessionState | undefined, + options: DoctorOptions, +): boolean { + return options.kind === 'auto' && !session?.snapshot; +} + +function missingSnapshotOverlayCheck(): DoctorCheck { + return { + id: 'rn-overlay', + status: 'info', + summary: 'No current session snapshot; React Native overlay check skipped.', + command: 'agent-device snapshot -i', + }; +} diff --git a/src/daemon/handlers/session-doctor-types.ts b/src/daemon/handlers/session-doctor-types.ts new file mode 100644 index 000000000..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..a2917722c --- /dev/null +++ b/src/daemon/handlers/session-doctor.ts @@ -0,0 +1,103 @@ +import path from 'node:path'; +import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; +import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; +import { readVersion } from '../../utils/version.ts'; +import type { DaemonRequest, DaemonResponse } from '../types.ts'; +import { SessionStore } from '../session-store.ts'; +import { appendAndroidChecks } from './session-doctor-android.ts'; +import { appendAppChecks } from './session-doctor-app.ts'; +import { appendDeviceInventoryCheck, platformScopeChecks } from './session-doctor-device.ts'; +import { probeMetro } from './session-doctor-metro.ts'; +import { + readDoctorOptions, + remoteConnectionChecks, + sessionChecks, +} from './session-doctor-options.ts'; +import { + appendDoctorCheck, + appendDoctorChecks, + doctorSummary, + sortChecks, + summarizeDoctorStatus, +} from './session-doctor-output.ts'; +import { appendReactNativeOverlayCheck } from './session-doctor-react-native.ts'; +import type { DoctorCheck } from './session-doctor-types.ts'; + +export async function handleDoctorCommand(params: { + req: DaemonRequest; + 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) { + const status = summarizeDoctorStatus(checks); + return { + ok: true, + data: { + status, + summary: doctorSummary(status), + kind: options.kind, + targetApp: options.targetApp, + checks: sortChecks(checks), + }, + }; + } + + const inventory = await appendDeviceInventoryCheck(checks, req, session); + const device = session?.device; + if (device) { + appendDoctorChecks(checks, ...platformScopeChecks(device, options)); + await appendAppChecks(checks, { device, session, targetApp: options.targetApp }); + await appendAndroidChecks(checks, { + device, + metroPort: options.metroPort, + shouldProbeMetro: options.shouldProbeMetro, + androidAdbExecutor, + }); + appendReactNativeOverlayCheck(checks, session, options); + } + if (options.shouldProbeMetro) { + appendDoctorCheck(checks, await probeMetro(options.metroHost, options.metroPort, options.kind)); + } + + const status = summarizeDoctorStatus(checks); + return { + ok: true, + data: { + status, + summary: doctorSummary(status), + kind: options.kind, + platform: device?.platform ?? inventory?.platform, + target: device?.target ?? inventory?.target, + targetApp: options.targetApp, + metro: options.shouldProbeMetro + ? { host: options.metroHost, port: options.metroPort } + : undefined, + checks: sortChecks(checks), + }, + }; +} + +function resolveDoctorStateDir(sessionStore: SessionStore, sessionName: string): string { + const sessionsDir = path.dirname(sessionStore.resolveSessionDir(sessionName)); + return path.basename(sessionsDir) === 'sessions' ? path.dirname(sessionsDir) : sessionsDir; +} diff --git a/src/daemon/handlers/session-state.ts b/src/daemon/handlers/session-state.ts index 48aa0bb09..ba1807aa3 100644 --- a/src/daemon/handlers/session-state.ts +++ b/src/daemon/handlers/session-state.ts @@ -233,6 +233,17 @@ export async function handleSessionStateCommands(params: { }); } await ensureDeviceReady(device); + } else if ( + device.platform === 'android' && + device.kind === 'emulator' && + device.booted !== true + ) { + device = await ensureAndroidEmulatorBoot({ + avdName: device.name, + serial: flags.serial, + headless: false, + }); + await ensureDeviceReady(device); } else { const shouldEnsureReady = device.platform !== 'android' || device.booted !== true; if (shouldEnsureReady) { diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 9d30f903e..53c258ad5 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -35,6 +35,7 @@ import { handleSessionInventoryCommands } from './session-inventory.ts'; import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; +import { handleDoctorCommand } from './session-doctor.ts'; import { getSessionCommandKind } from '../daemon-command-registry.ts'; const PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS = 45_000; @@ -261,6 +262,15 @@ export async function handleSessionCommands(params: { androidAdbExecutor, } = params; + if (req.command === PUBLIC_COMMANDS.doctor) { + return await handleDoctorCommand({ + req, + sessionName, + sessionStore, + androidAdbExecutor, + }); + } + if (getSessionCommandKind(req.command) === 'inventory') { return await handleSessionInventoryCommands({ req, diff --git a/src/daemon/request-progress-protocol.ts b/src/daemon/request-progress-protocol.ts index cf580da78..592bddacd 100644 --- a/src/daemon/request-progress-protocol.ts +++ b/src/daemon/request-progress-protocol.ts @@ -12,7 +12,7 @@ export type DaemonResponseEnvelope = { }; export function shouldStreamRequestProgress(req: Pick): boolean { - return req.meta?.requestProgress === 'replay-test'; + return req.meta?.requestProgress === 'replay-test' || req.meta?.requestProgress === 'doctor'; } export function isDaemonProgressEnvelope(value: unknown): value is DaemonProgressEnvelope { diff --git a/src/daemon/request-progress.ts b/src/daemon/request-progress.ts index 90c950eec..5d0729daf 100644 --- a/src/daemon/request-progress.ts +++ b/src/daemon/request-progress.ts @@ -32,7 +32,20 @@ export type ReplayTestProgressEvent = { deviceId?: string; }; -export type RequestProgressEvent = ReplayTestSuiteProgressEvent | ReplayTestProgressEvent; +export type DoctorCheckProgressEvent = { + type: 'doctor-check'; + id: string; + status: 'pass' | 'warn' | 'fail' | 'info'; + summary: string; + index: number; + hint?: string; + command?: string; +}; + +export type RequestProgressEvent = + | ReplayTestSuiteProgressEvent + | ReplayTestProgressEvent + | DoctorCheckProgressEvent; export type RequestProgressSink = (event: RequestProgressEvent) => void; export type ReplayTestActionProgressContext = Omit< ReplayTestProgressEvent, diff --git a/src/platforms/android/__tests__/devices.test.ts b/src/platforms/android/__tests__/devices.test.ts index 1c2d58825..f2618f55e 100644 --- a/src/platforms/android/__tests__/devices.test.ts +++ b/src/platforms/android/__tests__/devices.test.ts @@ -311,6 +311,18 @@ test('listAndroidDevices falls back to model when emulator avd name is unavailab ); }); +test('listAndroidDevices includes stopped AVDs as non-booted emulators', async () => { + await withMockedAndroidTools(async () => { + const devices = await listAndroidDevices(); + + assert.equal(devices.length, 1); + assert.equal(devices[0]?.id, 'Pixel_9_Pro_XL'); + assert.equal(devices[0]?.name, 'Pixel_9_Pro_XL'); + assert.equal(devices[0]?.kind, 'emulator'); + assert.equal(devices[0]?.booted, false); + }); +}); + test('ensureAndroidEmulatorBooted launches emulator in headless mode when requested', async () => { await withMockedAndroidTools(async ({ emulatorLogPath, emulatorBootedPath }) => { const device = await ensureAndroidEmulatorBooted({ diff --git a/src/platforms/android/devices.ts b/src/platforms/android/devices.ts index 22d396460..5352010d5 100644 --- a/src/platforms/android/devices.ts +++ b/src/platforms/android/devices.ts @@ -256,7 +256,7 @@ export async function listAndroidDevices( }, ); - return devices; + return [...devices, ...(await listStoppedAndroidAvdDevices(devices))]; } type AndroidDeviceEntry = { @@ -321,6 +321,29 @@ async function listAndroidAvdNames(): Promise { return parseAndroidAvdList(result.stdout); } +async function listStoppedAndroidAvdDevices(runningDevices: DeviceInfo[]): Promise { + const avdNames = await listAndroidAvdNames().catch(() => []); + const runningEmulatorNames = new Set( + runningDevices + .filter((device) => device.kind === 'emulator') + .map((device) => normalizeAndroidName(device.name)), + ); + return avdNames + .filter((avdName) => !runningEmulatorNames.has(normalizeAndroidName(avdName))) + .map((avdName) => ({ + platform: 'android', + id: avdName, + name: avdName, + kind: 'emulator', + target: inferAndroidAvdTarget(avdName), + booted: false, + })); +} + +function inferAndroidAvdTarget(avdName: string): 'mobile' | 'tv' { + return /\b(tv|television)\b/i.test(normalizeAndroidName(avdName)) ? 'tv' : 'mobile'; +} + function findAndroidEmulatorByAvdName( devices: DeviceInfo[], avdName: string, @@ -434,7 +457,8 @@ export async function ensureAndroidEmulatorBooted(params: { resolvedAvdName, params.serial, ); - if (!existing) { + const runningExisting = existing && isEmulatorSerial(existing.id) ? existing : undefined; + if (!runningExisting) { const launchArgs = ['-avd', resolvedAvdName]; if (params.headless) { launchArgs.push('-no-window', '-no-audio'); @@ -443,7 +467,7 @@ export async function ensureAndroidEmulatorBooted(params: { } const discovered = - existing ?? + runningExisting ?? (await waitForAndroidEmulatorByAvdName({ avdName: resolvedAvdName, serial: params.serial, diff --git a/src/platforms/ios/__tests__/devices.test.ts b/src/platforms/ios/__tests__/devices.test.ts index dabe3ad21..aa76bfebc 100644 --- a/src/platforms/ios/__tests__/devices.test.ts +++ b/src/platforms/ios/__tests__/devices.test.ts @@ -83,6 +83,82 @@ test('apple product type helpers classify iOS and tvOS product families', () => assert.equal(isAppleTvProductType('iPhone16,2'), false); }); +test('listAppleDevices orders simulators by iPhone, iPad, tvOS, then physical devices', async () => { + mockRunCommand = async (_cmd, args) => { + if (args.join(' ') === 'simctl list devices -j') { + return { + stdout: JSON.stringify({ + devices: { + 'com.apple.CoreSimulator.SimRuntime.tvOS-18-0': [ + { + name: 'Apple TV 4K (3rd generation)', + udid: 'tvos-sim', + state: 'Shutdown', + isAvailable: true, + }, + ], + 'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [ + { + name: 'iPad Pro 13-inch', + udid: 'ipad-sim', + state: 'Shutdown', + isAvailable: true, + }, + { + name: 'iPhone 16', + udid: 'iphone-sim', + state: 'Shutdown', + isAvailable: true, + }, + ], + }, + }), + stderr: '', + exitCode: 0, + }; + } + + if (args[0] === 'devicectl' && args[1] === 'list' && args[2] === 'devices') { + const jsonPath = String(args[4]); + await fs.writeFile( + jsonPath, + JSON.stringify({ + result: { + devices: [ + { + name: 'My iPhone', + hardwareProperties: { + platform: 'iOS', + udid: 'physical-iphone', + productType: 'iPhone16,2', + }, + }, + ], + }, + }), + 'utf8', + ); + return { stdout: '', stderr: '', exitCode: 0 }; + } + + if (args.join(' ') === 'xctrace list devices') { + return { stdout: '== Devices ==', stderr: '', exitCode: 0 }; + } + + throw new Error(`unexpected xcrun args: ${args.join(' ')}`); + }; + + const devices = await withMockedPlatform( + 'darwin', + async () => await withMockedAppleTools(async () => await listAppleDevices()), + ); + + assert.deepEqual( + devices.slice(0, 4).map((device) => device.id), + ['iphone-sim', 'ipad-sim', 'tvos-sim', 'physical-iphone'], + ); +}); + test('parseXctracePhysicalAppleDevices parses only physical devices from the Devices section', () => { const parsed = parseXctracePhysicalAppleDevices( [ diff --git a/src/platforms/ios/devices.ts b/src/platforms/ios/devices.ts index e890abba8..a38632b0a 100644 --- a/src/platforms/ios/devices.ts +++ b/src/platforms/ios/devices.ts @@ -2,7 +2,11 @@ import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { AppError } from '../../utils/errors.ts'; -import type { DeviceInfo, DeviceTarget } from '../../utils/device.ts'; +import { + sortAppleDevicesForSelection, + type DeviceInfo, + type DeviceTarget, +} from '../../utils/device.ts'; import { resolveIosSimulatorDeviceSetPath } from '../../utils/device-isolation.ts'; import { buildHostMacDevice } from '../macos/devices.ts'; import { buildSimctlArgs } from './simctl.ts'; @@ -157,23 +161,10 @@ export async function findBootableIosSimulator( return null; } - const simulators = parseSimctlAppleDevices(payload, simulatorSetPath); - let bestBooted: DeviceInfo | null = null; - let bestMobile: DeviceInfo | null = null; - let bestAny: DeviceInfo | null = null; - - for (const simulator of simulators) { - if (targetFilter && simulator.target !== targetFilter) continue; - if (simulator.booted) { - bestBooted = bestBooted ?? simulator; - } - if (simulator.target === 'mobile') { - bestMobile = bestMobile ?? simulator; - } - bestAny = bestAny ?? simulator; - } - - return bestBooted ?? bestMobile ?? bestAny; + const simulators = sortAppleDevicesForSelection( + parseSimctlAppleDevices(payload, simulatorSetPath), + ); + return simulators.find((simulator) => !targetFilter || simulator.target === targetFilter) ?? null; } function parseSimctlAppleDevices( @@ -348,7 +339,7 @@ export async function listAppleDevices( // Do not enumerate host-global physical devices, but keep the local Mac available // because desktop targeting is independent of simulator sets. if (simulatorSetPath) { - return devices; + return sortAppleDevicesForSelection(devices); } const [devicectlDevices, xctraceDevices] = await Promise.all([ @@ -357,5 +348,5 @@ export async function listAppleDevices( ]); devices = mergeAppleDevices(devices, devicectlDevices); - return mergeAppleDevices(devices, xctraceDevices); + return sortAppleDevicesForSelection(mergeAppleDevices(devices, xctraceDevices)); } diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index e3a90cac7..5c4e481f9 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -92,6 +92,26 @@ test('parseArgs recognizes command-specific flag combinations', async () => { assert.equal(parsed.flags.platform, 'ios'); }, }, + { + label: 'doctor android', + argv: ['doctor', '--platform', 'android'], + strictFlags: true, + assertParsed: (parsed) => { + assert.equal(parsed.command, 'doctor'); + assert.equal(parsed.flags.platform, 'android'); + }, + }, + { + 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'], @@ -1663,6 +1683,9 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help debugging/); assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); + assert.match(help, /agent-device doctor --platform android/); + assert.match(help, /agent-device doctor --platform ios/); + assert.match(help, /agent-device doctor --remote --remote-config \.\/remote\.json/); assert.match(help, /same host context that owns Metro/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); diff --git a/src/utils/__tests__/device.test.ts b/src/utils/__tests__/device.test.ts index 0426e52c8..84ac4e817 100644 --- a/src/utils/__tests__/device.test.ts +++ b/src/utils/__tests__/device.test.ts @@ -137,6 +137,52 @@ test('resolveDevice prefers booted simulator over physical device', async () => assert.equal(result.id, 'sim-1'); }); +test('resolveDevice keeps Apple simulator family priority ahead of boot state', async () => { + const tvSimulator: DeviceInfo = { + platform: 'ios', + id: 'tv-sim', + name: 'Apple TV 4K', + kind: 'simulator', + target: 'tv', + booted: true, + }; + const iphoneSimulator: DeviceInfo = { + platform: 'ios', + id: 'iphone-sim', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: false, + }; + + const result = await resolveDevice([tvSimulator, iphoneSimulator], { platform: 'ios' }); + + assert.equal(result.id, 'iphone-sim'); +}); + +test('resolveDevice prefers booted Apple simulator within the same family', async () => { + const shutdownIphone: DeviceInfo = { + platform: 'ios', + id: 'iphone-shutdown', + name: 'iPhone 16', + kind: 'simulator', + target: 'mobile', + booted: false, + }; + const bootedIphone: DeviceInfo = { + platform: 'ios', + id: 'iphone-booted', + name: 'iPhone 17', + kind: 'simulator', + target: 'mobile', + booted: true, + }; + + const result = await resolveDevice([shutdownIphone, bootedIphone], { platform: 'ios' }); + + assert.equal(result.id, 'iphone-booted'); +}); + test('resolveDevice returns physical device when explicitly selected by deviceName', async () => { const physical: DeviceInfo = { platform: 'ios', diff --git a/src/utils/cli-flags.ts b/src/utils/cli-flags.ts index 6a1f99703..2d2f3cade 100644 --- a/src/utils/cli-flags.ts +++ b/src/utils/cli-flags.ts @@ -58,6 +58,7 @@ export type CliFlags = RemoteConfigMetroOptions & iosXctestEnvDir?: string; deviceHub?: boolean; androidDeviceAllowlist?: string; + remote?: boolean; session?: string; metroHost?: string; metroPort?: number; @@ -576,6 +577,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--android-device-allowlist ', usageDescription: 'Comma/space separated Android serial allowlist for discovery/selection', }, + { + key: 'remote', + names: ['--remote'], + type: 'boolean', + usageLabel: '--remote', + usageDescription: 'Doctor: check remote connection setup instead of local device inventory', + }, { key: 'activity', names: ['--activity'], diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 0f70280b0..b79a72030 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -531,6 +531,10 @@ 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 --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/utils/device.ts b/src/utils/device.ts index 223121134..16dbc672f 100644 --- a/src/utils/device.ts +++ b/src/utils/device.ts @@ -20,7 +20,7 @@ export type DeviceInfo = { simulatorSetPath?: string; }; -type DeviceSelector = { +export type DeviceSelector = { platform?: PlatformSelector; target?: DeviceTarget; deviceName?: string; @@ -76,6 +76,15 @@ export function resolveAppleSimulatorSetPathForSelector(params: { return simulatorSetPath; } +export function sortAppleDevicesForSelection( + devices: TDevice[], +): TDevice[] { + return devices + .map((device, index) => ({ device, index })) + .sort((left, right) => compareAppleDevicesForSelection(left, right)) + .map(({ device }) => device); +} + function supportsAppleSimulatorSelection(platform: PlatformSelector | undefined): boolean { return !platform || platform === 'apple' || platform === 'ios'; } @@ -85,72 +94,163 @@ export async function resolveDevice( selector: DeviceSelector, context: DeviceSelectionContext = {}, ): Promise { - let candidates = devices; - const normalize = (value: string): string => - value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); + const candidates = sortDeviceCandidatesForSelection(filterDeviceCandidates(devices, selector)); + const explicitSelection = resolveExplicitDeviceSelection(candidates, selector); + if (explicitSelection) return explicitSelection; - if (selector.platform) { - candidates = candidates.filter((d) => matchesPlatformSelector(d.platform, selector.platform)); - } - if (selector.target) { - candidates = candidates.filter((d) => (d.target ?? 'mobile') === selector.target); - } + const onlyCandidate = candidates[0]; + if (onlyCandidate !== undefined && candidates.length === 1) return onlyCandidate; - if (selector.udid) { - const match = candidates.find((d) => d.id === selector.udid && isApplePlatform(d.platform)); - if (!match) { - throw new AppError('DEVICE_NOT_FOUND', `No Apple device with UDID ${selector.udid}`); - } - return match; + if (candidates.length === 0) { + throwNoDevicesFound(selector, context); } - if (selector.serial) { - const match = candidates.find((d) => d.id === selector.serial && d.platform === 'android'); - if (!match) - throw new AppError('DEVICE_NOT_FOUND', `No Android device with serial ${selector.serial}`); - return match; - } + const selected = selectDefaultDevice(candidates); + if (selected === undefined) throwNoDevicesFound(selector, context); + return selected; +} - if (selector.deviceName) { - const target = normalize(selector.deviceName); - const match = candidates.find((d) => normalize(d.name) === target); - if (!match) { - throw new AppError('DEVICE_NOT_FOUND', `No device named ${selector.deviceName}`); - } - return match; - } +function filterDeviceCandidates(devices: DeviceInfo[], selector: DeviceSelector): DeviceInfo[] { + return devices.filter((device) => matchesDeviceSelector(device, selector)); +} - const onlyCandidate = candidates[0]; - if (onlyCandidate !== undefined && candidates.length === 1) return onlyCandidate; +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)) + ); +} - if (candidates.length === 0) { - const simulatorSetPath = context.simulatorSetPath; - if (simulatorSetPath && supportsAppleSimulatorSelection(selector.platform)) { - throw new AppError('DEVICE_NOT_FOUND', 'No devices found in the scoped simulator set', { - simulatorSetPath, - hint: `The simulator set at "${simulatorSetPath}" appears to be empty. Create a simulator first:\n xcrun simctl --set "${simulatorSetPath}" create "iPhone 16" com.apple.CoreSimulator.SimDeviceType.iPhone-16 com.apple.CoreSimulator.SimRuntime.iOS-18-0`, - selector, - }); - } - throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector }); +function matchesExplicitDeviceSelector(device: DeviceInfo, selector: DeviceSelector): boolean { + if (selector.udid && !(device.id === selector.udid && isApplePlatform(device.platform))) { + return false; } - - // 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; + if (selector.serial && !(device.id === selector.serial && device.platform === 'android')) { + return false; } + if ( + selector.deviceName && + normalizeDeviceName(device.name) !== normalizeDeviceName(selector.deviceName) + ) { + return false; + } + return true; +} + +function sortDeviceCandidatesForSelection(candidates: DeviceInfo[]): DeviceInfo[] { + return isAppleDeviceCandidateSet(candidates) + ? sortAppleDevicesForSelection(candidates) + : candidates; +} + +function resolveExplicitDeviceSelection( + candidates: DeviceInfo[], + selector: DeviceSelector, +): DeviceInfo | undefined { + if (selector.udid) return findAppleDeviceById(candidates, selector.udid); + if (selector.serial) return findAndroidDeviceById(candidates, selector.serial); + if (selector.deviceName) return findDeviceByName(candidates, selector.deviceName); + return undefined; +} + +function findAppleDeviceById(candidates: DeviceInfo[], udid: string): DeviceInfo { + const match = candidates.find((device) => device.id === udid && isApplePlatform(device.platform)); + if (!match) throw new AppError('DEVICE_NOT_FOUND', `No Apple device with UDID ${udid}`); + return match; +} - const booted = candidates.filter((d) => d.booted); - const onlyBooted = booted[0]; - if (onlyBooted !== undefined && booted.length === 1) return onlyBooted; +function findAndroidDeviceById(candidates: DeviceInfo[], serial: string): DeviceInfo { + const match = candidates.find((device) => device.id === serial && device.platform === 'android'); + if (!match) throw new AppError('DEVICE_NOT_FOUND', `No Android device with serial ${serial}`); + return match; +} - // 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 findDeviceByName(candidates: DeviceInfo[], deviceName: string): DeviceInfo { + const normalizedName = normalizeDeviceName(deviceName); + const match = candidates.find((device) => normalizeDeviceName(device.name) === normalizedName); + if (!match) throw new AppError('DEVICE_NOT_FOUND', `No device named ${deviceName}`); + return match; +} + +function selectDefaultDevice(candidates: DeviceInfo[]): DeviceInfo | undefined { + const selectable = preferVirtualDevices(candidates); + const singleBootedDevice = findSingleBootedDevice(selectable); + if (singleBootedDevice && !isAppleDeviceCandidateSet(selectable)) return singleBootedDevice; + return isAppleDeviceCandidateSet(selectable) + ? selectable[0] + : (selectable.find((device) => device.booted) ?? selectable[0]); +} + +function preferVirtualDevices(candidates: DeviceInfo[]): DeviceInfo[] { + // Prefer virtual devices unless a physical device was explicitly selected. + const virtual = candidates.filter((device) => device.kind !== 'device'); + return virtual.length > 0 ? virtual : candidates; +} + +function findSingleBootedDevice(candidates: DeviceInfo[]): DeviceInfo | undefined { + const booted = candidates.filter((device) => device.booted); + return booted.length === 1 ? booted[0] : undefined; +} + +function throwNoDevicesFound(selector: DeviceSelector, context: DeviceSelectionContext): never { + const simulatorSetPath = context.simulatorSetPath; + if (simulatorSetPath && supportsAppleSimulatorSelection(selector.platform)) { + throw new AppError('DEVICE_NOT_FOUND', 'No devices found in the scoped simulator set', { + simulatorSetPath, + hint: `The simulator set at "${simulatorSetPath}" appears to be empty. Create a simulator first:\n xcrun simctl --set "${simulatorSetPath}" create "iPhone 16" com.apple.CoreSimulator.SimDeviceType.iPhone-16 com.apple.CoreSimulator.SimRuntime.iOS-18-0`, + selector, + }); } - return selected; + throw new AppError('DEVICE_NOT_FOUND', 'No devices found', { selector }); +} + +function normalizeDeviceName(value: string): string { + return value.toLowerCase().replace(/_/g, ' ').replace(/\s+/g, ' ').trim(); +} + +function compareAppleDevicesForSelection( + 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/utils/project-runtime.ts b/src/utils/project-runtime.ts new file mode 100644 index 000000000..76da5dc5a --- /dev/null +++ b/src/utils/project-runtime.ts @@ -0,0 +1,32 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +export type ProjectRuntimeKind = 'auto' | 'react-native' | 'expo'; + +type PackageJsonShape = { + dependencies?: Record; + devDependencies?: Record; +}; + +export function detectProjectRuntimeKind(cwd: string | undefined): ProjectRuntimeKind { + const packageJson = readPackageJson(cwd); + if (!packageJson) return 'auto'; + + const dependencies = { + ...(packageJson.dependencies ?? {}), + ...(packageJson.devDependencies ?? {}), + }; + if (typeof dependencies.expo === 'string') return 'expo'; + if (typeof dependencies['react-native'] === 'string') return 'react-native'; + return 'auto'; +} + +function readPackageJson(cwd: string | undefined): PackageJsonShape | undefined { + if (!cwd) return undefined; + const packageJsonPath = path.join(cwd, 'package.json'); + try { + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) as PackageJsonShape; + } catch { + return undefined; + } +} diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts new file mode 100644 index 000000000..85c8e45cd --- /dev/null +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -0,0 +1,207 @@ +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 --remote skips local device inventory', async () => { + let inventoryCalls = 0; + + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => { + inventoryCalls += 1; + return [PROVIDER_SCENARIO_ANDROID]; + }, + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', [], { + remote: true, + daemonBaseUrl: 'https://example.invalid/agent-device', + daemonAuthToken: 'secret', + }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'pass'); + assertDoctorCheck(data, 'remote-connection', 'pass'); + assertNoDoctorCheck(data, 'device'); + assert.equal(inventoryCalls, 0); + }, + ); +}); + +test('Provider-backed integration doctor --remote fails without remote scope', async () => { + await withProviderScenarioResource( + async () => + await createProviderScenarioHarness({ + deviceInventoryProvider: async () => [PROVIDER_SCENARIO_ANDROID], + }), + async (daemon) => { + const response = await daemon.callCommand('doctor', [], { remote: true }); + assertRpcOk(response); + const data = response.json.result.data; + assert.equal(data.status, 'fail'); + assertDoctorCheck(data, 'remote-connection', 'fail'); + assertNoDoctorCheck(data, 'device'); + }, + ); +}); + +function writePackageJson(dir: string, value: Record): void { + fs.writeFileSync(`${dir}/package.json`, `${JSON.stringify(value)}\n`); +} + +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 (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 b9e9f18e1..658742068 100644 --- a/test/integration/provider-scenarios/harness.ts +++ b/test/integration/provider-scenarios/harness.ts @@ -21,7 +21,7 @@ export type ProviderScenarioHarness = { command: string, positionals?: string[], flags?: DaemonRequest['flags'], - options?: { meta?: DaemonRequest['meta'] }, + options?: { meta?: DaemonRequest['meta']; runtime?: DaemonRequest['runtime'] }, ) => Promise; client: () => AgentDeviceClient; session: (name?: string) => SessionState | undefined; @@ -62,7 +62,7 @@ export async function createProviderScenarioHarness( return { callCommand: async (command, positionals = [], flags = {}, options = {}) => responseToRpcResult( - await handleRequest(commandRequest(command, positionals, flags, options.meta)), + await handleRequest(commandRequest(command, positionals, flags, options)), `direct-${command}-${Date.now()}`, ), client: () => createAgentDeviceClient({}, { transport }), @@ -126,7 +126,7 @@ function commandRequest( command: string, positionals: string[] = [], flags: DaemonRequest['flags'] = {}, - meta?: DaemonRequest['meta'], + options: { meta?: DaemonRequest['meta']; runtime?: DaemonRequest['runtime'] } = {}, ): DaemonRequest { return { token: PROVIDER_SCENARIO_TOKEN, @@ -134,7 +134,8 @@ function commandRequest( command, positionals, flags, - meta, + runtime: options.runtime, + meta: options.meta, }; }