Skip to content

Commit ce26763

Browse files
willwearingclaude
andauthored
feat: add package tests for shared, CLI, MCP; wire into CI (#103)
* feat: add package tests for shared, CLI, and MCP; wire into CI - shared: 14 unit tests covering validateParsedYaml, describeCourse, scaffoldCourseObject, scaffoldBrandObject, fillConceptInRaw, runQualityGate - CLI: 12 integration tests for offline commands (scaffold, validate, describe, fill, review, create-brand) using real files on disk - MCP: 10 unit tests covering all tool calls, tool registration, and auth enforcement. Refactored index.ts to export handleToolCall/TOOLS with require.main guard so tests can import without starting stdio. - CI: added shared/CLI/MCP test steps to ci-deploy.yml so these run on every push to main and every PR Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: repair pre-existing CLI test failures (spyOn console.error) bun 1.3.6 doesn't support spyOn for accessor properties on console. Replace spyOn(console, 'error') with manual save/restore in register.test.ts and login.test.ts. Also scope bun test to src/ to avoid running compiled dist/ test files. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: exclude __tests__ from shared tsconfig build tsc fails in CI because bun:test types aren't available during build. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: exclude __tests__ from MCP tsconfig build Same bun:test type issue as shared package. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d2fb9b5 commit ce26763

12 files changed

Lines changed: 649 additions & 45 deletions

File tree

.github/workflows/ci-deploy.yml

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ jobs:
2727
- name: Build shared package
2828
run: cd packages/shared && bun run build
2929

30+
- name: Run shared package tests
31+
run: cd packages/shared && bun test
32+
33+
- name: Build CLI
34+
run: cd packages/cli && bun run build
35+
36+
- name: Run CLI tests
37+
run: cd packages/cli && bun test
38+
39+
- name: Build MCP
40+
run: cd packages/mcp && bun run build
41+
42+
- name: Run MCP tests
43+
run: cd packages/mcp && bun test
44+
3045
- name: Generate Prisma client
3146
run: cd backend && bun x prisma generate
3247

packages/cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"scripts": {
1919
"build": "bun x tsc",
2020
"dev": "bun x tsc --watch",
21+
"test": "bun test src",
2122
"lint": "echo 'no lint configured yet'"
2223
},
2324
"dependencies": {

packages/cli/src/commands/__tests__/login.test.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ describe('graspful login', () => {
1212
let existsSyncSpy: ReturnType<typeof spyOn>;
1313
let exitSpy: ReturnType<typeof spyOn>;
1414
let consoleLogSpy: ReturnType<typeof spyOn>;
15-
let consoleErrorSpy: ReturnType<typeof spyOn>;
15+
let originalConsoleError: typeof console.error;
16+
const errorCalls: unknown[][] = [];
1617

1718
beforeEach(() => {
1819
originalFetch = globalThis.fetch;
@@ -21,7 +22,9 @@ describe('graspful login', () => {
2122
existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
2223
exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
2324
consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
24-
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {});
25+
originalConsoleError = console.error;
26+
errorCalls.length = 0;
27+
console.error = (...args: unknown[]) => { errorCalls.push(args); };
2528
});
2629

2730
afterEach(() => {
@@ -31,7 +34,7 @@ describe('graspful login', () => {
3134
existsSyncSpy.mockRestore();
3235
exitSpy.mockRestore();
3336
consoleLogSpy.mockRestore();
34-
consoleErrorSpy.mockRestore();
37+
console.error = originalConsoleError;
3538
});
3639

3740
it('saves API key credentials when token starts with gsk_', async () => {
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
2+
import { execSync } from 'child_process';
3+
import * as fs from 'fs';
4+
import * as path from 'path';
5+
import * as os from 'os';
6+
import * as yaml from 'js-yaml';
7+
8+
const CLI_CWD = path.resolve(__dirname, '..', '..', '..');
9+
const CLI_BIN = `node dist/index.js`;
10+
11+
function run(args: string, opts?: { cwd?: string }): string {
12+
try {
13+
return execSync(`${CLI_BIN} ${args}`, {
14+
cwd: opts?.cwd ?? CLI_CWD,
15+
encoding: 'utf-8',
16+
env: { ...process.env, NODE_ENV: 'test' },
17+
});
18+
} catch (e: any) {
19+
const msg = [e.stdout, e.stderr].filter(Boolean).join('\n');
20+
throw new Error(`CLI exited with code ${e.status}:\n${msg}`);
21+
}
22+
}
23+
24+
describe('offline CLI commands', () => {
25+
let tmpdir: string;
26+
27+
beforeEach(() => {
28+
tmpdir = fs.mkdtempSync(path.join(os.tmpdir(), 'graspful-test-'));
29+
});
30+
31+
afterEach(() => {
32+
fs.rmSync(tmpdir, { recursive: true, force: true });
33+
});
34+
35+
// ── create course ─────────────────────────────────────────────────────
36+
37+
describe('graspful create course', () => {
38+
it('scaffolds a valid YAML file with correct course id', () => {
39+
const outFile = path.join(tmpdir, 'course.yaml');
40+
run(`create course --topic "Test Course" --hours 5 -o ${outFile}`);
41+
42+
expect(fs.existsSync(outFile)).toBe(true);
43+
44+
const parsed = yaml.load(fs.readFileSync(outFile, 'utf-8')) as any;
45+
expect(parsed.course).toBeDefined();
46+
expect(parsed.course.id).toBe('test-course');
47+
expect(parsed.course.name).toBe('Test Course');
48+
expect(parsed.course.estimatedHours).toBe(5);
49+
expect(parsed.sections).toBeInstanceOf(Array);
50+
expect(parsed.concepts).toBeInstanceOf(Array);
51+
expect(parsed.concepts.length).toBeGreaterThan(0);
52+
});
53+
54+
it('outputs to stdout when no -o flag is given', () => {
55+
const stdout = run('create course --topic "Stdout Test" --hours 3');
56+
const parsed = yaml.load(stdout) as any;
57+
expect(parsed.course.id).toBe('stdout-test');
58+
});
59+
});
60+
61+
// ── create brand ──────────────────────────────────────────────────────
62+
63+
describe('graspful create brand', () => {
64+
it('scaffolds a valid brand YAML file', () => {
65+
const outFile = path.join(tmpdir, 'brand.yaml');
66+
run(`create brand --niche tech --name "Tech Academy" -o ${outFile}`);
67+
68+
expect(fs.existsSync(outFile)).toBe(true);
69+
70+
const parsed = yaml.load(fs.readFileSync(outFile, 'utf-8')) as any;
71+
expect(parsed.brand).toBeDefined();
72+
expect(parsed.brand.name).toBe('Tech Academy');
73+
expect(parsed.brand.domain).toContain('tech-academy');
74+
expect(parsed.theme).toBeDefined();
75+
expect(parsed.landing).toBeDefined();
76+
expect(parsed.landing.hero).toBeDefined();
77+
});
78+
});
79+
80+
// ── validate ──────────────────────────────────────────────────────────
81+
82+
describe('graspful validate', () => {
83+
it('passes validation for a scaffolded course file', () => {
84+
const courseFile = path.join(tmpdir, 'course.yaml');
85+
run(`create course --topic "Validation Test" --hours 4 -o ${courseFile}`);
86+
87+
const output = run(`validate ${courseFile}`);
88+
expect(output).toContain('PASS');
89+
});
90+
91+
it('exits non-zero for invalid YAML', () => {
92+
const badFile = path.join(tmpdir, 'bad.yaml');
93+
fs.writeFileSync(badFile, 'not: a: valid: course');
94+
95+
expect(() => run(`validate ${badFile}`)).toThrow();
96+
});
97+
});
98+
99+
// ── describe ──────────────────────────────────────────────────────────
100+
101+
describe('graspful describe', () => {
102+
it('shows concept count for a scaffolded course', () => {
103+
const courseFile = path.join(tmpdir, 'course.yaml');
104+
run(`create course --topic "Describe Test" --hours 6 -o ${courseFile}`);
105+
106+
const output = run(`describe ${courseFile}`);
107+
expect(output).toContain('Concepts:');
108+
expect(output).toContain('KPs:');
109+
});
110+
111+
it('returns JSON when --format json is used', () => {
112+
const courseFile = path.join(tmpdir, 'course.yaml');
113+
run(`create course --topic "JSON Describe" --hours 2 -o ${courseFile}`);
114+
115+
const output = run(`--format json describe ${courseFile}`);
116+
const parsed = JSON.parse(output);
117+
expect(parsed.concepts).toBeDefined();
118+
expect(typeof parsed.concepts).toBe('number');
119+
});
120+
});
121+
122+
// ── fill concept ──────────────────────────────────────────────────────
123+
124+
describe('graspful fill concept', () => {
125+
it('adds KP stubs to a concept and updates the file', () => {
126+
const courseFile = path.join(tmpdir, 'course.yaml');
127+
run(`create course --topic "Fill Test" --hours 3 -o ${courseFile}`);
128+
129+
// The scaffold creates a concept with id "{slug}-intro"
130+
const conceptId = 'fill-test-intro';
131+
run(`fill concept ${courseFile} ${conceptId}`);
132+
133+
const updated = yaml.load(fs.readFileSync(courseFile, 'utf-8')) as any;
134+
const concept = updated.concepts.find((c: any) => c.id === conceptId);
135+
expect(concept).toBeDefined();
136+
expect(concept.knowledgePoints.length).toBeGreaterThan(0);
137+
expect(concept.knowledgePoints[0].problems.length).toBeGreaterThan(0);
138+
});
139+
140+
it('respects --kps and --problems flags', () => {
141+
const courseFile = path.join(tmpdir, 'course.yaml');
142+
run(`create course --topic "KP Count" --hours 3 -o ${courseFile}`);
143+
144+
run(`fill concept ${courseFile} kp-count-intro --kps 3 --problems 2`);
145+
146+
const updated = yaml.load(fs.readFileSync(courseFile, 'utf-8')) as any;
147+
const concept = updated.concepts.find((c: any) => c.id === 'kp-count-intro');
148+
expect(concept.knowledgePoints).toHaveLength(3);
149+
expect(concept.knowledgePoints[0].problems).toHaveLength(2);
150+
});
151+
152+
it('errors when concept id does not exist', () => {
153+
const courseFile = path.join(tmpdir, 'course.yaml');
154+
run(`create course --topic "Missing Concept" --hours 3 -o ${courseFile}`);
155+
156+
expect(() => run(`fill concept ${courseFile} nonexistent-id`)).toThrow();
157+
});
158+
});
159+
160+
// ── review ────────────────────────────────────────────────────────────
161+
162+
describe('graspful review', () => {
163+
it('outputs a score line for a filled course', () => {
164+
const courseFile = path.join(tmpdir, 'course.yaml');
165+
run(`create course --topic "Review Test" --hours 3 -o ${courseFile}`);
166+
run(`fill concept ${courseFile} review-test-intro`);
167+
168+
// Review may pass or fail, but it should always output a score
169+
let output: string;
170+
try {
171+
output = run(`review ${courseFile}`);
172+
} catch (e: any) {
173+
// review exits non-zero on failure — grab stdout from the error
174+
output = e.message;
175+
}
176+
expect(output).toContain('Score:');
177+
});
178+
179+
it('returns structured JSON with --format json', () => {
180+
const courseFile = path.join(tmpdir, 'course.yaml');
181+
run(`create course --topic "JSON Review" --hours 3 -o ${courseFile}`);
182+
run(`fill concept ${courseFile} json-review-intro`);
183+
184+
let output: string;
185+
try {
186+
output = run(`--format json review ${courseFile}`);
187+
} catch (e: any) {
188+
// Even on failure the JSON is in stdout
189+
output = e.message;
190+
}
191+
192+
// Extract JSON from the output (may be wrapped in error message)
193+
const jsonMatch = output.match(/\{[\s\S]*\}/);
194+
expect(jsonMatch).not.toBeNull();
195+
const parsed = JSON.parse(jsonMatch![0]);
196+
expect(parsed.score).toBeDefined();
197+
expect(typeof parsed.passed).toBe('boolean');
198+
});
199+
});
200+
});

packages/cli/src/commands/__tests__/register.test.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ describe('graspful register', () => {
1515
let existsSyncSpy: ReturnType<typeof spyOn>;
1616
let exitSpy: ReturnType<typeof spyOn>;
1717
let consoleLogSpy: ReturnType<typeof spyOn>;
18-
let consoleErrorSpy: ReturnType<typeof spyOn>;
18+
let originalConsoleError: typeof console.error;
19+
const errorCalls: unknown[][] = [];
1920

2021
beforeEach(() => {
2122
originalFetch = globalThis.fetch;
@@ -24,7 +25,9 @@ describe('graspful register', () => {
2425
existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
2526
exitSpy = spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
2627
consoleLogSpy = spyOn(console, 'log').mockImplementation(() => {});
27-
consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {});
28+
originalConsoleError = console.error;
29+
errorCalls.length = 0;
30+
console.error = (...args: unknown[]) => { errorCalls.push(args); };
2831
});
2932

3033
afterEach(() => {
@@ -34,7 +37,7 @@ describe('graspful register', () => {
3437
existsSyncSpy.mockRestore();
3538
exitSpy.mockRestore();
3639
consoleLogSpy.mockRestore();
37-
consoleErrorSpy.mockRestore();
40+
console.error = originalConsoleError;
3841
});
3942

4043
it('saves API key credentials after browser sign-up completes', async () => {
@@ -149,7 +152,7 @@ describe('graspful register', () => {
149152
}
150153

151154
expect(exitSpy).toHaveBeenCalledWith(1);
152-
const errorOutput = consoleErrorSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
155+
const errorOutput = errorCalls.map((c) => c[0]).join('\n');
153156
expect(errorOutput).toContain('Sign-up disabled');
154157
});
155158

@@ -177,7 +180,7 @@ describe('graspful register', () => {
177180
}
178181

179182
expect(exitSpy).toHaveBeenCalledWith(1);
180-
const errorOutput = consoleErrorSpy.mock.calls.map((c: any[]) => c[0]).join('\n');
183+
const errorOutput = errorCalls.map((c) => c[0]).join('\n');
181184
expect(errorOutput).toContain('Could not reach the API');
182185
});
183186
});

packages/mcp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"files": ["dist"],
1717
"scripts": {
1818
"build": "bun x tsc",
19-
"dev": "bun x tsc --watch"
19+
"dev": "bun x tsc --watch",
20+
"test": "bun test src/__tests__"
2021
},
2122
"dependencies": {
2223
"@graspful/shared": "workspace:*",

0 commit comments

Comments
 (0)