@specmarket/cli 0.0.4 → 0.0.5

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.
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+
3
+ // --- Hoisted mocks ---
4
+
5
+ const { mockQuery, mockClient, mockSpinner } = vi.hoisted(() => {
6
+ const mockQuery = vi.fn();
7
+ const mockClient = { query: mockQuery };
8
+ const mockSpinner = {
9
+ start: vi.fn().mockReturnThis(),
10
+ stop: vi.fn().mockReturnThis(),
11
+ succeed: vi.fn().mockReturnThis(),
12
+ fail: vi.fn().mockReturnThis(),
13
+ };
14
+ return { mockQuery, mockClient, mockSpinner };
15
+ });
16
+
17
+ vi.mock('../lib/convex-client.js', () => ({
18
+ getConvexClient: vi.fn().mockResolvedValue(mockClient),
19
+ }));
20
+
21
+ vi.mock('ora', () => ({
22
+ default: vi.fn().mockReturnValue(mockSpinner),
23
+ }));
24
+
25
+ vi.mock('@specmarket/convex/api', () => ({
26
+ api: {
27
+ specs: { search: 'specs.search' },
28
+ },
29
+ }));
30
+
31
+ const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
32
+ throw new Error('process.exit called');
33
+ }) as any);
34
+
35
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
36
+ vi.spyOn(console, 'error').mockImplementation(() => {});
37
+
38
+ import { handleSearch } from './search.js';
39
+
40
+ // --- Test data ---
41
+
42
+ const MOCK_RESULTS = [
43
+ {
44
+ scopedName: '@alice/todo-app',
45
+ description: 'A simple todo application with authentication and dark mode',
46
+ replacesSaas: 'Todoist',
47
+ successRate: 0.85,
48
+ avgCostUsd: 1.5,
49
+ communityRating: 4.2,
50
+ },
51
+ {
52
+ scopedName: '@bob/crm-tool',
53
+ description: 'Customer relationship manager with pipeline tracking',
54
+ replacesSaas: null,
55
+ successRate: 0,
56
+ avgCostUsd: 0,
57
+ communityRating: 0,
58
+ },
59
+ ];
60
+
61
+ describe('handleSearch', () => {
62
+ beforeEach(() => {
63
+ vi.clearAllMocks();
64
+ mockExit.mockImplementation((() => {
65
+ throw new Error('process.exit called');
66
+ }) as any);
67
+ });
68
+
69
+ it('displays results in table format', async () => {
70
+ mockQuery.mockResolvedValue(MOCK_RESULTS);
71
+
72
+ await handleSearch('todo', {});
73
+
74
+ expect(mockQuery).toHaveBeenCalledWith('specs.search', {
75
+ query: 'todo',
76
+ limit: 20,
77
+ });
78
+ expect(consoleSpy).toHaveBeenCalledWith(
79
+ expect.stringContaining('Found 2 spec(s)')
80
+ );
81
+ });
82
+
83
+ it('shows no-results message when no specs match', async () => {
84
+ mockQuery.mockResolvedValue([]);
85
+
86
+ await handleSearch('nonexistent', {});
87
+
88
+ expect(consoleSpy).toHaveBeenCalledWith(
89
+ expect.stringContaining('No specs found')
90
+ );
91
+ });
92
+
93
+ it('passes filter options to backend query', async () => {
94
+ mockQuery.mockResolvedValue([]);
95
+
96
+ await handleSearch('test', {
97
+ outputType: 'web-app',
98
+ primaryStack: 'nextjs-typescript',
99
+ replacesSaas: 'Notion',
100
+ limit: '10',
101
+ tag: ['productivity'],
102
+ freeTierOnly: true,
103
+ });
104
+
105
+ expect(mockQuery).toHaveBeenCalledWith('specs.search', {
106
+ query: 'test',
107
+ limit: 10,
108
+ outputType: 'web-app',
109
+ primaryStack: 'nextjs-typescript',
110
+ replacesSaas: 'Notion',
111
+ tags: ['productivity'],
112
+ freeTierOnly: true,
113
+ });
114
+ });
115
+
116
+ it('converts min-success-rate from percentage to decimal', async () => {
117
+ mockQuery.mockResolvedValue([]);
118
+
119
+ await handleSearch('test', { minSuccessRate: '80' });
120
+
121
+ expect(mockQuery).toHaveBeenCalledWith('specs.search', {
122
+ query: 'test',
123
+ limit: 20,
124
+ minSuccessRate: 0.8,
125
+ });
126
+ });
127
+
128
+ it('exits with error for invalid min-success-rate', async () => {
129
+ await expect(
130
+ handleSearch('test', { minSuccessRate: '150' })
131
+ ).rejects.toThrow('process.exit called');
132
+
133
+ expect(mockSpinner.fail).toHaveBeenCalledWith(
134
+ expect.stringContaining('--min-success-rate must be between 0 and 100')
135
+ );
136
+ });
137
+
138
+ it('exits with error for invalid max-cost', async () => {
139
+ await expect(
140
+ handleSearch('test', { maxCost: '-5' })
141
+ ).rejects.toThrow('process.exit called');
142
+
143
+ expect(mockSpinner.fail).toHaveBeenCalledWith(
144
+ expect.stringContaining('--max-cost must be a non-negative number')
145
+ );
146
+ });
147
+ });
@@ -11,7 +11,7 @@ description: "A valid test spec with enough description length to pass."
11
11
  output_type: web-app
