Skip to content

Commit edc31c8

Browse files
authored
feat(cli): add "testsprite doctor" environment diagnostic (#183)
* feat(cli): add "testsprite doctor" environment diagnostic One-shot preflight: checks CLI version, Node runtime, profile, API endpoint, credentials, live connectivity (GET /me), and verify-skill install. Prints an OK/WARN/FAIL report and exits non-zero when any check fails, so it gates a CI step or agent preflight. Reuses the real resolution helpers; the API key is never printed. Fixes #73 * fix(doctor): address review findings - Node check reuses the CLI runtime guard (shouldRejectNodeVersion) instead of a hardcoded major floor, so the verdict matches what the entrypoint enforces at startup; the precise 20.19+/22.13+/24+ engines are enforced by npm engine-strict. - --request-timeout raises a validation error on malformed input instead of silently defaulting, matching the other commands. - The --output json test asserts the API key never appears in the JSON path (distinct from the text renderer already covered).
1 parent e819e45 commit edc31c8

4 files changed

Lines changed: 518 additions & 0 deletions

File tree

src/commands/doctor.test.ts

Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/**
2+
* Unit tests for `testsprite doctor`.
3+
*
4+
* The command reuses the real resolution helpers (loadConfig, makeHttpClient,
5+
* isVerifySkillInstalled), so these tests inject env/credentials/fetch/fs and
6+
* assert on the rendered report + the exit-on-failure contract.
7+
*/
8+
9+
import { mkdtempSync } from 'node:fs';
10+
import { tmpdir } from 'node:os';
11+
import { join } from 'node:path';
12+
import { beforeEach, describe, expect, it, vi } from 'vitest';
13+
import { CLIError } from '../lib/errors.js';
14+
import { writeProfile } from '../lib/credentials.js';
15+
import type { DoctorDeps, DoctorReport } from './doctor.js';
16+
import { createDoctorCommand, runDoctor } from './doctor.js';
17+
18+
interface CapturedOutput {
19+
stdout: string[];
20+
stderr: string[];
21+
}
22+
23+
function makeCapture(): { capture: CapturedOutput; deps: Pick<DoctorDeps, 'stdout' | 'stderr'> } {
24+
const capture: CapturedOutput = { stdout: [], stderr: [] };
25+
return {
26+
capture,
27+
deps: {
28+
stdout: line => capture.stdout.push(line),
29+
stderr: line => capture.stderr.push(line),
30+
},
31+
};
32+
}
33+
34+
function makeFetch(body: unknown, status = 200): DoctorDeps['fetchImpl'] {
35+
return vi.fn(
36+
async () =>
37+
new Response(JSON.stringify(body), {
38+
status,
39+
headers: { 'content-type': 'application/json' },
40+
}),
41+
) as unknown as DoctorDeps['fetchImpl'];
42+
}
43+
44+
const OK_ME = { userId: 'u-doc', keyId: 'k-doc' };
45+
46+
/** Base deps shared by the healthy-path tests: node OK, skill installed, empty env. */
47+
function healthyDeps(credentialsPath: string, extra: Partial<DoctorDeps> = {}): DoctorDeps {
48+
return {
49+
env: {},
50+
credentialsPath,
51+
cwd: '/project',
52+
nodeVersion: '22.9.0',
53+
existsSync: () => true, // skill landing file present
54+
fetchImpl: makeFetch(OK_ME),
55+
...extra,
56+
};
57+
}
58+
59+
let credentialsPath: string;
60+
61+
beforeEach(() => {
62+
credentialsPath = join(mkdtempSync(join(tmpdir(), 'testsprite-doctor-')), 'credentials');
63+
});
64+
65+
describe('runDoctor — healthy environment', () => {
66+
it('returns an all-passing report and does not throw', async () => {
67+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
68+
const { capture, deps } = makeCapture();
69+
const report = await runDoctor(
70+
{ profile: 'default', output: 'text', debug: false },
71+
{ ...healthyDeps(credentialsPath), ...deps },
72+
);
73+
expect(report.failures).toBe(0);
74+
expect(report.warnings).toBe(0);
75+
const out = capture.stdout.join('\n');
76+
expect(out).toContain('[OK]');
77+
expect(out).toContain('All checks passed.');
78+
expect(out).toContain('reached GET /me');
79+
});
80+
81+
it('never prints the API key anywhere in the report', async () => {
82+
writeProfile('default', { apiKey: 'sk-super-secret-value' }, { path: credentialsPath });
83+
const { capture, deps } = makeCapture();
84+
await runDoctor(
85+
{ profile: 'default', output: 'text', debug: false },
86+
{ ...healthyDeps(credentialsPath), ...deps },
87+
);
88+
const all = capture.stdout.join('\n') + capture.stderr.join('\n');
89+
expect(all).not.toContain('sk-super-secret-value');
90+
});
91+
92+
it('emits a machine-readable report under --output json without leaking the API key', async () => {
93+
writeProfile('default', { apiKey: 'sk-json-secret-value' }, { path: credentialsPath });
94+
const { capture, deps } = makeCapture();
95+
await runDoctor(
96+
{ profile: 'default', output: 'json', debug: false },
97+
{ ...healthyDeps(credentialsPath), ...deps },
98+
);
99+
const raw = capture.stdout.join('');
100+
// Security: the JSON serialization path is distinct from the text renderer,
101+
// so assert the key never leaks here either.
102+
expect(raw).not.toContain('sk-json-secret-value');
103+
const parsed = JSON.parse(raw) as DoctorReport;
104+
expect(parsed.failures).toBe(0);
105+
expect(Array.isArray(parsed.checks)).toBe(true);
106+
expect(
107+
parsed.checks.some(check => check.name === 'Connectivity' && check.status === 'ok'),
108+
).toBe(true);
109+
});
110+
});
111+
112+
describe('runDoctor — failing checks exit non-zero', () => {
113+
it('missing API key fails Credentials and throws CLIError (exit 1)', async () => {
114+
const { capture, deps } = makeCapture();
115+
const rejection = await runDoctor(
116+
{ profile: 'default', output: 'text', debug: false },
117+
{ ...healthyDeps(credentialsPath), ...deps }, // no profile written => no key
118+
).catch((error: unknown) => error);
119+
expect(rejection).toBeInstanceOf(CLIError);
120+
expect(rejection).toMatchObject({ exitCode: 1 });
121+
const out = capture.stdout.join('\n');
122+
expect(out).toContain('[FAIL]');
123+
expect(out).toContain('Credentials');
124+
});
125+
126+
it('invalid endpoint URL fails the API endpoint check', async () => {
127+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
128+
const { capture, deps } = makeCapture();
129+
const rejection = await runDoctor(
130+
{ profile: 'default', output: 'text', debug: false, endpointUrl: 'not-a-url' },
131+
{ ...healthyDeps(credentialsPath), ...deps },
132+
).catch((error: unknown) => error);
133+
expect(rejection).toBeInstanceOf(CLIError);
134+
const out = capture.stdout.join('\n');
135+
expect(out).toContain('API endpoint');
136+
expect(out).toContain('not a valid');
137+
});
138+
139+
it('rejected API key surfaces as a Connectivity failure', async () => {
140+
writeProfile('default', { apiKey: 'sk-bad' }, { path: credentialsPath });
141+
const { capture, deps } = makeCapture();
142+
const authError = {
143+
error: { code: 'AUTH_INVALID', message: 'Bad key.', requestId: 'req_x', details: {} },
144+
};
145+
const rejection = await runDoctor(
146+
{ profile: 'default', output: 'text', debug: false },
147+
{ ...healthyDeps(credentialsPath, { fetchImpl: makeFetch(authError, 401) }), ...deps },
148+
).catch((error: unknown) => error);
149+
expect(rejection).toBeInstanceOf(CLIError);
150+
const out = capture.stdout.join('\n');
151+
expect(out).toContain('Connectivity');
152+
expect(out).toContain('API key rejected (AUTH_INVALID)');
153+
});
154+
155+
it('a non-auth /me error is reported as a Connectivity failure with its code', async () => {
156+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
157+
const { capture, deps } = makeCapture();
158+
const notFound = {
159+
error: { code: 'NOT_FOUND', message: 'nope', requestId: 'req_y', details: {} },
160+
};
161+
const rejection = await runDoctor(
162+
{ profile: 'default', output: 'text', debug: false },
163+
{ ...healthyDeps(credentialsPath, { fetchImpl: makeFetch(notFound, 404) }), ...deps },
164+
).catch((error: unknown) => error);
165+
expect(rejection).toBeInstanceOf(CLIError);
166+
expect(capture.stdout.join('\n')).toContain('GET /me failed (NOT_FOUND)');
167+
});
168+
169+
it('an outdated Node runtime fails the Node.js check', async () => {
170+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
171+
const { capture, deps } = makeCapture();
172+
const rejection = await runDoctor(
173+
{ profile: 'default', output: 'text', debug: false },
174+
{ ...healthyDeps(credentialsPath, { nodeVersion: '18.0.0' }), ...deps },
175+
).catch((error: unknown) => error);
176+
expect(rejection).toBeInstanceOf(CLIError);
177+
const out = capture.stdout.join('\n');
178+
expect(out).toContain('Node.js');
179+
expect(out).toContain('below the required Node 20');
180+
});
181+
});
182+
183+
describe('runDoctor — warnings do not fail', () => {
184+
it('missing verify skill is a warning, not a failure', async () => {
185+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
186+
const { capture, deps } = makeCapture();
187+
const report = await runDoctor(
188+
{ profile: 'default', output: 'text', debug: false },
189+
{ ...healthyDeps(credentialsPath, { existsSync: () => false }), ...deps },
190+
);
191+
expect(report.failures).toBe(0);
192+
expect(report.warnings).toBeGreaterThanOrEqual(1);
193+
const out = capture.stdout.join('\n');
194+
expect(out).toContain('[WARN]');
195+
expect(out).toContain('Verify skill');
196+
});
197+
198+
it('--dry-run skips connectivity and never calls fetch, missing key is a warning', async () => {
199+
const fetchImpl = vi.fn(async () => {
200+
throw new Error('fetch must not be called under --dry-run');
201+
}) as unknown as DoctorDeps['fetchImpl'];
202+
const { capture, deps } = makeCapture();
203+
const report = await runDoctor(
204+
{ profile: 'default', output: 'text', debug: false, dryRun: true },
205+
{
206+
env: {},
207+
credentialsPath,
208+
cwd: '/project',
209+
nodeVersion: '22.9.0',
210+
existsSync: () => true,
211+
fetchImpl,
212+
...deps,
213+
},
214+
);
215+
expect(report.failures).toBe(0);
216+
expect(fetchImpl).not.toHaveBeenCalled();
217+
expect(capture.stdout.join('\n')).toContain('skipped under --dry-run');
218+
});
219+
});
220+
221+
describe('createDoctorCommand wiring', () => {
222+
it('exposes the doctor command name', () => {
223+
expect(createDoctorCommand().name()).toBe('doctor');
224+
});
225+
226+
it('--help describes the diagnostic', () => {
227+
expect(createDoctorCommand().helpInformation()).toContain('Diagnose');
228+
});
229+
});

0 commit comments

Comments
 (0)