Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ac8f43a
feat: add doctor command
thymikee Jun 25, 2026
d33aa9e
fix: reduce doctor command complexity
thymikee Jun 25, 2026
6b40168
fix: classify doctor integration flags
thymikee Jun 25, 2026
301f7e7
fix: simplify doctor setup
thymikee Jun 25, 2026
74c93b4
refactor: split doctor checks
thymikee Jun 26, 2026
9767c13
fix: simplify doctor check set
thymikee Jun 26, 2026
e7cd5eb
fix: include stopped android avds in devices
thymikee Jun 26, 2026
b062b6d
fix: report doctor device inventory
thymikee Jun 26, 2026
3610e6c
refactor: reuse device inventory selectors
thymikee Jun 26, 2026
602ed25
fix: summarize doctor inventory by platform
thymikee Jun 26, 2026
ad511e8
fix: show metro cwd in doctor
thymikee Jun 26, 2026
b9116e7
refactor: simplify metro doctor lookup
thymikee Jun 26, 2026
023a068
fix: update doctor imports after apple consolidation
thymikee Jun 30, 2026
49d0aeb
feat: make doctor Metro probe controllable and surface hidden toolcha…
thymikee Jul 1, 2026
7b7a61d
fix: align doctor CI expectations
thymikee Jul 1, 2026
9d3c7b9
feat: extend doctor preflight checks
thymikee Jul 1, 2026
27b4c69
fix: keep doctor checks within ci gates
thymikee Jul 1, 2026
d3202e7
fix: simplify doctor metro surface
thymikee Jul 1, 2026
ffb9c7b
refactor: trim doctor bundle impact
thymikee Jul 1, 2026
648338c
fix: restore useful doctor diagnostics
thymikee Jul 1, 2026
33bba6c
refactor: reuse doctor output helpers
thymikee Jul 1, 2026
d97c260
refactor: share device inventory grouping
thymikee Jul 1, 2026
143c154
refactor: keep doctor focused on preflight checks
thymikee Jul 1, 2026
5f7d29d
refactor: simplify doctor toolchain probes
thymikee Jul 1, 2026
7adaf3a
fix: keep scoped simulator hint generic
thymikee Jul 1, 2026
96004d0
fix: clarify doctor Xcode selection context
thymikee Jul 1, 2026
e3eb132
fix: recognize provider scope in remote doctor
thymikee Jul 1, 2026
59880fb
fix: address doctor review gaps
thymikee Jul 1, 2026
0f70cec
fix: keep doctor metro checks inferred
thymikee Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions scripts/integration-progress-model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ function summarizeProviderScenarioFlagCoverage(files) {
['iosSimulatorDeviceSet', 'iOS simulator-set scoping reaches inventory resolution'],
['androidDeviceAllowlist', 'Android serial allowlist reaches inventory resolution'],
['session', 'named session routing'],
['targetApp', 'doctor target app discovery without opening a session'],
['surface', 'macOS app/frontmost/desktop/menubar surfaces'],
['activity', 'Android explicit launch activity'],
['launchConsole', 'iOS simulator launch console capture'],
Expand Down Expand Up @@ -212,6 +213,7 @@ function summarizeProviderScenarioFlagExclusions() {
'daemonAuthToken',
'daemonTransport',
'daemonServerMode',
'remote',
'tenant',
'sessionIsolation',
'runId',
Expand Down
39 changes: 39 additions & 0 deletions src/__tests__/cli-network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,45 @@ test('test command prints suite summary and exits non-zero on failures', async (
assert.match(result.stdout, /Test summary: 1 passed \(3\), 1 failed in 0\.025s/);
});

test('doctor command opts into progress rows for human output', async () => {
const result = await runCliCapture(['doctor'], async () => ({
ok: true,
data: {
status: 'pass',
summary: 'No blockers found.',
checks: [
{
id: 'agent-device',
status: 'pass',
summary: 'agent-device 0.17.9 using /tmp/agent-device',
},
],
},
}));

assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.command, 'doctor');
assert.equal(result.calls[0]?.meta?.requestProgress, 'command');
assert.match(result.stdout, /✓ agent-device: agent-device 0\.17\.9 using \/tmp\/agent-device/);
});

test('doctor command keeps json output non-streaming', async () => {
const result = await runCliCapture(['doctor', '--json'], async () => ({
ok: true,
data: {
status: 'pass',
summary: 'No blockers found.',
checks: [],
},
}));

assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.meta?.requestProgress, undefined);
assert.match(result.stdout, /"success": true/);
});