12
12
  primary_stack: nextjs-typescript
13
13
  version: "1.0.0"
14
- runner: claude-code
14
+ runner: claude
15
15
  min_model: "claude-opus-4-5"
16
16
  estimated_tokens: 50000
17
17
  estimated_cost_usd: 2.50
@@ -105,7 +105,7 @@ describe('validateSpec', () => {
105
105
  await writeValidSpec();
106
106
  await writeFile(
107
107
  join(tmpDir, 'spec.yaml'),
108
- 'name: Invalid Name With Spaces\ndisplay_name: "Test"\ndescription: "short"\noutput_type: invalid\nprimary_stack: nextjs-typescript\nversion: "1.0.0"\nrunner: claude-code\nmin_model: "model"\nestimated_tokens: 100\nestimated_cost_usd: 0.01\nestimated_time_minutes: 1\n'
108
+ 'name: Invalid Name With Spaces\ndisplay_name: "Test"\ndescription: "short"\noutput_type: invalid\nprimary_stack: nextjs-typescript\nversion: "1.0.0"\nrunner: claude\nmin_model: "model"\nestimated_tokens: 100\nestimated_cost_usd: 0.01\nestimated_time_minutes: 1\n'
109
109
  );
110
110
  const result = await validateSpec(tmpDir);
111
111
  expect(result.valid).toBe(false);
@@ -276,3 +276,130 @@ describe('detectCircularReferences', () => {
276
276
  expect(cycles).toHaveLength(0);
277
277
  });
278
278
  });
