@specmarket/cli 0.0.3 → 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.
- package/CHANGELOG.md +6 -1
- package/README.md +1 -1
- package/dist/api-GIDUNUXG.js +0 -0
- package/dist/{chunk-MS2DYACY.js → chunk-DLEMNRTH.js} +19 -2
- package/dist/chunk-DLEMNRTH.js.map +1 -0
- package/dist/chunk-JEUDDJP7.js +0 -0
- package/dist/{config-R5KWZSJP.js → config-OAU6SJLC.js} +2 -2
- package/dist/exec-K3BOXX3C.js +0 -0
- package/dist/index.js +980 -181
- package/dist/index.js.map +1 -1
- package/package.json +21 -15
- package/src/commands/comment.test.ts +211 -0
- package/src/commands/comment.ts +176 -0
- package/src/commands/fork.test.ts +163 -0
- package/src/commands/info.test.ts +192 -0
- package/src/commands/info.ts +66 -2
- package/src/commands/init.test.ts +106 -0
- package/src/commands/init.ts +12 -10
- package/src/commands/issues.test.ts +377 -0
- package/src/commands/issues.ts +443 -0
- package/src/commands/login.test.ts +99 -0
- package/src/commands/logout.test.ts +54 -0
- package/src/commands/publish.test.ts +146 -0
- package/src/commands/report.test.ts +181 -0
- package/src/commands/run.test.ts +213 -0
- package/src/commands/run.ts +10 -2
- package/src/commands/search.test.ts +147 -0
- package/src/commands/validate.test.ts +129 -2
- package/src/commands/validate.ts +333 -192
- package/src/commands/whoami.test.ts +106 -0
- package/src/index.ts +6 -0
- package/src/lib/convex-client.ts +6 -2
- package/src/lib/format-detection.test.ts +223 -0
- package/src/lib/format-detection.ts +172 -0
- package/src/lib/ralph-loop.ts +49 -20
- package/src/lib/telemetry.ts +2 -1
- package/dist/chunk-MS2DYACY.js.map +0 -1
- /package/dist/{config-R5KWZSJP.js.map → config-OAU6SJLC.js.map} +0 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// --- Hoisted mocks (available inside vi.mock factories) ---
|
|
4
|
+
|
|
5
|
+
const { mockQuery, mockMutation, mockClient, mockSpinner } = vi.hoisted(() => {
|
|
6
|
+
const mockQuery = vi.fn();
|
|
7
|
+
const mockMutation = vi.fn();
|
|
8
|
+
const mockClient = { query: mockQuery, mutation: mockMutation };
|
|
9
|
+
const mockSpinner = {
|
|
10
|
+
start: vi.fn().mockReturnThis(),
|
|
11
|
+
stop: vi.fn().mockReturnThis(),
|
|
12
|
+
succeed: vi.fn().mockReturnThis(),
|
|
13
|
+
fail: vi.fn().mockReturnThis(),
|
|
14
|
+
};
|
|
15
|
+
return { mockQuery, mockMutation, mockClient, mockSpinner };
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
vi.mock('../lib/convex-client.js', () => ({
|
|
19
|
+
getConvexClient: vi.fn().mockResolvedValue(mockClient),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock('../lib/auth.js', () => ({
|
|
23
|
+
requireAuth: vi.fn().mockResolvedValue({
|
|
24
|
+
token: 'test-token',
|
|
25
|
+
username: 'testuser',
|
|
26
|
+
expiresAt: Date.now() + 3600_000,
|
|
27
|
+
}),
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('ora', () => ({
|
|
31
|
+
default: vi.fn().mockReturnValue(mockSpinner),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('@specmarket/convex/api', () => ({
|
|
35
|
+
api: {
|
|
36
|
+
specs: { get: 'specs.get' },
|
|
37
|
+
issues: {
|
|
38
|
+
list: 'issues.list',
|
|
39
|
+
get: 'issues.get',
|
|
40
|
+
create: 'issues.create',
|
|
41
|
+
close: 'issues.close',
|
|
42
|
+
reopen: 'issues.reopen',
|
|
43
|
+
},
|
|
44
|
+
comments: { list: 'comments.list' },
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// Prevent process.exit from actually exiting in tests
|
|
49
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
50
|
+
throw new Error('process.exit called');
|
|
51
|
+
}) as any);
|
|
52
|
+
|
|
53
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
54
|
+
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
55
|
+
|
|
56
|
+
import {
|
|
57
|
+
handleIssuesList,
|
|
58
|
+
handleIssuesCreate,
|
|
59
|
+
handleIssuesView,
|
|
60
|
+
handleIssuesClose,
|
|
61
|
+
handleIssuesReopen,
|
|
62
|
+
} from './issues.js';
|
|
63
|
+
|
|
64
|
+
// --- Test data ---
|
|
65
|
+
|
|
66
|
+
const MOCK_SPEC = {
|
|
67
|
+
_id: 'spec123',
|
|
68
|
+
scopedName: '@alice/my-spec',
|
|
69
|
+
displayName: 'My Spec',
|
|
70
|
+
status: 'published',
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const MOCK_ISSUE = {
|
|
74
|
+
_id: 'issue456',
|
|
75
|
+
specId: 'spec123',
|
|
76
|
+
number: 1,
|
|
77
|
+
authorId: 'user789',
|
|
78
|
+
title: 'Bug in build step',
|
|
79
|
+
body: 'The build fails when using Node 22.',
|
|
80
|
+
labels: ['bug'],
|
|
81
|
+
status: 'open',
|
|
82
|
+
createdAt: Date.now() - 86400_000,
|
|
83
|
+
updatedAt: Date.now(),
|
|
84
|
+
author: { username: 'bob', displayName: 'Bob', avatarUrl: null },
|
|
85
|
+
commentCount: 3,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
describe('handleIssuesList', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
vi.clearAllMocks();
|
|
91
|
+
mockExit.mockImplementation((() => {
|
|
92
|
+
throw new Error('process.exit called');
|
|
93
|
+
}) as any);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('lists open issues in table format', async () => {
|
|
97
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
98
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
99
|
+
if (fn === 'issues.list') {
|
|
100
|
+
return {
|
|
101
|
+
page: [
|
|
102
|
+
{
|
|
103
|
+
...MOCK_ISSUE,
|
|
104
|
+
author: { username: 'bob', displayName: 'Bob', avatarUrl: null },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
isDone: true,
|
|
108
|
+
continueCursor: null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
await handleIssuesList('@alice/my-spec', {});
|
|
114
|
+
|
|
115
|
+
expect(mockQuery).toHaveBeenCalledWith('specs.get', {
|
|
116
|
+
scopedName: '@alice/my-spec',
|
|
117
|
+
});
|
|
118
|
+
expect(mockQuery).toHaveBeenCalledWith('issues.list', {
|
|
119
|
+
specId: 'spec123',
|
|
120
|
+
status: 'open',
|
|
121
|
+
paginationOpts: { numItems: 50, cursor: null },
|
|
122
|
+
});
|
|
123
|
+
expect(consoleSpy).toHaveBeenCalled();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('shows empty message when no issues found', async () => {
|
|
127
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
128
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
129
|
+
if (fn === 'issues.list') {
|
|
130
|
+
return { page: [], isDone: true, continueCursor: null };
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
await handleIssuesList('@alice/my-spec', {});
|
|
135
|
+
|
|
136
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
137
|
+
expect.stringContaining('No open issues')
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('passes closed status filter to backend', async () => {
|
|
142
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
143
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
144
|
+
if (fn === 'issues.list') {
|
|
145
|
+
return { page: [], isDone: true, continueCursor: null };
|
|
146
|
+
}
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
await handleIssuesList('@alice/my-spec', { status: 'closed' });
|
|
150
|
+
|
|
151
|
+
expect(mockQuery).toHaveBeenCalledWith('issues.list', {
|
|
152
|
+
specId: 'spec123',
|
|
153
|
+
status: 'closed',
|
|
154
|
+
paginationOpts: { numItems: 50, cursor: null },
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('passes undefined status for "all" filter', async () => {
|
|
159
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
160
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
161
|
+
if (fn === 'issues.list') {
|
|
162
|
+
return { page: [], isDone: true, continueCursor: null };
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await handleIssuesList('@alice/my-spec', { status: 'all' });
|
|
167
|
+
|
|
168
|
+
expect(mockQuery).toHaveBeenCalledWith('issues.list', {
|
|
169
|
+
specId: 'spec123',
|
|
170
|
+
status: undefined,
|
|
171
|
+
paginationOpts: { numItems: 50, cursor: null },
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('filters by label client-side', async () => {
|
|
176
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
177
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
178
|
+
if (fn === 'issues.list') {
|
|
179
|
+
return {
|
|
180
|
+
page: [
|
|
181
|
+
{ ...MOCK_ISSUE, labels: ['bug'] },
|
|
182
|
+
{ ...MOCK_ISSUE, number: 2, labels: ['enhancement'], title: 'Add feature' },
|
|
183
|
+
],
|
|
184
|
+
isDone: true,
|
|
185
|
+
continueCursor: null,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
await handleIssuesList('@alice/my-spec', { label: 'enhancement' });
|
|
191
|
+
|
|
192
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
193
|
+
expect.stringContaining('1 issue(s)')
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('exits with error when spec not found', async () => {
|
|
198
|
+
mockQuery.mockResolvedValue(null);
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
handleIssuesList('@alice/nonexistent', {})
|
|
202
|
+
).rejects.toThrow('process.exit called');
|
|
203
|
+
|
|
204
|
+
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
|
205
|
+
expect.stringContaining('Spec not found')
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('handleIssuesView', () => {
|
|
211
|
+
beforeEach(() => {
|
|
212
|
+
vi.clearAllMocks();
|
|
213
|
+
mockExit.mockImplementation((() => {
|
|
214
|
+
throw new Error('process.exit called');
|
|
215
|
+
}) as any);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('displays issue detail with comments', async () => {
|
|
219
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
220
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
221
|
+
if (fn === 'issues.get') return MOCK_ISSUE;
|
|
222
|
+
if (fn === 'comments.list') {
|
|
223
|
+
return {
|
|
224
|
+
page: [
|
|
225
|
+
{
|
|
226
|
+
_id: 'comment1',
|
|
227
|
+
body: 'This needs to be fixed ASAP',
|
|
228
|
+
author: { username: 'carol', displayName: 'Carol', avatarUrl: null },
|
|
229
|
+
createdAt: Date.now() - 3600_000,
|
|
230
|
+
replies: [],
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
isDone: true,
|
|
234
|
+
continueCursor: null,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
await handleIssuesView('@alice/my-spec', 1);
|
|
240
|
+
|
|
241
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
242
|
+
expect.stringContaining('#1: Bug in build step')
|
|
243
|
+
);
|
|
244
|
+
expect(consoleSpy).toHaveBeenCalledWith('The build fails when using Node 22.');
|
|
245
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
246
|
+
expect.stringContaining('@carol')
|
|
247
|
+
);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it('exits with error when issue not found', async () => {
|
|
251
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
252
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
253
|
+
if (fn === 'issues.get') return null;
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
await expect(
|
|
257
|
+
handleIssuesView('@alice/my-spec', 99)
|
|
258
|
+
).rejects.toThrow('process.exit called');
|
|
259
|
+
|
|
260
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
261
|
+
expect.stringContaining('Issue #99 not found')
|
|
262
|
+
);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('shows "No comments yet" when there are none', async () => {
|
|
266
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
267
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
268
|
+
if (fn === 'issues.get') return { ...MOCK_ISSUE, commentCount: 0 };
|
|
269
|
+
if (fn === 'comments.list') {
|
|
270
|
+
return { page: [], isDone: true, continueCursor: null };
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
await handleIssuesView('@alice/my-spec', 1);
|
|
275
|
+
|
|
276
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
277
|
+
expect.stringContaining('No comments yet')
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('handleIssuesClose', () => {
|
|
283
|
+
beforeEach(() => {
|
|
284
|
+
vi.clearAllMocks();
|
|
285
|
+
mockExit.mockImplementation((() => {
|
|
286
|
+
throw new Error('process.exit called');
|
|
287
|
+
}) as any);
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('closes an issue successfully', async () => {
|
|
291
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
292
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
293
|
+
if (fn === 'issues.get') return MOCK_ISSUE;
|
|
294
|
+
});
|
|
295
|
+
mockMutation.mockResolvedValue({ success: true });
|
|
296
|
+
|
|
297
|
+
await handleIssuesClose('@alice/my-spec', 1);
|
|
298
|
+
|
|
299
|
+
expect(mockMutation).toHaveBeenCalledWith('issues.close', {
|
|
300
|
+
issueId: 'issue456',
|
|
301
|
+
});
|
|
302
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
303
|
+
expect.stringContaining('Issue #1 closed')
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it('exits with error when issue not found', async () => {
|
|
308
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
309
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
310
|
+
if (fn === 'issues.get') return null;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
await expect(
|
|
314
|
+
handleIssuesClose('@alice/my-spec', 99)
|
|
315
|
+
).rejects.toThrow('process.exit called');
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
describe('handleIssuesReopen', () => {
|
|
320
|
+
beforeEach(() => {
|
|
321
|
+
vi.clearAllMocks();
|
|
322
|
+
mockExit.mockImplementation((() => {
|
|
323
|
+
throw new Error('process.exit called');
|
|
324
|
+
}) as any);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it('reopens an issue successfully', async () => {
|
|
328
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
329
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
330
|
+
if (fn === 'issues.get') return { ...MOCK_ISSUE, status: 'closed' };
|
|
331
|
+
});
|
|
332
|
+
mockMutation.mockResolvedValue({ success: true });
|
|
333
|
+
|
|
334
|
+
await handleIssuesReopen('@alice/my-spec', 1);
|
|
335
|
+
|
|
336
|
+
expect(mockMutation).toHaveBeenCalledWith('issues.reopen', {
|
|
337
|
+
issueId: 'issue456',
|
|
338
|
+
});
|
|
339
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
340
|
+
expect.stringContaining('Issue #1 reopened')
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
describe('handleIssuesCreate', () => {
|
|
346
|
+
beforeEach(() => {
|
|
347
|
+
vi.clearAllMocks();
|
|
348
|
+
mockExit.mockImplementation((() => {
|
|
349
|
+
throw new Error('process.exit called');
|
|
350
|
+
}) as any);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('creates an issue with inquirer prompts', async () => {
|
|
354
|
+
const inquirerMock = await import('inquirer');
|
|
355
|
+
vi.spyOn(inquirerMock.default, 'prompt').mockResolvedValue({
|
|
356
|
+
title: 'New bug report',
|
|
357
|
+
body: 'Steps to reproduce...',
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
361
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
362
|
+
});
|
|
363
|
+
mockMutation.mockResolvedValue({ issueId: 'newIssue1', number: 2 });
|
|
364
|
+
|
|
365
|
+
await handleIssuesCreate('@alice/my-spec');
|
|
366
|
+
|
|
367
|
+
expect(mockMutation).toHaveBeenCalledWith('issues.create', {
|
|
368
|
+
specId: 'spec123',
|
|
369
|
+
title: 'New bug report',
|
|
370
|
+
body: 'Steps to reproduce...',
|
|
371
|
+
labels: [],
|
|
372
|
+
});
|
|
373
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
374
|
+
expect.stringContaining('Issue #2 created')
|
|
375
|
+
);
|
|
376
|
+
});
|
|
377
|
+
});
|