test('test command --verbose prints all test statuses', async () => {
const result = await runCliCapture(['test', './suite', '--verbose'], async () =>
makeReplaySuiteResponse(),
Expand Down
14 changes: 14 additions & 0 deletions src/__tests__/test-utils/color.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
export function withNoColor<T>(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;
}
}
2 changes: 2 additions & 0 deletions src/__tests__/test-utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ export {

export { makeSnapshotState } from './snapshot-builders.ts';

export { withNoColor } from './color.ts';

export {
closeLoopbackServer,
listenOnLoopback,
Expand Down
13 changes: 13 additions & 0 deletions src/cli-doctor-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export { formatDoctorCheckDetailLines, formatDoctorCheckSummaryLine } from './doctor-output.ts';

let renderedDoctorProgress = false;

export function markDoctorProgressRendered(): void {
renderedDoctorProgress = true;
}

export function consumeDoctorProgressRendered(): boolean {
const rendered = renderedDoctorProgress;
renderedDoctorProgress = false;
return rendered;
}
21 changes: 21 additions & 0 deletions src/cli-status-markers.ts
Original file line number Diff line number Diff line change
@@ -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<typeof colorize>[1]): string {
return colorize(text, format, { validateStream: false });
}
16 changes: 16 additions & 0 deletions src/cli/parser/cli-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,9 @@ export type CliFlags = CloudProviderProfileFields &
iosXctestEnvDir?: string;
deviceHub?: boolean;
androidDeviceAllowlist?: string;
remote?: boolean;
session?: string;
targetApp?: string;
metroHost?: string;
metroPort?: number;
bundleUrl?: string;
Expand Down Expand Up @@ -493,6 +495,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
usageLabel: '--headless',
usageDescription: 'Boot: launch Android emulator without a GUI window',
},
{
key: 'targetApp',
names: ['--app', '--target-app'],
type: 'string',
usageLabel: '--app <id-or-name>',
usageDescription: 'Doctor: verify an installed target app without opening a session',
},
{
key: 'metroHost',
names: ['--metro-host'],
Expand Down Expand Up @@ -678,6 +687,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [
usageLabel: '--android-device-allowlist <serials>',
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'],
Expand Down
5 changes: 5 additions & 0 deletions src/cli/parser/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,11 @@ Choose the next help topic:
Remote/cloud config, leases, and local service tunnels: help remote.

React Native dev loop:
Before QA/dogfood runs, use doctor to separate environment setup from app failures:
agent-device doctor --platform android
agent-device doctor --platform ios
agent-device doctor --platform android --app com.example.app
agent-device doctor --remote --remote-config ./remote.json
For "start from screen X" flows, prefer open --relaunch before the first snapshot so the app does not reuse a prior in-progress navigation state.
JS-only change with Metro connected:
agent-device metro reload
Expand Down
6 changes: 6 additions & 0 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,11 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & {
timeoutMs?: number;
};

export type DoctorCommandOptions = DeviceCommandBaseOptions & {
targetApp?: string;
remote?: boolean;
};

export type ViewportCommandOptions = DeviceCommandBaseOptions & {
width: number;
height: number;
Expand All @@ -520,6 +525,7 @@ export type AgentDeviceCommandClient = {
keyboard: (options?: KeyboardCommandOptions) => Promise<CommandResult<'keyboard'>>;
clipboard: (options: ClipboardCommandOptions) => Promise<CommandResult<'clipboard'>>;
reactNative: (options: ReactNativeCommandOptions) => Promise<CommandRequestResult>;
doctor: (options?: DoctorCommandOptions) => Promise<CommandRequestResult>;
prepare: (options: PrepareCommandOptions) => Promise<CommandRequestResult>;
viewport: (options: ViewportCommandOptions) => Promise<CommandResult<'viewport'>>;
};
Expand Down
1 change: 1 addition & 0 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export function createAgentDeviceClient(
clipboard: async (options) =>
await executeCommand<CommandResult<'clipboard'>>('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<CommandResult<'viewport'>>('viewport', options),
Expand Down
2 changes: 2 additions & 0 deletions src/command-catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export const PUBLIC_COMMANDS = {
close: 'close',
clipboard: 'clipboard',
devices: 'devices',
doctor: 'doctor',
diff: 'diff',
fill: 'fill',
find: 'find',
Expand Down Expand Up @@ -118,6 +119,7 @@ const CAPABILITY_EXEMPT_CLI_COMMANDS = commandSet(
PUBLIC_COMMANDS.prepare,
PUBLIC_COMMANDS.batch,
PUBLIC_COMMANDS.devices,
PUBLIC_COMMANDS.doctor,
PUBLIC_COMMANDS.gesture,
PUBLIC_COMMANDS.replay,
PUBLIC_COMMANDS.test,
Expand Down
53 changes: 53 additions & 0 deletions src/commands/management/doctor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import type { CommandSchemaOverride } from '../../utils/cli-command-schema-types.ts';
import * as commandInput from '../command-input.ts';
import { defineExecutableCommand } from '../command-contract.ts';
import { commonInputFromFlags, direct } from '../cli-grammar/common.ts';
import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts';
import { defineCommandFacet } from '../family/types.ts';
import { defineFieldCommandMetadata } from '../field-command-contract.ts';
import { managementCliOutputFormatters } from './output.ts';

const doctorCommandMetadata = defineFieldCommandMetadata(
'doctor',
'Diagnose device, app, Metro, and React Native readiness before a run.',
{
targetApp: commandInput.stringField(
'Installed app package/bundle id or app name to verify without opening a session.',
),
remote: commandInput.booleanField(
'Check remote connection setup instead of local device inventory.',
),
},
);

const doctorCommandDefinition = defineExecutableCommand(doctorCommandMetadata, (client, input) =>
client.command.doctor(input),
);

const doctorCliSchema = {
usageOverride:
'doctor [--platform ios|android|macos|linux|web|apple] [--app <id-or-name>] [--remote]',
helpDescription:
'Read-only preflight for QA and dogfood runs. Reports local device inventory, active sessions, optional app discovery, scoped toolchain info, and Metro reachability inferred from cwd/runtime. Pass --app to verify a target app on the one matching booted device without opening a session. Use --remote to check remote connection setup without probing local devices. Default output is compact; use --json for full checks and evidence.',
summary: 'Preflight device, app, Metro, and RN/Expo readiness',
allowedFlags: ['targetApp', 'remote'],
} as const satisfies CommandSchemaOverride;

const doctorCliReader: CliReader = (_positionals, flags) => ({
...commonInputFromFlags(flags),
targetApp: flags.targetApp,
remote: flags.remote,
});

const doctorDaemonWriter: DaemonWriter = direct(PUBLIC_COMMANDS.doctor);

export const doctorCommandFacet = defineCommandFacet({
name: 'doctor',
metadata: doctorCommandMetadata,
definition: doctorCommandDefinition,
cliSchema: doctorCliSchema,
cliReader: doctorCliReader,
daemonWriter: doctorDaemonWriter,
cliOutputFormatter: managementCliOutputFormatters.doctor,
});
2 changes: 2 additions & 0 deletions src/commands/management/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { defineCommandFamilyFromFacets } from '../family/types.ts';
import { artifactsCommandFacet } from './artifacts.ts';
import { appsCommandFacet, closeCommandFacet, openCommandFacet } from './app.ts';
import { deviceManagementCommandFacets } from './device.ts';
import { doctorCommandFacet } from './doctor.ts';
import { installManagementCommandFacets } from './install.ts';
import { prepareCommandFacet } from './prepare.ts';
import { pushManagementCommandFacets } from './push.ts';
Expand All @@ -13,6 +14,7 @@ export const managementCommandFamily = defineCommandFamilyFromFacets({
commands: [
...deviceManagementCommandFacets,
artifactsCommandFacet,
doctorCommandFacet,
prepareCommandFacet,
appsCommandFacet,
sessionCommandFacet,
Expand Down
92 changes: 91 additions & 1 deletion src/commands/management/output.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { describe, expect, test } from 'vitest';
import { managementCliOutputFormatters, openCliOutput } from './output.ts';
import { doctorCliOutput, managementCliOutputFormatters, openCliOutput } from './output.ts';
import { markDoctorProgressRendered } from '../../cli-doctor-output.ts';
import { withNoColor } from '../../__tests__/test-utils/index.ts';

describe('openCliOutput', () => {
test('prints session state directory on a second line', () => {
Expand Down Expand Up @@ -66,3 +68,91 @@ describe('artifactsCliOutput', () => {
);
});
});

describe('doctorCliOutput', () => {
test('prints passing checks by default using test-style status markers', () => {
const output = withNoColor(() =>
doctorCliOutput({
status: 'pass',
summary: 'No blockers found.',
checks: [
{
id: 'agent-device',
status: 'pass',
summary: 'agent-device 0.17.9 using /tmp/agent-device',
},
{
id: 'device',
status: 'pass',
summary: 'Selected Pixel (android)',
},
{
id: 'session',
status: 'info',
summary: 'No active session named default. Doctor will use the selected device.',
},
],
}),
);

expect(output.text).toBe(
[
'Doctor: pass',
'✓ agent-device: agent-device 0.17.9 using /tmp/agent-device',
'✓ device: Selected Pixel (android)',
'- session: No active session named default. Doctor will use the selected device.',
].join('\n'),
);
});

test('keeps warning and failure recovery details under the relevant row', () => {
const output = withNoColor(() =>
doctorCliOutput({
status: 'fail',
checks: [
{
id: 'device',
status: 'fail',
summary: 'No devices found.',
command: 'agent-device devices',
},
{
id: 'android-reverse',
status: 'warn',
summary: 'Android adb reverse is missing for Metro port 8081.',
command: 'adb -s emulator-5554 reverse tcp:8081 tcp:8081',
},
],
}),
);

expect(output.text).toBe(
[
'Doctor: fail',
'⨯ device: No devices found.',
' run: agent-device devices',
'! android-reverse: Android adb reverse is missing for Metro port 8081.',
' run: adb -s emulator-5554 reverse tcp:8081 tcp:8081',
].join('\n'),
);
});

test('prints only the summary after streamed progress rendered the checks', () => {
const output = withNoColor(() => {
markDoctorProgressRendered();
return doctorCliOutput({
status: 'pass',
summary: 'No blockers found.',
checks: [
{
id: 'device',
status: 'pass',
summary: 'Selected Pixel (android)',
},
],
});
});

expect(output.text).toBe(['Doctor: pass', 'No blockers found.'].join('\n'));
});
});
Loading
Loading