mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 15:39:41 +02:00
feat(settings): codeman doctor in Settings -> System -> Diagnostics
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
ffaa5ee80c
commit
d9174a7a03
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment node
|
||||
// The contract GET /api/doctor's default runner relies on: the same entry script, given
|
||||
// `doctor --json`, prints a parseable DependencyReportJson on stdout, even when it exits
|
||||
// non-zero because something required is missing.
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const ROOT = join(import.meta.dirname, '..');
|
||||
|
||||
describe('codeman doctor --json', () => {
|
||||
it('prints a report that includes Node and a summary, whatever the exit code', async () => {
|
||||
const stdout = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
process.execPath,
|
||||
[
|
||||
join(ROOT, 'node_modules/tsx/dist/cli.mjs'),
|
||||
join(ROOT, 'src/index.ts'),
|
||||
'doctor',
|
||||
'--json',
|
||||
'--category',
|
||||
'core',
|
||||
],
|
||||
{ timeout: 60_000, cwd: ROOT },
|
||||
(err, out) => (out ? resolve(out) : reject(err ?? new Error('no output')))
|
||||
);
|
||||
});
|
||||
const report = JSON.parse(stdout);
|
||||
expect(report.platform.environment).toMatch(/linux|darwin|win32|wsl/);
|
||||
expect(report.summary).toEqual(expect.objectContaining({ ok: expect.any(Number), exitCode: expect.any(Number) }));
|
||||
const node = report.tools.find((t: { id: string }) => t.id === 'node');
|
||||
expect(node?.status).toBe('ok');
|
||||
expect(report.tools.every((t: { category: string }) => t.category === 'core')).toBe(true);
|
||||
}, 90_000);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
/** @fileoverview Settings → System → Diagnostics in a real browser, with GET /api/doctor stubbed at the network layer. */
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { chromium, type Browser, type Page } from 'playwright';
|
||||
import { WebServer } from '../src/web/server.js';
|
||||
|
||||
const PORT = 3196;
|
||||
|
||||
const REPORT = {
|
||||
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',
|
||||
path: '/usr/bin/node',
|
||||
},
|
||||
{
|
||||
id: 'tmux',
|
||||
label: 'tmux',
|
||||
category: 'core',
|
||||
required: true,
|
||||
usedBy: [],
|
||||
status: 'missing',
|
||||
installHint: 'apt install tmux',
|
||||
},
|
||||
// Host-supplied strings must be rendered as text, never as markup.
|
||||
{
|
||||
id: 'x',
|
||||
label: '<img src=x onerror=window.__pwned=1>',
|
||||
category: 'other',
|
||||
required: false,
|
||||
usedBy: [],
|
||||
status: 'missing',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('Diagnostics panel in a real browser', () => {
|
||||
let server: WebServer;
|
||||
let browser: Browser;
|
||||
let page: Page;
|
||||
|
||||
beforeAll(async () => {
|
||||
server = new WebServer(PORT, false, true);
|
||||
await server.start();
|
||||
browser = await chromium.launch({ headless: true });
|
||||
page = await browser.newPage();
|
||||
await page.goto(`http://localhost:${PORT}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
|
||||
await page.evaluate(() => (window as any).app.openAppSettings());
|
||||
}, 90000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (browser) await browser.close();
|
||||
if (server) await server.stop();
|
||||
}, 60000);
|
||||
|
||||
it('lists each tool with status, version, path and install hint, and renders host strings as text', async () => {
|
||||
await page.route('**/api/doctor', (route) =>
|
||||
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data: REPORT }) })
|
||||
);
|
||||
await page.click('#doctorRunBtn');
|
||||
await page.waitForFunction(() => /1 ok/.test(document.getElementById('doctorResult')?.textContent ?? ''));
|
||||
const text = await page.textContent('#doctorResult');
|
||||
expect(text).toContain('1 ok · 1 required missing · 0 optional missing (linux)');
|
||||
expect(text).toContain('✓ Node.js ok · 22.1.0');
|
||||
expect(text).toContain('/usr/bin/node');
|
||||
expect(text).toContain('✗ tmux missing · required');
|
||||
expect(text).toContain('Install: apt install tmux');
|
||||
expect(text).toContain('<img src=x onerror=window.__pwned=1>'); // shown literally
|
||||
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
|
||||
expect(await page.$('#doctorResult img')).toBeNull();
|
||||
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows the server’s message when the check fails, and re-enables the button', async () => {
|
||||
await page.unroute('**/api/doctor');
|
||||
await page.route('**/api/doctor', (route) =>
|
||||
route.fulfill({
|
||||
status: 500,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ success: false, errorCode: 'OPERATION_FAILED', error: 'doctor failed: boom' }),
|
||||
})
|
||||
);
|
||||
await page.click('#doctorRunBtn');
|
||||
await page.waitForFunction(() => /boom/.test(document.getElementById('doctorResult')?.textContent ?? ''));
|
||||
expect(await page.isDisabled('#doctorRunBtn')).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user