@shaykec/claude-teach 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -3
- package/src/cli.e2e.helpers.js +289 -0
- package/src/cli.e2e.test.js +962 -0
- package/src/cli.js +206 -2
|
@@ -0,0 +1,962 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Level 1 E2E tests — run the claude-teach CLI binary and assert on output.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest';
|
|
6
|
+
import { spawn } from 'child_process';
|
|
7
|
+
import { existsSync, mkdtempSync, rmSync } from 'fs';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
import { tmpdir } from 'os';
|
|
10
|
+
import yaml from 'js-yaml';
|
|
11
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
12
|
+
import {
|
|
13
|
+
runCli,
|
|
14
|
+
runNpx,
|
|
15
|
+
stripAnsi,
|
|
16
|
+
createTempHome,
|
|
17
|
+
createTestPack,
|
|
18
|
+
backupProgress,
|
|
19
|
+
restoreProgress,
|
|
20
|
+
seedProgress,
|
|
21
|
+
PROJECT_ROOT,
|
|
22
|
+
} from './cli.e2e.helpers.js';
|
|
23
|
+
|
|
24
|
+
// =====================================================================
|
|
25
|
+
// Group A — Learning commands (read-only, no isolation needed)
|
|
26
|
+
// =====================================================================
|
|
27
|
+
|
|
28
|
+
describe('CLI E2E: Learning commands', () => {
|
|
29
|
+
describe('list', () => {
|
|
30
|
+
it('lists available modules with count', () => {
|
|
31
|
+
const { stdout } = runCli(['list']);
|
|
32
|
+
const clean = stripAnsi(stdout);
|
|
33
|
+
expect(clean).toContain('Available Modules');
|
|
34
|
+
expect(clean).toContain('git');
|
|
35
|
+
expect(clean).toContain('hooks');
|
|
36
|
+
expect(clean).toContain('daily-workflow');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('filters by difficulty beginner', () => {
|
|
40
|
+
const { stdout } = runCli(['list', '--difficulty', 'beginner']);
|
|
41
|
+
const clean = stripAnsi(stdout);
|
|
42
|
+
expect(clean).toContain('git');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('shows no modules for non-matching difficulty', () => {
|
|
46
|
+
const { stdout } = runCli(['list', '--difficulty', 'nonexistent-level']);
|
|
47
|
+
const clean = stripAnsi(stdout);
|
|
48
|
+
expect(clean).toContain('No modules found');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('filters by category', () => {
|
|
52
|
+
const { stdout } = runCli(['list', '--category', 'developer-skills']);
|
|
53
|
+
const clean = stripAnsi(stdout);
|
|
54
|
+
expect(clean).toContain('git');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('filters by tag', () => {
|
|
58
|
+
const { stdout } = runCli(['list', '--tag', 'git']);
|
|
59
|
+
const clean = stripAnsi(stdout);
|
|
60
|
+
expect(clean).toContain('git');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('filters by source', () => {
|
|
64
|
+
const { stdout } = runCli(['list', '--source', 'built-in']);
|
|
65
|
+
const clean = stripAnsi(stdout);
|
|
66
|
+
expect(clean).toContain('Available Modules');
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe('get', () => {
|
|
71
|
+
it('outputs walkthrough content for git module', () => {
|
|
72
|
+
const { stdout, exitCode } = runCli(['get', 'git']);
|
|
73
|
+
expect(exitCode).toBe(0);
|
|
74
|
+
// walkthrough.md exists for git
|
|
75
|
+
expect(stdout.length).toBeGreaterThan(100);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('outputs quick reference for git module', () => {
|
|
79
|
+
const { stdout, exitCode } = runCli(['get', 'git', '--quick']);
|
|
80
|
+
expect(exitCode).toBe(0);
|
|
81
|
+
expect(stdout.length).toBeGreaterThan(50);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('outputs quiz for git module', () => {
|
|
85
|
+
const { stdout, exitCode } = runCli(['get', 'git', '--quiz-only']);
|
|
86
|
+
expect(exitCode).toBe(0);
|
|
87
|
+
expect(stdout.length).toBeGreaterThan(50);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('shows no progress for unstudied module', () => {
|
|
91
|
+
const { stdout, exitCode } = runCli(['get', 'git', '--status']);
|
|
92
|
+
const clean = stripAnsi(stdout);
|
|
93
|
+
expect(exitCode).toBe(0);
|
|
94
|
+
expect(clean).toContain('No progress recorded');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('fails for nonexistent module', () => {
|
|
98
|
+
const { stderr, exitCode } = runCli(['get', 'nonexistent-module-xyz'], { expectError: true });
|
|
99
|
+
expect(exitCode).toBe(1);
|
|
100
|
+
expect(stderr).toContain('not found');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('fails for quick ref on module without it', () => {
|
|
104
|
+
const { stderr, exitCode } = runCli(['get', 'hooks', '--quick'], { expectError: true });
|
|
105
|
+
expect(exitCode).toBe(1);
|
|
106
|
+
expect(stderr).toContain('No quick reference');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('outputs content.md for module without walkthrough', () => {
|
|
110
|
+
const { stdout, exitCode } = runCli(['get', 'hooks']);
|
|
111
|
+
expect(exitCode).toBe(0);
|
|
112
|
+
expect(stdout.length).toBeGreaterThan(50);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('stats', () => {
|
|
117
|
+
it('outputs dashboard with default belt', () => {
|
|
118
|
+
const { stdout, exitCode } = runCli(['stats']);
|
|
119
|
+
const clean = stripAnsi(stdout);
|
|
120
|
+
expect(exitCode).toBe(0);
|
|
121
|
+
expect(clean).toContain('ClaudeTeach Dashboard');
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe('level-up', () => {
|
|
126
|
+
it('outputs belt info and recommendations', () => {
|
|
127
|
+
const { stdout, exitCode } = runCli(['level-up']);
|
|
128
|
+
const clean = stripAnsi(stdout);
|
|
129
|
+
expect(exitCode).toBe(0);
|
|
130
|
+
// Should mention at least one thing about the belt system or recommendations
|
|
131
|
+
expect(clean.length).toBeGreaterThan(20);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('global flags', () => {
|
|
136
|
+
it('outputs version matching semver', () => {
|
|
137
|
+
const { stdout, exitCode } = runCli(['--version']);
|
|
138
|
+
expect(exitCode).toBe(0);
|
|
139
|
+
expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('outputs help text', () => {
|
|
143
|
+
const { stdout, exitCode } = runCli(['--help']);
|
|
144
|
+
expect(exitCode).toBe(0);
|
|
145
|
+
expect(stdout).toContain('Socratic AI teaching platform');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
// =====================================================================
|
|
151
|
+
// Group B — Progress commands (backup/restore .teach-progress.yaml)
|
|
152
|
+
// =====================================================================
|
|
153
|
+
|
|
154
|
+
describe('CLI E2E: Progress commands', () => {
|
|
155
|
+
let progressBackup;
|
|
156
|
+
|
|
157
|
+
beforeEach(() => {
|
|
158
|
+
progressBackup = backupProgress();
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
afterEach(() => {
|
|
162
|
+
restoreProgress(progressBackup);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('resets progress for a module', () => {
|
|
166
|
+
// Seed some progress first
|
|
167
|
+
seedProgress({
|
|
168
|
+
user: { xp: 50, belt: 'yellow', modules_completed: 0 },
|
|
169
|
+
modules: { git: { status: 'in-progress', xp_earned: 50 } },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const { stdout, exitCode } = runCli(['get', 'git', '--reset']);
|
|
173
|
+
const clean = stripAnsi(stdout);
|
|
174
|
+
expect(exitCode).toBe(0);
|
|
175
|
+
expect(clean).toContain('Progress reset');
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('shows status for module with progress', () => {
|
|
179
|
+
seedProgress({
|
|
180
|
+
user: { xp: 100, belt: 'yellow', modules_completed: 0 },
|
|
181
|
+
modules: { git: { status: 'in-progress', xp_earned: 100, walkthrough_step: 3 } },
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const { stdout, exitCode } = runCli(['get', 'git', '--status']);
|
|
185
|
+
expect(exitCode).toBe(0);
|
|
186
|
+
const parsed = JSON.parse(stdout);
|
|
187
|
+
expect(parsed.status).toBe('in-progress');
|
|
188
|
+
expect(parsed.xp_earned).toBe(100);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('stats shows seeded XP and belt', () => {
|
|
192
|
+
seedProgress({
|
|
193
|
+
user: { xp: 200, belt: 'green', modules_completed: 1 },
|
|
194
|
+
modules: { git: { status: 'completed', xp_earned: 200 } },
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const { stdout, exitCode } = runCli(['stats']);
|
|
198
|
+
const clean = stripAnsi(stdout);
|
|
199
|
+
expect(exitCode).toBe(0);
|
|
200
|
+
expect(clean).toContain('200');
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// =====================================================================
|
|
205
|
+
// Group C — Authoring commands (temp dir as cwd)
|
|
206
|
+
// =====================================================================
|
|
207
|
+
|
|
208
|
+
describe('CLI E2E: Authoring commands', () => {
|
|
209
|
+
let tmpDir;
|
|
210
|
+
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
tmpDir = mkdtempSync(join(tmpdir(), 'teach-e2e-author-'));
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
afterEach(() => {
|
|
216
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
describe('author init', () => {
|
|
220
|
+
it('scaffolds a new pack', () => {
|
|
221
|
+
const { stdout, exitCode } = runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
|
|
222
|
+
const clean = stripAnsi(stdout);
|
|
223
|
+
expect(exitCode).toBe(0);
|
|
224
|
+
expect(clean).toContain('scaffolded');
|
|
225
|
+
|
|
226
|
+
// Verify file structure
|
|
227
|
+
expect(existsSync(join(tmpDir, 'test-pack', 'pack.yaml'))).toBe(true);
|
|
228
|
+
expect(existsSync(join(tmpDir, 'test-pack', 'modules'))).toBe(true);
|
|
229
|
+
expect(existsSync(join(tmpDir, 'test-pack', 'README.md'))).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('fails if directory already exists', () => {
|
|
233
|
+
runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
|
|
234
|
+
const { stderr, exitCode } = runCli(['author', 'init', 'test-pack'], {
|
|
235
|
+
cwd: tmpDir,
|
|
236
|
+
expectError: true,
|
|
237
|
+
});
|
|
238
|
+
expect(exitCode).toBe(1);
|
|
239
|
+
expect(stderr).toContain('already exists');
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
describe('author add', () => {
|
|
244
|
+
it('adds a module to a pack', () => {
|
|
245
|
+
runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
|
|
246
|
+
const { stdout, exitCode } = runCli(['author', 'add', 'test-pack/my-module'], { cwd: tmpDir });
|
|
247
|
+
const clean = stripAnsi(stdout);
|
|
248
|
+
expect(exitCode).toBe(0);
|
|
249
|
+
expect(clean).toContain('my-module');
|
|
250
|
+
|
|
251
|
+
const moduleDir = join(tmpDir, 'test-pack', 'modules', 'my-module');
|
|
252
|
+
expect(existsSync(join(moduleDir, 'module.yaml'))).toBe(true);
|
|
253
|
+
expect(existsSync(join(moduleDir, 'content.md'))).toBe(true);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it('fails for nonexistent pack', () => {
|
|
257
|
+
const { stderr, exitCode } = runCli(['author', 'add', 'no-pack/mod'], {
|
|
258
|
+
cwd: tmpDir,
|
|
259
|
+
expectError: true,
|
|
260
|
+
});
|
|
261
|
+
expect(exitCode).toBe(1);
|
|
262
|
+
expect(stderr).toContain('No pack.yaml');
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('fails for duplicate module', () => {
|
|
266
|
+
runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
|
|
267
|
+
runCli(['author', 'add', 'test-pack/my-module'], { cwd: tmpDir });
|
|
268
|
+
const { stderr, exitCode } = runCli(['author', 'add', 'test-pack/my-module'], {
|
|
269
|
+
cwd: tmpDir,
|
|
270
|
+
expectError: true,
|
|
271
|
+
});
|
|
272
|
+
expect(exitCode).toBe(1);
|
|
273
|
+
expect(stderr).toContain('already exists');
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe('author validate', () => {
|
|
278
|
+
it('validates a correct pack', () => {
|
|
279
|
+
runCli(['author', 'init', 'test-pack'], { cwd: tmpDir });
|
|
280
|
+
runCli(['author', 'add', 'test-pack/my-module'], { cwd: tmpDir });
|
|
281
|
+
const { stdout, exitCode } = runCli(['author', 'validate', 'test-pack'], { cwd: tmpDir });
|
|
282
|
+
const clean = stripAnsi(stdout);
|
|
283
|
+
expect(exitCode).toBe(0);
|
|
284
|
+
expect(clean).toContain('Pack is valid');
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it('fails for missing pack.yaml', () => {
|
|
288
|
+
const { stdout, exitCode } = runCli(['author', 'validate', '.'], {
|
|
289
|
+
cwd: tmpDir,
|
|
290
|
+
expectError: true,
|
|
291
|
+
});
|
|
292
|
+
const clean = stripAnsi(stdout + '');
|
|
293
|
+
expect(exitCode).toBe(1);
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('author preview', () => {
|
|
298
|
+
let tempHome;
|
|
299
|
+
|
|
300
|
+
beforeEach(() => {
|
|
301
|
+
tempHome = createTempHome();
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
afterEach(() => {
|
|
305
|
+
tempHome.cleanup();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
it('previews a valid module', () => {
|
|
309
|
+
// Create a module directory with module.yaml
|
|
310
|
+
const packDir = createTestPack(tmpDir, 'test-pack', ['my-mod']);
|
|
311
|
+
const modulePath = join(packDir, 'modules', 'my-mod');
|
|
312
|
+
|
|
313
|
+
const { stdout, exitCode } = runCli(['author', 'preview', modulePath], {
|
|
314
|
+
cwd: tmpDir,
|
|
315
|
+
env: tempHome.env,
|
|
316
|
+
});
|
|
317
|
+
const clean = stripAnsi(stdout);
|
|
318
|
+
expect(exitCode).toBe(0);
|
|
319
|
+
expect(clean).toContain('Previewing');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('fails for path without module.yaml', () => {
|
|
323
|
+
const { stderr, exitCode } = runCli(['author', 'preview', tmpDir], {
|
|
324
|
+
cwd: tmpDir,
|
|
325
|
+
env: tempHome.env,
|
|
326
|
+
expectError: true,
|
|
327
|
+
});
|
|
328
|
+
expect(exitCode).toBe(1);
|
|
329
|
+
expect(stderr).toContain('No module.yaml');
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
// =====================================================================
|
|
335
|
+
// Group D — Marketplace commands (HOME isolation)
|
|
336
|
+
// =====================================================================
|
|
337
|
+
|
|
338
|
+
describe('CLI E2E: Marketplace commands', () => {
|
|
339
|
+
let tempHome;
|
|
340
|
+
|
|
341
|
+
beforeEach(() => {
|
|
342
|
+
tempHome = createTempHome();
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
afterEach(() => {
|
|
346
|
+
tempHome.cleanup();
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
describe('packs', () => {
|
|
350
|
+
it('shows no packs when none installed', () => {
|
|
351
|
+
const { stdout, exitCode } = runCli(['packs'], { env: tempHome.env });
|
|
352
|
+
const clean = stripAnsi(stdout);
|
|
353
|
+
expect(exitCode).toBe(0);
|
|
354
|
+
expect(clean).toContain('No module packs installed');
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
describe('install error handling', () => {
|
|
359
|
+
it('fails for invalid URL format', () => {
|
|
360
|
+
const { stderr, exitCode } = runCli(['install', 'not-a-valid!!!url'], {
|
|
361
|
+
env: tempHome.env,
|
|
362
|
+
expectError: true,
|
|
363
|
+
});
|
|
364
|
+
expect(exitCode).toBe(1);
|
|
365
|
+
expect(stderr).toContain('Invalid URL');
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it('fails for unreachable git repo', () => {
|
|
369
|
+
const { stderr, exitCode } = runCli(
|
|
370
|
+
['install', 'https://github.com/nonexistent-user-xyz/nonexistent-repo-xyz'],
|
|
371
|
+
{ env: tempHome.env, expectError: true, timeout: 20000 },
|
|
372
|
+
);
|
|
373
|
+
expect(exitCode).toBe(1);
|
|
374
|
+
expect(stderr).toContain('Error');
|
|
375
|
+
});
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
describe('packs + remove (with manually placed pack)', () => {
|
|
379
|
+
/**
|
|
380
|
+
* Simulate a previously installed pack by writing files directly
|
|
381
|
+
* into the HOME/.claude-teach/modules/ directory.
|
|
382
|
+
*/
|
|
383
|
+
function placePack(homePath, packName) {
|
|
384
|
+
const packDir = join(homePath, '.claude-teach', 'modules', packName);
|
|
385
|
+
const modulesDir = join(packDir, 'modules', 'test-mod');
|
|
386
|
+
mkdirSync(modulesDir, { recursive: true });
|
|
387
|
+
|
|
388
|
+
writeFileSync(join(packDir, 'pack.yaml'), yaml.dump({
|
|
389
|
+
name: packName,
|
|
390
|
+
author: 'test',
|
|
391
|
+
description: 'A test pack',
|
|
392
|
+
version: '1.0.0',
|
|
393
|
+
modules: ['test-mod'],
|
|
394
|
+
}), 'utf-8');
|
|
395
|
+
|
|
396
|
+
writeFileSync(join(modulesDir, 'module.yaml'), yaml.dump({
|
|
397
|
+
slug: 'test-mod',
|
|
398
|
+
title: 'Test Module',
|
|
399
|
+
version: '1.0.0',
|
|
400
|
+
difficulty: 'beginner',
|
|
401
|
+
xp: { read: 10 },
|
|
402
|
+
}), 'utf-8');
|
|
403
|
+
|
|
404
|
+
writeFileSync(join(modulesDir, 'content.md'), '# Test\n', 'utf-8');
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
it('lists an installed pack', () => {
|
|
408
|
+
placePack(tempHome.path, 'test-pack');
|
|
409
|
+
|
|
410
|
+
const { stdout, exitCode } = runCli(['packs'], { env: tempHome.env });
|
|
411
|
+
const clean = stripAnsi(stdout);
|
|
412
|
+
expect(exitCode).toBe(0);
|
|
413
|
+
expect(clean).toContain('test-pack');
|
|
414
|
+
expect(clean).toContain('Installed Packs');
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('removes an installed pack', () => {
|
|
418
|
+
placePack(tempHome.path, 'test-pack');
|
|
419
|
+
|
|
420
|
+
const { stdout, exitCode } = runCli(['remove', 'test-pack'], { env: tempHome.env });
|
|
421
|
+
const clean = stripAnsi(stdout);
|
|
422
|
+
expect(exitCode).toBe(0);
|
|
423
|
+
expect(clean).toContain('removed');
|
|
424
|
+
|
|
425
|
+
const packDir = join(tempHome.path, '.claude-teach', 'modules', 'test-pack');
|
|
426
|
+
expect(existsSync(packDir)).toBe(false);
|
|
427
|
+
});
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
describe('remove edge cases', () => {
|
|
431
|
+
it('fails for nonexistent pack', () => {
|
|
432
|
+
const { stderr, exitCode } = runCli(['remove', 'nonexistent-pack'], {
|
|
433
|
+
env: tempHome.env,
|
|
434
|
+
expectError: true,
|
|
435
|
+
});
|
|
436
|
+
expect(exitCode).toBe(1);
|
|
437
|
+
expect(stderr).toContain('not installed');
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it('fails for reserved "local" name', () => {
|
|
441
|
+
const { stderr, exitCode } = runCli(['remove', 'local'], {
|
|
442
|
+
env: tempHome.env,
|
|
443
|
+
expectError: true,
|
|
444
|
+
});
|
|
445
|
+
expect(exitCode).toBe(1);
|
|
446
|
+
expect(stderr).toContain('cannot be removed');
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
describe('update', () => {
|
|
451
|
+
it('fails for nonexistent pack', () => {
|
|
452
|
+
const { stderr, exitCode } = runCli(['update', 'nonexistent-pack'], {
|
|
453
|
+
env: tempHome.env,
|
|
454
|
+
expectError: true,
|
|
455
|
+
});
|
|
456
|
+
expect(exitCode).toBe(1);
|
|
457
|
+
expect(stderr).toContain('not installed');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('fails for reserved "local" name', () => {
|
|
461
|
+
const { stderr, exitCode } = runCli(['update', 'local'], {
|
|
462
|
+
env: tempHome.env,
|
|
463
|
+
expectError: true,
|
|
464
|
+
});
|
|
465
|
+
expect(exitCode).toBe(1);
|
|
466
|
+
expect(stderr).toContain('cannot be updated');
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
describe('search', () => {
|
|
471
|
+
it('handles no results gracefully', () => {
|
|
472
|
+
const { stdout, exitCode } = runCli(['search', 'xyznonexistent999'], {
|
|
473
|
+
env: tempHome.env,
|
|
474
|
+
timeout: 15000,
|
|
475
|
+
});
|
|
476
|
+
const clean = stripAnsi(stdout);
|
|
477
|
+
expect(exitCode).toBe(0);
|
|
478
|
+
expect(clean).toContain('No packs found');
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
describe('registry', () => {
|
|
483
|
+
it('lists default registry', () => {
|
|
484
|
+
const { stdout, exitCode } = runCli(['registry', 'list'], { env: tempHome.env });
|
|
485
|
+
const clean = stripAnsi(stdout);
|
|
486
|
+
expect(exitCode).toBe(0);
|
|
487
|
+
expect(clean).toContain('default');
|
|
488
|
+
expect(clean).toContain('Configured Registries');
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
it('adds a registry', () => {
|
|
492
|
+
const { stdout, exitCode } = runCli(['registry', 'add', 'user/test-registry'], {
|
|
493
|
+
env: tempHome.env,
|
|
494
|
+
});
|
|
495
|
+
const clean = stripAnsi(stdout);
|
|
496
|
+
expect(exitCode).toBe(0);
|
|
497
|
+
expect(clean).toContain('added');
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
it('lists added registry', () => {
|
|
501
|
+
runCli(['registry', 'add', 'user/test-registry'], { env: tempHome.env });
|
|
502
|
+
|
|
503
|
+
const { stdout, exitCode } = runCli(['registry', 'list'], { env: tempHome.env });
|
|
504
|
+
const clean = stripAnsi(stdout);
|
|
505
|
+
expect(exitCode).toBe(0);
|
|
506
|
+
expect(clean).toContain('test-registry');
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
it('removes a registry', () => {
|
|
510
|
+
runCli(['registry', 'add', 'user/test-registry'], { env: tempHome.env });
|
|
511
|
+
|
|
512
|
+
const { stdout, exitCode } = runCli(['registry', 'remove', 'test-registry'], {
|
|
513
|
+
env: tempHome.env,
|
|
514
|
+
});
|
|
515
|
+
const clean = stripAnsi(stdout);
|
|
516
|
+
expect(exitCode).toBe(0);
|
|
517
|
+
expect(clean).toContain('removed');
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
it('fails to remove nonexistent registry', () => {
|
|
521
|
+
const { stderr, exitCode } = runCli(['registry', 'remove', 'nonexistent'], {
|
|
522
|
+
env: tempHome.env,
|
|
523
|
+
expectError: true,
|
|
524
|
+
});
|
|
525
|
+
expect(exitCode).toBe(1);
|
|
526
|
+
expect(stderr).toContain('not found');
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
it('fails to add duplicate registry', () => {
|
|
530
|
+
runCli(['registry', 'add', 'user/test-registry'], { env: tempHome.env });
|
|
531
|
+
const { stderr, exitCode } = runCli(['registry', 'add', 'user/test-registry'], {
|
|
532
|
+
env: tempHome.env,
|
|
533
|
+
expectError: true,
|
|
534
|
+
});
|
|
535
|
+
expect(exitCode).toBe(1);
|
|
536
|
+
expect(stderr).toContain('already configured');
|
|
537
|
+
});
|
|
538
|
+
});
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// =====================================================================
|
|
542
|
+
// Group E — Server command (spawn + kill)
|
|
543
|
+
// =====================================================================
|
|
544
|
+
|
|
545
|
+
describe('CLI E2E: Server command', () => {
|
|
546
|
+
it('serve starts and responds to /health', async () => {
|
|
547
|
+
const port = 13456 + Math.floor(Math.random() * 1000);
|
|
548
|
+
const child = spawn('node', [
|
|
549
|
+
join(PROJECT_ROOT, 'packages/core/src/cli.js'),
|
|
550
|
+
'serve',
|
|
551
|
+
'--port',
|
|
552
|
+
String(port),
|
|
553
|
+
], {
|
|
554
|
+
cwd: PROJECT_ROOT,
|
|
555
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
try {
|
|
559
|
+
// Wait for server to be ready (try hitting /health with retries)
|
|
560
|
+
let ready = false;
|
|
561
|
+
for (let i = 0; i < 20; i++) {
|
|
562
|
+
await new Promise(r => setTimeout(r, 500));
|
|
563
|
+
try {
|
|
564
|
+
const resp = await fetch(`http://localhost:${port}/health`);
|
|
565
|
+
if (resp.ok) {
|
|
566
|
+
ready = true;
|
|
567
|
+
break;
|
|
568
|
+
}
|
|
569
|
+
} catch {
|
|
570
|
+
// Server not ready yet
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
expect(ready).toBe(true);
|
|
575
|
+
|
|
576
|
+
// Verify health response
|
|
577
|
+
const resp = await fetch(`http://localhost:${port}/health`);
|
|
578
|
+
const data = await resp.json();
|
|
579
|
+
expect(data).toHaveProperty('status', 'ok');
|
|
580
|
+
} finally {
|
|
581
|
+
child.kill('SIGTERM');
|
|
582
|
+
await new Promise(resolve => child.on('close', resolve));
|
|
583
|
+
}
|
|
584
|
+
}, 15000);
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
// =====================================================================
|
|
588
|
+
// Group F — System commands
|
|
589
|
+
// =====================================================================
|
|
590
|
+
|
|
591
|
+
describe('CLI E2E: System commands', () => {
|
|
592
|
+
it('registry:build outputs rebuild summary', () => {
|
|
593
|
+
const { stdout, exitCode } = runCli(['registry:build']);
|
|
594
|
+
const clean = stripAnsi(stdout);
|
|
595
|
+
expect(exitCode).toBe(0);
|
|
596
|
+
expect(clean).toContain('Registry rebuilt');
|
|
597
|
+
expect(clean).toContain('built-in');
|
|
598
|
+
});
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
// =====================================================================
|
|
602
|
+
// Group G — npx commands (published package)
|
|
603
|
+
// =====================================================================
|
|
604
|
+
|
|
605
|
+
describe('CLI E2E: npx @shaykec/claude-teach', () => {
|
|
606
|
+
it('npx --version outputs semver', () => {
|
|
607
|
+
const { stdout, exitCode } = runNpx(['--version']);
|
|
608
|
+
expect(exitCode).toBe(0);
|
|
609
|
+
expect(stdout.trim()).toMatch(/^\d+\.\d+\.\d+$/);
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
it('npx list shows available modules', () => {
|
|
613
|
+
const { stdout, exitCode } = runNpx(['list']);
|
|
614
|
+
const clean = stripAnsi(stdout);
|
|
615
|
+
expect(exitCode).toBe(0);
|
|
616
|
+
expect(clean).toContain('Available Modules');
|
|
617
|
+
expect(clean).toContain('git');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it('npx stats shows dashboard', () => {
|
|
621
|
+
const { stdout, exitCode } = runNpx(['stats']);
|
|
622
|
+
const clean = stripAnsi(stdout);
|
|
623
|
+
expect(exitCode).toBe(0);
|
|
624
|
+
expect(clean).toContain('ClaudeTeach Dashboard');
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it('npx get git outputs module content', () => {
|
|
628
|
+
const { stdout, exitCode } = runNpx(['get', 'git']);
|
|
629
|
+
expect(exitCode).toBe(0);
|
|
630
|
+
expect(stdout.length).toBeGreaterThan(100);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
it('npx --help shows description', () => {
|
|
634
|
+
const { stdout, exitCode } = runNpx(['--help']);
|
|
635
|
+
expect(exitCode).toBe(0);
|
|
636
|
+
expect(stdout).toContain('Socratic AI teaching platform');
|
|
637
|
+
});
|
|
638
|
+
});
|
|
639
|
+
|
|
640
|
+
// =====================================================================
|
|
641
|
+
// Group H — Setup command (all options)
|
|
642
|
+
// =====================================================================
|
|
643
|
+
|
|
644
|
+
describe('CLI E2E: Setup command', () => {
|
|
645
|
+
it('setup --show-path outputs plugin path containing packages/plugin', () => {
|
|
646
|
+
const { stdout, exitCode } = runCli(['setup', '--show-path']);
|
|
647
|
+
expect(exitCode).toBe(0);
|
|
648
|
+
expect(stdout.trim()).toContain('packages/plugin');
|
|
649
|
+
});
|
|
650
|
+
|
|
651
|
+
it('setup --show-path outputs an absolute path', () => {
|
|
652
|
+
const { stdout, exitCode } = runCli(['setup', '--show-path']);
|
|
653
|
+
expect(exitCode).toBe(0);
|
|
654
|
+
expect(stdout.trim()).toMatch(/^\//); // starts with /
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
it('setup --show-path path actually has plugin.json', () => {
|
|
658
|
+
const { stdout, exitCode } = runCli(['setup', '--show-path']);
|
|
659
|
+
expect(exitCode).toBe(0);
|
|
660
|
+
const pluginDir = stdout.trim();
|
|
661
|
+
expect(existsSync(join(pluginDir, '.claude-plugin', 'plugin.json'))).toBe(true);
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
it('setup --help shows all options', () => {
|
|
665
|
+
const { stdout, exitCode } = runCli(['setup', '--help']);
|
|
666
|
+
expect(exitCode).toBe(0);
|
|
667
|
+
const clean = stripAnsi(stdout);
|
|
668
|
+
expect(clean).toContain('Check environment');
|
|
669
|
+
expect(clean).toContain('--show-path');
|
|
670
|
+
expect(clean).toContain('--extension');
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it('start --help shows command description and options', () => {
|
|
674
|
+
const { stdout, exitCode } = runCli(['start', '--help']);
|
|
675
|
+
expect(exitCode).toBe(0);
|
|
676
|
+
const clean = stripAnsi(stdout);
|
|
677
|
+
expect(clean).toContain('Launch Claude Code');
|
|
678
|
+
expect(clean).toContain('--no-server');
|
|
679
|
+
expect(clean).toContain('--port');
|
|
680
|
+
});
|
|
681
|
+
});
|
|
682
|
+
|
|
683
|
+
// =====================================================================
|
|
684
|
+
// Group I — Start command (spawn-based, no actual Claude)
|
|
685
|
+
// =====================================================================
|
|
686
|
+
|
|
687
|
+
describe('CLI E2E: Start command', () => {
|
|
688
|
+
it('start --no-server fails gracefully when claude is not on PATH', () => {
|
|
689
|
+
// Build a PATH that includes node but excludes claude
|
|
690
|
+
const nodeBinDir = join(process.execPath, '..');
|
|
691
|
+
const { exitCode, stderr, stdout } = runCli(['start', '--no-server'], {
|
|
692
|
+
env: { PATH: `${nodeBinDir}:/usr/bin:/bin` },
|
|
693
|
+
expectError: true,
|
|
694
|
+
timeout: 10000,
|
|
695
|
+
});
|
|
696
|
+
const output = stripAnsi(stdout + stderr);
|
|
697
|
+
// Should fail because claude is not found on this restricted PATH
|
|
698
|
+
expect(exitCode).not.toBe(0);
|
|
699
|
+
expect(output).toContain('Claude Code CLI not found');
|
|
700
|
+
});
|
|
701
|
+
|
|
702
|
+
it('start spawns bridge server on custom port', async () => {
|
|
703
|
+
const port = 14456 + Math.floor(Math.random() * 1000);
|
|
704
|
+
// Spawn start with a bogus claude path to test bridge startup
|
|
705
|
+
// Use a wrapper script that just exits so we can verify bridge started
|
|
706
|
+
const child = spawn('node', [
|
|
707
|
+
join(PROJECT_ROOT, 'packages/core/src/cli.js'),
|
|
708
|
+
'start',
|
|
709
|
+
'--port', String(port),
|
|
710
|
+
], {
|
|
711
|
+
cwd: PROJECT_ROOT,
|
|
712
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
713
|
+
});
|
|
714
|
+
|
|
715
|
+
let stdout = '';
|
|
716
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
717
|
+
|
|
718
|
+
try {
|
|
719
|
+
// Wait a bit for the bridge to start
|
|
720
|
+
let bridgeStarted = false;
|
|
721
|
+
for (let i = 0; i < 10; i++) {
|
|
722
|
+
await new Promise(r => setTimeout(r, 500));
|
|
723
|
+
try {
|
|
724
|
+
const resp = await fetch(`http://localhost:${port}/health`);
|
|
725
|
+
if (resp.ok) {
|
|
726
|
+
bridgeStarted = true;
|
|
727
|
+
break;
|
|
728
|
+
}
|
|
729
|
+
} catch { /* not ready */ }
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// If claude is installed, bridge should have started
|
|
733
|
+
// If claude is not installed, the process exits with error before bridge starts
|
|
734
|
+
// Either way, the test should not fail - just verify the behavior
|
|
735
|
+
const cleanOutput = stripAnsi(stdout);
|
|
736
|
+
if (bridgeStarted) {
|
|
737
|
+
const resp = await fetch(`http://localhost:${port}/health`);
|
|
738
|
+
const data = await resp.json();
|
|
739
|
+
expect(data).toHaveProperty('status', 'ok');
|
|
740
|
+
} else {
|
|
741
|
+
// Process exited because claude is not on PATH — valid behavior
|
|
742
|
+
expect(true).toBe(true);
|
|
743
|
+
}
|
|
744
|
+
} finally {
|
|
745
|
+
child.kill('SIGTERM');
|
|
746
|
+
await new Promise(resolve => child.on('close', resolve));
|
|
747
|
+
}
|
|
748
|
+
}, 15000);
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
// =====================================================================
|
|
752
|
+
// Group J — Bridge server /api/open-extension endpoint
|
|
753
|
+
// =====================================================================
|
|
754
|
+
|
|
755
|
+
describe('CLI E2E: Bridge API endpoints', () => {
|
|
756
|
+
let port;
|
|
757
|
+
let child;
|
|
758
|
+
|
|
759
|
+
beforeAll(async () => {
|
|
760
|
+
port = 15456 + Math.floor(Math.random() * 1000);
|
|
761
|
+
child = spawn('node', [
|
|
762
|
+
join(PROJECT_ROOT, 'packages/core/src/cli.js'),
|
|
763
|
+
'serve',
|
|
764
|
+
'--port', String(port),
|
|
765
|
+
], {
|
|
766
|
+
cwd: PROJECT_ROOT,
|
|
767
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// Wait for server to be ready
|
|
771
|
+
for (let i = 0; i < 20; i++) {
|
|
772
|
+
await new Promise(r => setTimeout(r, 500));
|
|
773
|
+
try {
|
|
774
|
+
const resp = await fetch(`http://localhost:${port}/health`);
|
|
775
|
+
if (resp.ok) break;
|
|
776
|
+
} catch { /* not ready */ }
|
|
777
|
+
}
|
|
778
|
+
}, 15000);
|
|
779
|
+
|
|
780
|
+
afterAll(async () => {
|
|
781
|
+
if (child) {
|
|
782
|
+
child.kill('SIGTERM');
|
|
783
|
+
await new Promise(resolve => child.on('close', resolve));
|
|
784
|
+
}
|
|
785
|
+
});
|
|
786
|
+
|
|
787
|
+
it('POST /api/open-extension returns ok with extension path', async () => {
|
|
788
|
+
const resp = await fetch(`http://localhost:${port}/api/open-extension`, {
|
|
789
|
+
method: 'POST',
|
|
790
|
+
});
|
|
791
|
+
const data = await resp.json();
|
|
792
|
+
expect(resp.status).toBe(200);
|
|
793
|
+
expect(data.ok).toBe(true);
|
|
794
|
+
expect(data.path).toContain('extension');
|
|
795
|
+
});
|
|
796
|
+
|
|
797
|
+
it('GET /health returns status ok and tier info', async () => {
|
|
798
|
+
const resp = await fetch(`http://localhost:${port}/health`);
|
|
799
|
+
const data = await resp.json();
|
|
800
|
+
expect(resp.status).toBe(200);
|
|
801
|
+
expect(data.status).toBe('ok');
|
|
802
|
+
expect(data).toHaveProperty('tier');
|
|
803
|
+
expect(data).toHaveProperty('uptime');
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
it('GET /api/tier returns tier info', async () => {
|
|
807
|
+
const resp = await fetch(`http://localhost:${port}/api/tier`);
|
|
808
|
+
const data = await resp.json();
|
|
809
|
+
expect(resp.status).toBe(200);
|
|
810
|
+
expect(data).toHaveProperty('tier');
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
it('GET /api/progress returns progress data', async () => {
|
|
814
|
+
const resp = await fetch(`http://localhost:${port}/api/progress`);
|
|
815
|
+
const data = await resp.json();
|
|
816
|
+
expect(resp.status).toBe(200);
|
|
817
|
+
// Default empty progress
|
|
818
|
+
expect(data).toHaveProperty('user');
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
it('GET /api/events returns empty events array', async () => {
|
|
822
|
+
const resp = await fetch(`http://localhost:${port}/api/events`);
|
|
823
|
+
const data = await resp.json();
|
|
824
|
+
expect(resp.status).toBe(200);
|
|
825
|
+
expect(data).toHaveProperty('events');
|
|
826
|
+
expect(Array.isArray(data.events)).toBe(true);
|
|
827
|
+
expect(data).toHaveProperty('count');
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
it('GET /api/templates returns templates list', async () => {
|
|
831
|
+
const resp = await fetch(`http://localhost:${port}/api/templates`);
|
|
832
|
+
const data = await resp.json();
|
|
833
|
+
expect(resp.status).toBe(200);
|
|
834
|
+
expect(data).toHaveProperty('templates');
|
|
835
|
+
expect(Array.isArray(data.templates)).toBe(true);
|
|
836
|
+
});
|
|
837
|
+
|
|
838
|
+
it('POST /api/event with invalid body returns 400', async () => {
|
|
839
|
+
const resp = await fetch(`http://localhost:${port}/api/event`, {
|
|
840
|
+
method: 'POST',
|
|
841
|
+
headers: { 'Content-Type': 'application/json' },
|
|
842
|
+
body: 'not-json',
|
|
843
|
+
});
|
|
844
|
+
expect(resp.status).toBe(400);
|
|
845
|
+
});
|
|
846
|
+
|
|
847
|
+
it('POST /api/capture saves a capture file', async () => {
|
|
848
|
+
const resp = await fetch(`http://localhost:${port}/api/capture`, {
|
|
849
|
+
method: 'POST',
|
|
850
|
+
headers: { 'Content-Type': 'application/json' },
|
|
851
|
+
body: JSON.stringify({ title: 'test-capture', content: 'hello' }),
|
|
852
|
+
});
|
|
853
|
+
const data = await resp.json();
|
|
854
|
+
expect(resp.status).toBe(200);
|
|
855
|
+
expect(data.ok).toBe(true);
|
|
856
|
+
expect(data.saved).toContain('test-capture');
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
it('GET /nonexistent returns 404', async () => {
|
|
860
|
+
const resp = await fetch(`http://localhost:${port}/nonexistent.xyz`);
|
|
861
|
+
expect(resp.status).toBe(404);
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
it('OPTIONS returns 204 (CORS preflight)', async () => {
|
|
865
|
+
const resp = await fetch(`http://localhost:${port}/api/event`, {
|
|
866
|
+
method: 'OPTIONS',
|
|
867
|
+
});
|
|
868
|
+
expect(resp.status).toBe(204);
|
|
869
|
+
});
|
|
870
|
+
});
|
|
871
|
+
|
|
872
|
+
// =====================================================================
|
|
873
|
+
// Group K — All CLI subcommand help texts
|
|
874
|
+
// =====================================================================
|
|
875
|
+
|
|
876
|
+
describe('CLI E2E: Subcommand help texts', () => {
|
|
877
|
+
const subcommands = [
|
|
878
|
+
{ args: ['list', '--help'], contains: 'List available learning modules' },
|
|
879
|
+
{ args: ['get', '--help'], contains: 'Get module content' },
|
|
880
|
+
{ args: ['stats', '--help'], contains: 'gamification dashboard' },
|
|
881
|
+
{ args: ['level-up', '--help'], contains: 'belt roadmap' },
|
|
882
|
+
{ args: ['serve', '--help'], contains: 'bridge server' },
|
|
883
|
+
{ args: ['inbox', '--help'], contains: 'captured from the browser' },
|
|
884
|
+
{ args: ['start', '--help'], contains: 'Launch Claude Code' },
|
|
885
|
+
{ args: ['setup', '--help'], contains: 'Check environment' },
|
|
886
|
+
{ args: ['registry:build', '--help'], contains: 'Rebuild the module registry' },
|
|
887
|
+
{ args: ['install', '--help'], contains: 'Install a module pack' },
|
|
888
|
+
{ args: ['packs', '--help'], contains: 'installed module packs' },
|
|
889
|
+
{ args: ['update', '--help'], contains: 'Update an installed module pack' },
|
|
890
|
+
{ args: ['remove', '--help'], contains: 'Remove an installed module pack' },
|
|
891
|
+
{ args: ['search', '--help'], contains: 'Search configured registries' },
|
|
892
|
+
{ args: ['author', '--help'], contains: 'authoring tools' },
|
|
893
|
+
];
|
|
894
|
+
|
|
895
|
+
for (const { args, contains } of subcommands) {
|
|
896
|
+
it(`${args[0]} --help shows description`, () => {
|
|
897
|
+
const { stdout, exitCode } = runCli(args);
|
|
898
|
+
expect(exitCode).toBe(0);
|
|
899
|
+
expect(stripAnsi(stdout)).toContain(contains);
|
|
900
|
+
});
|
|
901
|
+
}
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
// =====================================================================
|
|
905
|
+
// Group L — Comprehensive npx tests (all commands)
|
|
906
|
+
// =====================================================================
|
|
907
|
+
|
|
908
|
+
describe('CLI E2E: npx comprehensive', () => {
|
|
909
|
+
it('npx setup --show-path resolves plugin via npm', () => {
|
|
910
|
+
const { stdout, exitCode } = runNpx(['setup', '--show-path'], { timeout: 60000 });
|
|
911
|
+
expect(exitCode).toBe(0);
|
|
912
|
+
expect(stdout.trim()).toContain('plugin');
|
|
913
|
+
expect(stdout.trim().length).toBeGreaterThan(5);
|
|
914
|
+
});
|
|
915
|
+
|
|
916
|
+
it('npx start --help shows launch description', () => {
|
|
917
|
+
const { stdout, exitCode } = runNpx(['start', '--help'], { timeout: 60000 });
|
|
918
|
+
expect(exitCode).toBe(0);
|
|
919
|
+
expect(stripAnsi(stdout)).toContain('Launch Claude Code');
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
it('npx setup --help shows setup options', () => {
|
|
923
|
+
const { stdout, exitCode } = runNpx(['setup', '--help'], { timeout: 60000 });
|
|
924
|
+
expect(exitCode).toBe(0);
|
|
925
|
+
const clean = stripAnsi(stdout);
|
|
926
|
+
expect(clean).toContain('--show-path');
|
|
927
|
+
expect(clean).toContain('--extension');
|
|
928
|
+
});
|
|
929
|
+
|
|
930
|
+
it('npx level-up outputs recommendations', () => {
|
|
931
|
+
const { stdout, exitCode } = runNpx(['level-up'], { timeout: 60000 });
|
|
932
|
+
expect(exitCode).toBe(0);
|
|
933
|
+
expect(stdout.trim().length).toBeGreaterThan(20);
|
|
934
|
+
});
|
|
935
|
+
|
|
936
|
+
it('npx get git --quick outputs quick reference', () => {
|
|
937
|
+
const { stdout, exitCode } = runNpx(['get', 'git', '--quick'], { timeout: 60000 });
|
|
938
|
+
expect(exitCode).toBe(0);
|
|
939
|
+
expect(stdout.length).toBeGreaterThan(50);
|
|
940
|
+
});
|
|
941
|
+
|
|
942
|
+
it('npx get nonexistent-module fails with exit 1', () => {
|
|
943
|
+
const { exitCode, stderr } = runNpx(['get', 'nonexistent-module-xyz'], {
|
|
944
|
+
timeout: 60000,
|
|
945
|
+
expectError: true,
|
|
946
|
+
});
|
|
947
|
+
expect(exitCode).toBe(1);
|
|
948
|
+
expect(stderr).toContain('not found');
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
it('npx list --difficulty beginner shows filtered modules', () => {
|
|
952
|
+
const { stdout, exitCode } = runNpx(['list', '--difficulty', 'beginner'], { timeout: 60000 });
|
|
953
|
+
expect(exitCode).toBe(0);
|
|
954
|
+
expect(stripAnsi(stdout)).toContain('git');
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
it('npx registry:build rebuilds registry', () => {
|
|
958
|
+
const { stdout, exitCode } = runNpx(['registry:build'], { timeout: 60000 });
|
|
959
|
+
expect(exitCode).toBe(0);
|
|
960
|
+
expect(stripAnsi(stdout)).toContain('Registry rebuilt');
|
|
961
|
+
});
|
|
962
|
+
});
|