feat(settings): codeman doctor in Settings -> System -> Diagnostics

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-10-05 07:03:40 +08:00
co-authored by Claude Sonnet 5.5
parent ffaa5ee80c
commit d9174a7a03
9 changed files with 413 additions and 0 deletions
+119
View File
@@ -0,0 +1,119 @@
/**
* @fileoverview GET /api/doctor: the `codeman doctor` report for Settings → System → Diagnostics.
* The route runs the probe out of process (the engine is synchronous), so every test injects the
* runner; the default runner's parsing is covered against a faked `execFile`, and the CLI contract
* it relies on is exercised for real in test/doctor-cli-json.test.ts.
*
* Port: N/A (app.inject()).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { defaultDoctorRunner, registerDoctorRoutes, type DoctorRunner } from '../../src/web/routes/doctor-routes.js';
import type { DependencyReportJson } from '../../src/utils/dependency-report.js';
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn() }));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, execFile: execFileMock };
});
const REPORT: DependencyReportJson = {
platform: { environment: 'linux' },
summary: { ok: 1, requiredMissing: 1, optionalMissing: 0, exitCode: 1 },
tools: [
{ id: 'node', label: 'Node.js', category: 'core', required: true, usedBy: [], status: 'ok', version: '22.1.0' },
{ id: 'tmux', label: 'tmux', category: 'core', required: true, usedBy: [], status: 'missing' },
],
};
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
execFileMock.mockReset();
});
describe('GET /api/doctor', () => {
it('returns the runner’s report in the success envelope', async () => {
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ success: true, data: REPORT });
expect(runner).toHaveBeenCalledWith(undefined);
});
it('passes a valid category through and rejects an unknown one without running anything', async () => {
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
expect((await app.inject({ method: 'GET', url: '/api/doctor?category=office' })).statusCode).toBe(200);
expect(runner).toHaveBeenLastCalledWith('office');
runner.mockClear();
const bad = await app.inject({ method: 'GET', url: '/api/doctor?category=%3Brm%20-rf' });
expect(bad.statusCode).toBe(400);
expect(bad.json().errorCode).toBe('INVALID_INPUT');
expect(runner).not.toHaveBeenCalled();
});
it('answers 500 with a message when the runner fails', async () => {
const runner: DoctorRunner = async () => {
throw new Error('spawn blew up');
};
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner));
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(500);
expect(res.json().error).toContain('spawn blew up');
});
it('multi-user: a non-admin is refused and nothing is probed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const runner = vi.fn<DoctorRunner>(async () => REPORT);
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, runner), {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/doctor' });
expect(res.statusCode).toBe(403);
expect(runner).not.toHaveBeenCalled();
});
it('multi-user: an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness((a) => registerDoctorRoutes(a, async () => REPORT), {
authUser: { username: 'root', role: 'admin' },
});
expect((await app.inject({ method: 'GET', url: '/api/doctor' })).statusCode).toBe(200);
});
});
describe('defaultDoctorRunner', () => {
type Done = (err: Error | null, stdout: string) => void;
const respond = (err: Error | null, stdout: string) =>
execFileMock.mockImplementation((_bin: string, _args: string[], _opts: unknown, done: Done) => done(err, stdout));
it('runs `doctor --json` in a child of this same entry script, never in-process', async () => {
respond(null, JSON.stringify(REPORT));
await defaultDoctorRunner('core');
const [bin, args, opts] = execFileMock.mock.calls[0];
expect(bin).toBe(process.execPath);
expect(args.slice(-4)).toEqual(['doctor', '--json', '--category', 'core']);
expect(args).toContain(process.argv[1]);
expect((opts as { timeout: number }).timeout).toBeGreaterThan(0);
});
it('treats a non-zero exit with a valid report as a normal result (a missing required tool exits 1)', async () => {
respond(Object.assign(new Error('exit 1'), { code: 1 }), JSON.stringify(REPORT));
await expect(defaultDoctorRunner()).resolves.toEqual(REPORT);
});
it.each([
['empty output', ''],
['non-JSON output', 'Segmentation fault'],
['JSON of the wrong shape', '{"hello":"world"}'],
])('rejects %s', async (_label, stdout) => {
respond(null, stdout);
await expect(defaultDoctorRunner()).rejects.toThrow();
});
it('passes the child’s own error through when there is no report at all', async () => {
respond(new Error('ETIMEDOUT'), '');
await expect(defaultDoctorRunner()).rejects.toThrow('ETIMEDOUT');
});
});