Skip to content
72 changes: 72 additions & 0 deletions src/__tests__/cli-doctor-progress.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
39 changes: 39 additions & 0 deletions src/__tests__/cli-network.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
60 changes: 60 additions & 0 deletions src/__tests__/daemon-client-progress.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
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
39 changes: 39 additions & 0 deletions src/cli-doctor-output.ts
Original file line number Diff line number Diff line change
@@ -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, unknown>): string {
const statusMarker = formatCliStatusMarker(doctorStatusMarker(check.status));
return `${statusMarker} ${formatDoctorCheckLabel(check)}`;
}

export function formatDoctorCheckDetailLines(check: Record<string, unknown>): 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, unknown>): 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}`;
}
40 changes: 40 additions & 0 deletions src/cli-doctor-progress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import type { RequestProgressEvent } from './daemon/request-progress.ts';
import {
markDoctorProgressRendered,
formatDoctorCheckDetailLines,
formatDoctorCheckSummaryLine,
} from './cli-doctor-output.ts';

type DoctorCheckProgressEvent = Extract<RequestProgressEvent, { type: 'doctor-check' }>;

export type DoctorProgressRenderer = {
render(event: RequestProgressEvent): { text: string; newline: true } | undefined;
};

export function createDoctorProgressRenderer(): DoctorProgressRenderer {
const completed = new Set<string>();
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');
}
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: 6 additions & 10 deletions src/cli-test-progress.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<RequestProgressEvent, { type: 'replay-test' }>;
type ReplayTestProgressFormatOptions = {
Expand Down Expand Up @@ -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<typeof colorize>[1]): string {
return colorize(text, format, { validateStream: false });
return formatCliStatusMarker('skip');
}

function formatReplayTestProgressShardSuffix(event: ReplayTestCaseProgressEvent): string {
Expand Down
7 changes: 5 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
});
}
Expand Down
3 changes: 3 additions & 0 deletions src/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,6 +509,8 @@ export type PrepareCommandOptions = DeviceCommandBaseOptions & {
timeoutMs?: number;
};

export type DoctorCommandOptions = DeviceCommandBaseOptions;

export type ViewportCommandOptions = DeviceCommandBaseOptions & {
width: number;
height: number;
Expand All @@ -530,6 +532,7 @@ export type AgentDeviceCommandClient = {
keyboard: (options?: KeyboardCommandOptions) => Promise<KeyboardCommandResult>;
clipboard: (options: ClipboardCommandOptions) => Promise<ClipboardCommandResult>;
reactNative: (options: ReactNativeCommandOptions) => Promise<CommandRequestResult>;
doctor: (options?: DoctorCommandOptions) => Promise<CommandRequestResult>;
prepare: (options: PrepareCommandOptions) => Promise<CommandRequestResult>;
viewport: (options: ViewportCommandOptions) => Promise<ViewportCommandResult>;
};
Expand Down
1 change: 1 addition & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ViewportCommandResult>('viewport', options),
},
Expand Down
Loading