Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 4 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1430,10 +1430,13 @@ cli({
const fmt = resolveCommandOutputFormat(doctorCmd, opts.format);
if (fmt === null) return;
const fmtExplicit = outputFormatIsExplicit(doctorCmd);
const { runBrowserDoctor, renderBrowserDoctorReport } = await import('./doctor.js');
const { runBrowserDoctor, renderBrowserDoctorReport, doctorRequiredChecksFailed } = await import('./doctor.js');
const report = await runBrowserDoctor({ cliVersion: PKG_VERSION });
if (fmt === 'table') console.log(renderBrowserDoctorReport(report));
else await renderOutput(report, { fmt, fmtExplicit });
// The structured report is the payload and always reaches stdout; the exit
// code is what lets an agent gate its next step on the outcome.
if (doctorRequiredChecksFailed(report)) process.exitCode = EXIT_CODES.CONFIG_ERROR;
});

configureCompletionCommandSurface(program.command('completion'))
Expand Down
88 changes: 84 additions & 4 deletions src/doctor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { EXIT_CODES } from './errors.js';

const {
mockGetDaemonHealth,
Expand All @@ -23,9 +24,13 @@ const {
mockEnsureBinary: vi.fn(),
}));

vi.mock('./browser/daemon-transport.js', () => ({
getDaemonHealth: mockGetDaemonHealth,
}));
vi.mock('./browser/daemon-transport.js', async () => {
const actual = await vi.importActual<typeof import('./browser/daemon-transport.js')>('./browser/daemon-transport.js');
return {
...actual,
getDaemonHealth: mockGetDaemonHealth,
};
});

// Real binaryInfo() reads this machine's actual CloakBrowser cache dir, which
// varies by dev box/CI runner — mock it so doctor tests are hermetic and the
Expand Down Expand Up @@ -55,7 +60,8 @@ vi.mock('./adapter-shadow.js', async () => {
};
});

import { checkBrowserBinary, checkConnectivity, renderBrowserDoctorReport, runBrowserDoctor } from './doctor.js';
import { checkBrowserBinary, checkConnectivity, doctorRequiredChecksFailed, renderBrowserDoctorReport, runBrowserDoctor, type DoctorReport } from './doctor.js';
import { createProgram } from './cli.js';

const managedBinaryDir = fs.mkdtempSync(path.join(os.tmpdir(), 'webcmd-managed-binary-'));
const managedBinaryPath = path.join(managedBinaryDir, process.platform === 'win32' ? 'chrome.exe' : 'chrome');
Expand Down Expand Up @@ -815,3 +821,77 @@ describe('doctor window mode', () => {
vi.unstubAllEnvs();
});
});

describe('doctorRequiredChecksFailed', () => {
const healthy: DoctorReport = {
daemonRunning: true,
runtimeConnected: true,
connectivity: { ok: true, durationMs: 12 },
issues: [],
};

it('passes when every required check is healthy', () => {
expect(doctorRequiredChecksFailed(healthy)).toBe(false);
});

it('passes when only a soft issue was recorded', () => {
expect(doctorRequiredChecksFailed({
...healthy,
issues: ['Could not check adapter overrides: EACCES'],
})).toBe(false);
});

it.each([
['daemon down', { ...healthy, daemonRunning: false }],
['runtime disconnected', { ...healthy, runtimeConnected: false }],
['connectivity failed', { ...healthy, connectivity: { ok: false, durationMs: 9 } }],
])('fails when %s', (_label, report) => {
expect(doctorRequiredChecksFailed(report as DoctorReport)).toBe(true);
});

it('passes when connectivity was not probed at all', () => {
const { connectivity: _omitted, ...withoutConnectivity } = healthy;
expect(doctorRequiredChecksFailed(withoutConnectivity as DoctorReport)).toBe(false);
});
});

describe('doctor command', () => {
beforeEach(() => {
vi.clearAllMocks();
mockEnsureBinary.mockResolvedValue('path/to/chrome');
mockBinaryInfo.mockReturnValue({
version: '146.0.7680.177.5',
bundledVersion: '146.0.7680.177.5',
tier: 'free',
platform: 'linux-x64',
binaryPath: '/path/to/chrome',
installed: true,
cacheDir: '/cache',
downloadUrl: 'https://example.com/download',
});
mockConnect.mockResolvedValue({
evaluate: vi.fn().mockResolvedValue(2),
closeWindow: vi.fn().mockResolvedValue(undefined),
});
mockClose.mockResolvedValue(undefined);
mockSendCommand.mockImplementation(async (action: string) => {
if (action === 'session-create') return { id: 'doctor-probe-k7' };
if (action === 'session-close') return { closed: true };
throw new Error(`Unexpected doctor command: ${action}`);
});
mockFindShadowedUserAdapters.mockReturnValue([]);
mockSetDaemonCommandTimeoutSeconds.mockClear();
});

it('exits CONFIG_ERROR when a required doctor check fails', async () => {
mockGetDaemonHealth.mockResolvedValue({ state: 'stopped', status: null });
const previous = process.exitCode;
process.exitCode = undefined;
try {
await createProgram('', '').parseAsync(['doctor', '-f', 'json'], { from: 'user' });
expect(process.exitCode).toBe(EXIT_CODES.CONFIG_ERROR);
} finally {
process.exitCode = previous;
}
});
});
12 changes: 12 additions & 0 deletions src/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,18 @@ export type DoctorReport = {
issues: string[];
};

/**
* Required readiness checks, as opposed to `issues`, which also collects soft
* warnings such as an unreadable adapter-override directory. An absent
* `connectivity` means the probe never ran, which is not a failure.
*/
export function doctorRequiredChecksFailed(report: DoctorReport): boolean {
if (!report.daemonRunning) return true;
if (!report.runtimeConnected) return true;
if (report.connectivity && !report.connectivity.ok) return true;
return false;
}

function isLaunchableFile(binaryPath: string): boolean {
try {
if (!fs.statSync(binaryPath).isFile()) return false;
Expand Down
Loading