Skip to content

Commit f371e53

Browse files
committed
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
1 parent b9e9601 commit f371e53

4 files changed

Lines changed: 506 additions & 0 deletions

File tree

src/commands/doctor.test.ts

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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', async () => {
93+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
94+
const { capture, deps } = makeCapture();
95+
await runDoctor(
96+
{ profile: 'default', output: 'json', debug: false },
97+
{ ...healthyDeps(credentialsPath), ...deps },
98+
);
99+
const parsed = JSON.parse(capture.stdout.join('')) as DoctorReport;
100+
expect(parsed.failures).toBe(0);
101+
expect(Array.isArray(parsed.checks)).toBe(true);
102+
expect(
103+
parsed.checks.some(check => check.name === 'Connectivity' && check.status === 'ok'),
104+
).toBe(true);
105+
});
106+
});
107+
108+
describe('runDoctor — failing checks exit non-zero', () => {
109+
it('missing API key fails Credentials and throws CLIError (exit 1)', async () => {
110+
const { capture, deps } = makeCapture();
111+
const rejection = await runDoctor(
112+
{ profile: 'default', output: 'text', debug: false },
113+
{ ...healthyDeps(credentialsPath), ...deps }, // no profile written => no key
114+
).catch((error: unknown) => error);
115+
expect(rejection).toBeInstanceOf(CLIError);
116+
expect(rejection).toMatchObject({ exitCode: 1 });
117+
const out = capture.stdout.join('\n');
118+
expect(out).toContain('[FAIL]');
119+
expect(out).toContain('Credentials');
120+
});
121+
122+
it('invalid endpoint URL fails the API endpoint check', async () => {
123+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
124+
const { capture, deps } = makeCapture();
125+
const rejection = await runDoctor(
126+
{ profile: 'default', output: 'text', debug: false, endpointUrl: 'not-a-url' },
127+
{ ...healthyDeps(credentialsPath), ...deps },
128+
).catch((error: unknown) => error);
129+
expect(rejection).toBeInstanceOf(CLIError);
130+
const out = capture.stdout.join('\n');
131+
expect(out).toContain('API endpoint');
132+
expect(out).toContain('not a valid');
133+
});
134+
135+
it('rejected API key surfaces as a Connectivity failure', async () => {
136+
writeProfile('default', { apiKey: 'sk-bad' }, { path: credentialsPath });
137+
const { capture, deps } = makeCapture();
138+
const authError = {
139+
error: { code: 'AUTH_INVALID', message: 'Bad key.', requestId: 'req_x', details: {} },
140+
};
141+
const rejection = await runDoctor(
142+
{ profile: 'default', output: 'text', debug: false },
143+
{ ...healthyDeps(credentialsPath, { fetchImpl: makeFetch(authError, 401) }), ...deps },
144+
).catch((error: unknown) => error);
145+
expect(rejection).toBeInstanceOf(CLIError);
146+
const out = capture.stdout.join('\n');
147+
expect(out).toContain('Connectivity');
148+
expect(out).toContain('API key rejected (AUTH_INVALID)');
149+
});
150+
151+
it('a non-auth /me error is reported as a Connectivity failure with its code', async () => {
152+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
153+
const { capture, deps } = makeCapture();
154+
const notFound = {
155+
error: { code: 'NOT_FOUND', message: 'nope', requestId: 'req_y', details: {} },
156+
};
157+
const rejection = await runDoctor(
158+
{ profile: 'default', output: 'text', debug: false },
159+
{ ...healthyDeps(credentialsPath, { fetchImpl: makeFetch(notFound, 404) }), ...deps },
160+
).catch((error: unknown) => error);
161+
expect(rejection).toBeInstanceOf(CLIError);
162+
expect(capture.stdout.join('\n')).toContain('GET /me failed (NOT_FOUND)');
163+
});
164+
165+
it('an outdated Node runtime fails the Node.js check', async () => {
166+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
167+
const { capture, deps } = makeCapture();
168+
const rejection = await runDoctor(
169+
{ profile: 'default', output: 'text', debug: false },
170+
{ ...healthyDeps(credentialsPath, { nodeVersion: '18.0.0' }), ...deps },
171+
).catch((error: unknown) => error);
172+
expect(rejection).toBeInstanceOf(CLIError);
173+
const out = capture.stdout.join('\n');
174+
expect(out).toContain('Node.js');
175+
expect(out).toContain('below the required Node 20');
176+
});
177+
});
178+
179+
describe('runDoctor — warnings do not fail', () => {
180+
it('missing verify skill is a warning, not a failure', async () => {
181+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
182+
const { capture, deps } = makeCapture();
183+
const report = await runDoctor(
184+
{ profile: 'default', output: 'text', debug: false },
185+
{ ...healthyDeps(credentialsPath, { existsSync: () => false }), ...deps },
186+
);
187+
expect(report.failures).toBe(0);
188+
expect(report.warnings).toBeGreaterThanOrEqual(1);
189+
const out = capture.stdout.join('\n');
190+
expect(out).toContain('[WARN]');
191+
expect(out).toContain('Verify skill');
192+
});
193+
194+
it('--dry-run skips connectivity and never calls fetch, missing key is a warning', async () => {
195+
const fetchImpl = vi.fn(async () => {
196+
throw new Error('fetch must not be called under --dry-run');
197+
}) as unknown as DoctorDeps['fetchImpl'];
198+
const { capture, deps } = makeCapture();
199+
const report = await runDoctor(
200+
{ profile: 'default', output: 'text', debug: false, dryRun: true },
201+
{
202+
env: {},
203+
credentialsPath,
204+
cwd: '/project',
205+
nodeVersion: '22.9.0',
206+
existsSync: () => true,
207+
fetchImpl,
208+
...deps,
209+
},
210+
);
211+
expect(report.failures).toBe(0);
212+
expect(fetchImpl).not.toHaveBeenCalled();
213+
expect(capture.stdout.join('\n')).toContain('skipped under --dry-run');
214+
});
215+
});
216+
217+
describe('createDoctorCommand wiring', () => {
218+
it('exposes the doctor command name', () => {
219+
expect(createDoctorCommand().name()).toBe('doctor');
220+
});
221+
222+
it('--help describes the diagnostic', () => {
223+
expect(createDoctorCommand().helpInformation()).toContain('Diagnose');
224+
});
225+
});

0 commit comments

Comments
 (0)