279
+
280
+ describe('validateSpec format-aware', () => {
281
+ let tmpDir: string;
282
+
283
+ beforeEach(async () => {
284
+ tmpDir = join(tmpdir(), `spec-format-${randomUUID()}`);
285
+ await mkdir(tmpDir, { recursive: true });
286
+ });
287
+
288
+ afterEach(async () => {
289
+ await rm(tmpDir, { recursive: true, force: true });
290
+ });
291
+
292
+ it('reports format and formatDetectedBy in result for legacy spec', async () => {
293
+ await mkdir(join(tmpDir, 'stdlib'), { recursive: true });
294
+ await writeFile(join(tmpDir, 'spec.yaml'), VALID_SPEC_YAML);
295
+ await writeFile(join(tmpDir, 'PROMPT.md'), VALID_PROMPT_MD);
296
+ await writeFile(join(tmpDir, 'SPEC.md'), VALID_SPEC_MD);
297
+ await writeFile(join(tmpDir, 'SUCCESS_CRITERIA.md'), VALID_SUCCESS_CRITERIA);
298
+ await writeFile(join(tmpDir, 'stdlib', 'STACK.md'), VALID_STACK_MD);
299
+ const result = await validateSpec(tmpDir);
300
+ expect(result.valid).toBe(true);
301
+ expect(result.format).toBe('specmarket-legacy');
302
+ expect(result.formatDetectedBy).toBe('heuristic');
303
+ });
304
+
305
+ it('speckit dir validates successfully', async () => {
306
+ await writeFile(join(tmpDir, 'spec.md'), '# Spec\nContent here.');
307
+ await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
308
+ await mkdir(join(tmpDir, '.specify'), { recursive: true });
309
+ const result = await validateSpec(tmpDir);
310
+ expect(result.valid).toBe(true);
311
+ expect(result.format).toBe('speckit');
312
+ });
313
+
314
+ it('speckit missing tasks.md and plan.md returns error', async () => {
315
+ await writeFile(join(tmpDir, 'spec.md'), '# Spec');
316
+ const result = await validateSpec(tmpDir);
317
+ expect(result.valid).toBe(false);
318
+ expect(result.errors.some((e) => e.includes('tasks.md') || e.includes('plan.md'))).toBe(true);
319
+ });
320
+
321
+ it('bmad dir validates successfully', async () => {
322
+ await writeFile(join(tmpDir, 'prd.md'), '# PRD\nProduct requirements.');
323
+ await writeFile(join(tmpDir, 'story-1.md'), '# Story 1');
324
+ const result = await validateSpec(tmpDir);
325
+ expect(result.valid).toBe(true);
326
+ expect(result.format).toBe('bmad');
327
+ });
328
+
329
+ it('ralph dir validates successfully', async () => {
330
+ await writeFile(
331
+ join(tmpDir, 'prd.json'),
332
+ JSON.stringify({ userStories: [{ title: 'As a user I want X' }] })
333
+ );
334
+ const result = await validateSpec(tmpDir);
335
+ expect(result.valid).toBe(true);
336
+ expect(result.format).toBe('ralph');
337
+ });
338
+
339
+ it('ralph prd.json missing userStories returns error', async () => {
340
+ await writeFile(join(tmpDir, 'prd.json'), JSON.stringify({ other: true }));
341
+ const result = await validateSpec(tmpDir);
342
+ expect(result.valid).toBe(false);
343
+ expect(result.errors.some((e) => e.includes('userStories'))).toBe(true);
344
+ });
345
+
346
+ it('custom dir with sufficient .md validates', async () => {
347
+ const content =
348
+ '# Readme\n\nThis is a spec with enough content to pass the 100-byte minimum for custom format. Extra text here.';
349
+ expect(content.length).toBeGreaterThan(100);
350
+ await writeFile(join(tmpDir, 'readme.md'), content);
351
+ const result = await validateSpec(tmpDir);
352
+ expect(result.valid).toBe(true);
353
+ expect(result.format).toBe('custom');
354
+ });
355
+
356
+ it('custom dir with only tiny .md files fails', async () => {
357
+ await writeFile(join(tmpDir, 'tiny.md'), 'x');
358
+ const result = await validateSpec(tmpDir);
359
+ expect(result.valid).toBe(false);
360
+ expect(result.errors.some((e) => e.includes('100 bytes'))).toBe(true);
361
+ });
362
+
363
+ it('sidecar with invalid schema returns validation error', async () => {
364
+ await writeFile(join(tmpDir, 'spec.md'), '# Spec');
365
+ await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
366
+ await writeFile(
367
+ join(tmpDir, 'specmarket.yaml'),
368
+ 'spec_format: speckit\ndisplay_name: X\ndescription: short'
369
+ );
370
+ const result = await validateSpec(tmpDir);
371
+ expect(result.valid).toBe(false);
372
+ expect(result.errors.some((e) => e.includes('specmarket.yaml'))).toBe(true);
373
+ });
374
+
375
+ it('sidecar with valid schema passes and format is from sidecar', async () => {
376
+ await writeFile(join(tmpDir, 'spec.md'), '# Spec');
377
+ await writeFile(join(tmpDir, 'tasks.md'), '# Tasks');
378
+ await writeFile(
379
+ join(tmpDir, 'specmarket.yaml'),
380
+ `spec_format: speckit
381
+ display_name: My Spec
382
+ description: A long enough description for the sidecar schema.
383
+ output_type: web-app
384
+ primary_stack: nextjs-typescript
385
+ `
386
+ );
387
+ const result = await validateSpec(tmpDir);
388
+ expect(result.valid).toBe(true);
389
+ expect(result.format).toBe('speckit');
390
+ expect(result.formatDetectedBy).toBe('sidecar');
391
+ });
392
+
393
+ it('empty directory fails with universal check', async () => {
394
+ const result = await validateSpec(tmpDir);
395
+ expect(result.valid).toBe(false);
396
+ expect(result.errors.some((e) => e.includes('empty') || e.includes('unreadable'))).toBe(true);
397
+ });
398
+
399
+ it('directory with only subdir and no files fails', async () => {
400
+ await mkdir(join(tmpDir, 'sub'), { recursive: true });
401
+ const result = await validateSpec(tmpDir);
402
+ expect(result.valid).toBe(false);
403
+ expect(result.errors.some((e) => e.includes('empty') || e.includes('readable'))).toBe(true);
404
+ });
405
+ });