Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c649f1d
feat: add doctor command
thymikee Jun 25, 2026
7ac0feb
fix: reduce doctor command complexity
thymikee Jun 25, 2026
e0a858a
fix: classify doctor integration flags
thymikee Jun 25, 2026
2b9f72d
fix: simplify doctor setup
thymikee Jun 25, 2026
69a2580
refactor: split doctor checks
thymikee Jun 26, 2026
27b17b5
fix: simplify doctor check set
thymikee Jun 26, 2026
e44e7c4
fix: include stopped android avds in devices
thymikee Jun 26, 2026
84b7c6a
fix: report doctor device inventory
thymikee Jun 26, 2026
403dfdc
refactor: reuse device inventory selectors
thymikee Jun 26, 2026
9af9b2a
fix: summarize doctor inventory by platform
thymikee Jun 26, 2026
ee5435c
fix: show metro cwd in doctor
thymikee Jun 26, 2026
3655d2e
refactor: simplify metro doctor lookup
thymikee Jun 26, 2026
dd16137
fix: update doctor imports after apple consolidation
thymikee Jun 30, 2026
64d8a1e
feat: make doctor Metro probe controllable and surface hidden toolcha…
thymikee Jul 1, 2026
10fd391
fix: align doctor CI expectations
thymikee Jul 1, 2026
3e4278d
feat: extend doctor preflight checks
thymikee Jul 1, 2026
30c84ba
fix: keep doctor checks within ci gates
thymikee Jul 1, 2026
4012013
fix: simplify doctor metro surface
thymikee Jul 1, 2026
753c04f
refactor: trim doctor bundle impact
thymikee Jul 1, 2026
00cbcb1
fix: restore useful doctor diagnostics
thymikee Jul 1, 2026
11588a2
refactor: reuse doctor output helpers
thymikee Jul 1, 2026
079d27e
refactor: share device inventory grouping
thymikee Jul 1, 2026
404522f
refactor: keep doctor focused on preflight checks
thymikee Jul 1, 2026
0000c60
refactor: simplify doctor toolchain probes
thymikee Jul 1, 2026
255219a
fix: keep scoped simulator hint generic
thymikee Jul 1, 2026
bd65d09
fix: clarify doctor Xcode selection context
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 @@ -65,7 +65,9 @@ export type CliFlags = RemoteConfigMetroOptions &
iosXctestEnvDir?: string;
deviceHub?: boolean;
androidDeviceAllowlist?: string;
remote?: boolean;
session?: string;
targetApp?: string;
metroHost?: string;
metroPort?: number;
bundleUrl?: string;
Expand Down Expand Up @@ -401,6 +403,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 @@ -586,6 +595,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 @@ -482,6 +482,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 @@ -498,6 +503,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 @@ -122,6 +122,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 @@ -10,6 +10,7 @@ export const PUBLIC_COMMANDS = {
close: 'close',
clipboard: 'clipboard',
devices: 'devices',
doctor: 'doctor',
diff: 'diff',
fill: 'fill',
find: 'find',
Expand Down Expand Up @@ -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,
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
@@ -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';
Expand All @@ -11,6 +12,7 @@ export const managementCommandFamily = defineCommandFamilyFromFacets({
name: 'management',
commands: [
...deviceManagementCommandFacets,
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 { 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', () => {
Expand All @@ -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'));
});
});
Loading
Loading