@specmarket/cli 0.0.4 → 0.0.6
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/README.md +1 -1
- package/dist/{chunk-MS2DYACY.js → chunk-OTXWWFAO.js} +42 -3
- package/dist/chunk-OTXWWFAO.js.map +1 -0
- package/dist/{config-R5KWZSJP.js → config-5JMI3YAR.js} +2 -2
- package/dist/index.js +1945 -252
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- 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 +245 -0
- package/src/commands/init.ts +359 -25
- package/src/commands/issues.test.ts +382 -0
- package/src/commands/issues.ts +436 -0
- package/src/commands/login.test.ts +99 -0
- package/src/commands/login.ts +2 -6
- package/src/commands/logout.test.ts +54 -0
- package/src/commands/publish.test.ts +159 -0
- package/src/commands/publish.ts +1 -0
- package/src/commands/report.test.ts +181 -0
- package/src/commands/run.test.ts +419 -0
- package/src/commands/run.ts +71 -3
- package/src/commands/search.test.ts +147 -0
- package/src/commands/validate.test.ts +206 -2
- package/src/commands/validate.ts +315 -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/meta-instructions.test.ts +340 -0
- package/src/lib/meta-instructions.ts +562 -0
- package/src/lib/ralph-loop.test.ts +404 -0
- package/src/lib/ralph-loop.ts +501 -95
- package/src/lib/telemetry.ts +7 -1
- package/dist/chunk-MS2DYACY.js.map +0 -1
- /package/dist/{config-R5KWZSJP.js.map → config-5JMI3YAR.js.map} +0 -0
package/package.json
CHANGED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
// --- Hoisted mocks ---
|
|
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: { get: 'issues.get' },
|
|
38
|
+
comments: { create: 'comments.create' },
|
|
39
|
+
},
|
|
40
|
+
}));
|
|
41
|
+
|
|
42
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
43
|
+
throw new Error('process.exit called');
|
|
44
|
+
}) as any);
|
|
45
|
+
|
|
46
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
47
|
+
|
|
48
|
+
import { handleComment } from './comment.js';
|
|
49
|
+
|
|
50
|
+
// --- Test data ---
|
|
51
|
+
|
|
52
|
+
const MOCK_SPEC = {
|
|
53
|
+
_id: 'spec123',
|
|
54
|
+
scopedName: '@alice/my-spec',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const MOCK_ISSUE = {
|
|
58
|
+
_id: 'issue456',
|
|
59
|
+
specId: 'spec123',
|
|
60
|
+
number: 3,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
describe('handleComment', () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.clearAllMocks();
|
|
66
|
+
mockExit.mockImplementation((() => {
|
|
67
|
+
throw new Error('process.exit called');
|
|
68
|
+
}) as any);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('posts a comment on a spec using scoped name', async () => {
|
|
72
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
73
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
74
|
+
});
|
|
75
|
+
mockMutation.mockResolvedValue('comment789');
|
|
76
|
+
|
|
77
|
+
await handleComment('spec', '@alice/my-spec', 'Great spec!', {});
|
|
78
|
+
|
|
79
|
+
expect(mockQuery).toHaveBeenCalledWith('specs.get', {
|
|
80
|
+
scopedName: '@alice/my-spec',
|
|
81
|
+
});
|
|
82
|
+
expect(mockMutation).toHaveBeenCalledWith('comments.create', {
|
|
83
|
+
targetType: 'spec',
|
|
84
|
+
targetId: 'spec123',
|
|
85
|
+
body: 'Great spec!',
|
|
86
|
+
});
|
|
87
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
88
|
+
expect.stringContaining('Comment posted')
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('posts a comment on an issue using @user/spec#N format', async () => {
|
|
93
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
94
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
95
|
+
if (fn === 'issues.get') return MOCK_ISSUE;
|
|
96
|
+
});
|
|
97
|
+
mockMutation.mockResolvedValue('comment789');
|
|
98
|
+
|
|
99
|
+
await handleComment(
|
|
100
|
+
'issue',
|
|
101
|
+
'@alice/my-spec#3',
|
|
102
|
+
'I can reproduce this',
|
|
103
|
+
{}
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
expect(mockQuery).toHaveBeenCalledWith('specs.get', {
|
|
107
|
+
scopedName: '@alice/my-spec',
|
|
108
|
+
});
|
|
109
|
+
expect(mockQuery).toHaveBeenCalledWith('issues.get', {
|
|
110
|
+
specId: 'spec123',
|
|
111
|
+
number: 3,
|
|
112
|
+
});
|
|
113
|
+
expect(mockMutation).toHaveBeenCalledWith('comments.create', {
|
|
114
|
+
targetType: 'issue',
|
|
115
|
+
targetId: 'issue456',
|
|
116
|
+
body: 'I can reproduce this',
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('posts a comment on a bounty', async () => {
|
|
121
|
+
mockMutation.mockResolvedValue('comment789');
|
|
122
|
+
|
|
123
|
+
await handleComment(
|
|
124
|
+
'bounty',
|
|
125
|
+
'bounty999',
|
|
126
|
+
'I am working on this',
|
|
127
|
+
{}
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
expect(mockMutation).toHaveBeenCalledWith('comments.create', {
|
|
131
|
+
targetType: 'bounty',
|
|
132
|
+
targetId: 'bounty999',
|
|
133
|
+
body: 'I am working on this',
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('passes reply comment ID when --reply flag is set', async () => {
|
|
138
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
139
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
140
|
+
});
|
|
141
|
+
mockMutation.mockResolvedValue('comment790');
|
|
142
|
+
|
|
143
|
+
await handleComment('spec', '@alice/my-spec', 'Reply text', {
|
|
144
|
+
reply: 'parentComment123',
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(mockMutation).toHaveBeenCalledWith('comments.create', {
|
|
148
|
+
targetType: 'spec',
|
|
149
|
+
targetId: 'spec123',
|
|
150
|
+
body: 'Reply text',
|
|
151
|
+
parentId: 'parentComment123',
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('exits with error for invalid target type', async () => {
|
|
156
|
+
await expect(
|
|
157
|
+
handleComment('invalid', 'ref', 'text', {})
|
|
158
|
+
).rejects.toThrow('process.exit called');
|
|
159
|
+
|
|
160
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
161
|
+
expect.stringContaining('Invalid target type')
|
|
162
|
+
);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('exits with error for missing # in issue reference', async () => {
|
|
166
|
+
await expect(
|
|
167
|
+
handleComment('issue', '@alice/my-spec', 'text', {})
|
|
168
|
+
).rejects.toThrow('process.exit called');
|
|
169
|
+
|
|
170
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
171
|
+
expect.stringContaining('Invalid issue reference')
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('exits with error for non-numeric issue number', async () => {
|
|
176
|
+
await expect(
|
|
177
|
+
handleComment('issue', '@alice/my-spec#abc', 'text', {})
|
|
178
|
+
).rejects.toThrow('process.exit called');
|
|
179
|
+
|
|
180
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
181
|
+
expect.stringContaining('Invalid issue number')
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('exits with error when spec not found', async () => {
|
|
186
|
+
mockQuery.mockResolvedValue(null);
|
|
187
|
+
|
|
188
|
+
await expect(
|
|
189
|
+
handleComment('spec', '@alice/nonexistent', 'text', {})
|
|
190
|
+
).rejects.toThrow('process.exit called');
|
|
191
|
+
|
|
192
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
193
|
+
expect.stringContaining('Spec not found')
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('exits with error when issue not found', async () => {
|
|
198
|
+
mockQuery.mockImplementation((fn: string) => {
|
|
199
|
+
if (fn === 'specs.get') return MOCK_SPEC;
|
|
200
|
+
if (fn === 'issues.get') return null;
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
await expect(
|
|
204
|
+
handleComment('issue', '@alice/my-spec#99', 'text', {})
|
|
205
|
+
).rejects.toThrow('process.exit called');
|
|
206
|
+
|
|
207
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
208
|
+
expect.stringContaining('Issue #99 not found')
|
|
209
|
+
);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { getConvexClient } from '../lib/convex-client.js';
|
|
4
|
+
import { requireAuth } from '../lib/auth.js';
|
|
5
|
+
import { EXIT_CODES } from '@specmarket/shared';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Posts a comment on a spec, issue, or bounty.
|
|
9
|
+
*
|
|
10
|
+
* Target reference formats:
|
|
11
|
+
* - spec: @user/spec or specId
|
|
12
|
+
* - issue: @user/spec#3 (scoped name + issue number)
|
|
13
|
+
* - bounty: bountyId
|
|
14
|
+
*
|
|
15
|
+
* Requires authentication.
|
|
16
|
+
*/
|
|
17
|
+
export async function handleComment(
|
|
18
|
+
targetType: string,
|
|
19
|
+
targetRef: string,
|
|
20
|
+
body: string,
|
|
21
|
+
opts: { reply?: string }
|
|
22
|
+
): Promise<void> {
|
|
23
|
+
const creds = await requireAuth();
|
|
24
|
+
|
|
25
|
+
let api: any;
|
|
26
|
+
try {
|
|
27
|
+
api = (await import('@specmarket/convex/api')).api;
|
|
28
|
+
} catch {
|
|
29
|
+
console.error(
|
|
30
|
+
chalk.red('Error: Could not load Convex API bindings. Is CONVEX_URL configured?')
|
|
31
|
+
);
|
|
32
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const client = await getConvexClient(creds.token);
|
|
36
|
+
const spinner = (await import('ora')).default('Posting comment...').start();
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
let resolvedTargetType: 'spec' | 'bounty' | 'issue';
|
|
40
|
+
let resolvedTargetId: string;
|
|
41
|
+
|
|
42
|
+
if (targetType === 'spec') {
|
|
43
|
+
resolvedTargetType = 'spec';
|
|
44
|
+
const isScopedName = targetRef.startsWith('@') || targetRef.includes('/');
|
|
45
|
+
const spec = await client.query(
|
|
46
|
+
api.specs.get,
|
|
47
|
+
isScopedName ? { scopedName: targetRef } : { specId: targetRef }
|
|
48
|
+
);
|
|
49
|
+
if (!spec) {
|
|
50
|
+
spinner.fail(chalk.red(`Spec not found: ${targetRef}`));
|
|
51
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
52
|
+
}
|
|
53
|
+
resolvedTargetId = spec._id;
|
|
54
|
+
} else if (targetType === 'issue') {
|
|
55
|
+
resolvedTargetType = 'issue';
|
|
56
|
+
// Parse @user/spec#3 format
|
|
57
|
+
const hashIndex = targetRef.lastIndexOf('#');
|
|
58
|
+
if (hashIndex === -1) {
|
|
59
|
+
spinner.fail(
|
|
60
|
+
chalk.red(
|
|
61
|
+
'Invalid issue reference. Use format: @user/spec#<number>'
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const specRef = targetRef.slice(0, hashIndex);
|
|
68
|
+
const issueNumber = parseInt(targetRef.slice(hashIndex + 1), 10);
|
|
69
|
+
|
|
70
|
+
if (isNaN(issueNumber) || issueNumber < 1) {
|
|
71
|
+
spinner.fail(
|
|
72
|
+
chalk.red(`Invalid issue number in "${targetRef}". Use format: @user/spec#<number>`)
|
|
73
|
+
);
|
|
74
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Resolve spec
|
|
78
|
+
const isScopedName = specRef.startsWith('@') || specRef.includes('/');
|
|
79
|
+
const spec = await client.query(
|
|
80
|
+
api.specs.get,
|
|
81
|
+
isScopedName ? { scopedName: specRef } : { specId: specRef }
|
|
82
|
+
);
|
|
83
|
+
if (!spec) {
|
|
84
|
+
spinner.fail(chalk.red(`Spec not found: ${specRef}`));
|
|
85
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Resolve issue
|
|
89
|
+
const issue = await client.query(api.issues.get, {
|
|
90
|
+
specId: spec._id,
|
|
91
|
+
number: issueNumber,
|
|
92
|
+
});
|
|
93
|
+
if (!issue) {
|
|
94
|
+
spinner.fail(
|
|
95
|
+
chalk.red(`Issue #${issueNumber} not found on ${spec.scopedName}`)
|
|
96
|
+
);
|
|
97
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
98
|
+
}
|
|
99
|
+
resolvedTargetId = issue._id;
|
|
100
|
+
} else if (targetType === 'bounty') {
|
|
101
|
+
resolvedTargetType = 'bounty';
|
|
102
|
+
resolvedTargetId = targetRef;
|
|
103
|
+
} else {
|
|
104
|
+
spinner.fail(
|
|
105
|
+
chalk.red(
|
|
106
|
+
`Invalid target type: "${targetType}". Use "spec", "issue", or "bounty".`
|
|
107
|
+
)
|
|
108
|
+
);
|
|
109
|
+
process.exit(EXIT_CODES.VALIDATION_ERROR);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const args: Record<string, unknown> = {
|
|
113
|
+
targetType: resolvedTargetType,
|
|
114
|
+
targetId: resolvedTargetId,
|
|
115
|
+
body: body.trim(),
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
if (opts.reply) {
|
|
119
|
+
args.parentId = opts.reply;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await client.mutation(api.comments.create, args);
|
|
123
|
+
|
|
124
|
+
spinner.succeed(chalk.green(`Comment posted on ${targetType} ${targetRef}`));
|
|
125
|
+
} catch (err) {
|
|
126
|
+
spinner.fail(chalk.red(`Failed to post comment: ${(err as Error).message}`));
|
|
127
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Creates the `specmarket comment` command.
|
|
133
|
+
*
|
|
134
|
+
* Usage:
|
|
135
|
+
* specmarket comment spec @user/spec "Great spec!"
|
|
136
|
+
* specmarket comment issue @user/spec#3 "I can reproduce this"
|
|
137
|
+
* specmarket comment bounty <bounty-id> "I'm working on this"
|
|
138
|
+
* --reply <comment-id> Reply to a specific comment (threading)
|
|
139
|
+
*/
|
|
140
|
+
export function createCommentCommand(): Command {
|
|
141
|
+
return new Command('comment')
|
|
142
|
+
.description('Post a comment on a spec, issue, or bounty (requires login)')
|
|
143
|
+
.argument(
|
|
144
|
+
'<target-type>',
|
|
145
|
+
'Target type: spec, issue, or bounty'
|
|
146
|
+
)
|
|
147
|
+
.argument(
|
|
148
|
+
'<target-ref>',
|
|
149
|
+
'Target reference (e.g., @user/spec, @user/spec#3, bounty-id)'
|
|
150
|
+
)
|
|
151
|
+
.argument('<body>', 'Comment body text')
|
|
152
|
+
.option(
|
|
153
|
+
'--reply <comment-id>',
|
|
154
|
+
'Reply to a specific comment (threading)'
|
|
155
|
+
)
|
|
156
|
+
.action(
|
|
157
|
+
async (
|
|
158
|
+
targetType: string,
|
|
159
|
+
targetRef: string,
|
|
160
|
+
body: string,
|
|
161
|
+
opts: { reply?: string }
|
|
162
|
+
) => {
|
|
163
|
+
try {
|
|
164
|
+
await handleComment(targetType, targetRef, body, opts);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const error = err as NodeJS.ErrnoException;
|
|
167
|
+
if (error.code === String(EXIT_CODES.AUTH_ERROR)) {
|
|
168
|
+
console.error(chalk.red(error.message));
|
|
169
|
+
process.exit(EXIT_CODES.AUTH_ERROR);
|
|
170
|
+
}
|
|
171
|
+
console.error(chalk.red(`Error: ${error.message}`));
|
|
172
|
+
process.exit(EXIT_CODES.NETWORK_ERROR);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
);
|
|
176
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import { readFile, rm, access } from 'fs/promises';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { tmpdir } from 'os';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
|
|
7
|
+
// --- Hoisted mocks ---
|
|
8
|
+
|
|
9
|
+
const { mockQuery, mockAction, mockClient, mockSpinner } = vi.hoisted(() => {
|
|
10
|
+
const mockQuery = vi.fn();
|
|
11
|
+
const mockAction = vi.fn();
|
|
12
|
+
const mockClient = { query: mockQuery, action: mockAction };
|
|
13
|
+
const mockSpinner = {
|
|
14
|
+
start: vi.fn().mockReturnThis(),
|
|
15
|
+
stop: vi.fn().mockReturnThis(),
|
|
16
|
+
succeed: vi.fn().mockReturnThis(),
|
|
17
|
+
fail: vi.fn().mockReturnThis(),
|
|
18
|
+
text: '',
|
|
19
|
+
};
|
|
20
|
+
return { mockQuery, mockAction, mockClient, mockSpinner };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
vi.mock('../lib/convex-client.js', () => ({
|
|
24
|
+
getConvexClient: vi.fn().mockResolvedValue(mockClient),
|
|
25
|
+
}));
|
|
26
|
+
|
|
27
|
+
vi.mock('../lib/auth.js', () => ({
|
|
28
|
+
requireAuth: vi.fn().mockResolvedValue({
|
|
29
|
+
token: 'test-token',
|
|
30
|
+
username: 'testuser',
|
|
31
|
+
expiresAt: Date.now() + 3600_000,
|
|
32
|
+
}),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock('ora', () => ({
|
|
36
|
+
default: vi.fn().mockReturnValue(mockSpinner),
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
vi.mock('@specmarket/convex/api', () => ({
|
|
40
|
+
api: {
|
|
41
|
+
specs: {
|
|
42
|
+
get: 'specs.get',
|
|
43
|
+
download: 'specs.download',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
}));
|
|
47
|
+
|
|
48
|
+
// Mock exec for unzip
|
|
49
|
+
vi.mock('../lib/exec.js', () => ({
|
|
50
|
+
execAsync: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
|
51
|
+
}));
|
|
52
|
+
|
|
53
|
+
// Mock fetch for download
|
|
54
|
+
const mockFetch = vi.fn();
|
|
55
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
56
|
+
|
|
57
|
+
const mockExit = vi.spyOn(process, 'exit').mockImplementation((() => {
|
|
58
|
+
throw new Error('process.exit called');
|
|
59
|
+
}) as any);
|
|
60
|
+
|
|
61
|
+
const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
62
|
+
vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
63
|
+
|
|
64
|
+
import { handleFork } from './fork.js';
|
|
65
|
+
|
|
66
|
+
// --- Test data ---
|
|
67
|
+
|
|
68
|
+
const MOCK_SPEC = {
|
|
69
|
+
_id: 'spec123',
|
|
70
|
+
scopedName: '@alice/todo-app',
|
|
71
|
+
displayName: 'Todo App',
|
|
72
|
+
slug: 'todo-app',
|
|
73
|
+
currentVersion: '2.0.0',
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
describe('handleFork', () => {
|
|
77
|
+
let targetDir: string;
|
|
78
|
+
|
|
79
|
+
beforeEach(() => {
|
|
80
|
+
vi.clearAllMocks();
|
|
81
|
+
mockExit.mockImplementation((() => {
|
|
82
|
+
throw new Error('process.exit called');
|
|
83
|
+
}) as any);
|
|
84
|
+
targetDir = join(tmpdir(), `fork-test-${randomUUID()}`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterEach(async () => {
|
|
88
|
+
await rm(targetDir, { recursive: true, force: true }).catch(() => {});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('exits with error when spec not found', async () => {
|
|
92
|
+
mockQuery.mockResolvedValue(null);
|
|
93
|
+
|
|
94
|
+
await expect(
|
|
95
|
+
handleFork('@alice/nonexistent', targetDir)
|
|
96
|
+
).rejects.toThrow('process.exit called');
|
|
97
|
+
|
|
98
|
+
expect(mockSpinner.fail).toHaveBeenCalledWith(
|
|
99
|
+
expect.stringContaining('Spec not found')
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('downloads and extracts a spec fork successfully', async () => {
|
|
104
|
+
mockQuery.mockResolvedValue(MOCK_SPEC);
|
|
105
|
+
mockAction.mockResolvedValue({ url: 'https://storage.example.com/spec.zip' });
|
|
106
|
+
|
|
107
|
+
// Create a minimal zip-like response (the actual extraction is mocked via exec.js)
|
|
108
|
+
mockFetch.mockResolvedValue({
|
|
109
|
+
ok: true,
|
|
110
|
+
arrayBuffer: async () => new ArrayBuffer(100),
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// We need to create the targetDir and a spec.yaml in it since the fork handler
|
|
114
|
+
// reads and rewrites spec.yaml after extracting. We'll mock readFile indirectly
|
|
115
|
+
// by having exec mock create the directory structure.
|
|
116
|
+
// Actually, the code does: downloadAndExtract → readFile(specYamlPath).
|
|
117
|
+
// Since exec is mocked, we need to pre-create the spec.yaml file.
|
|
118
|
+
const { mkdir, writeFile } = await import('fs/promises');
|
|
119
|
+
await mkdir(targetDir, { recursive: true });
|
|
120
|
+
await writeFile(
|
|
121
|
+
join(targetDir, 'spec.yaml'),
|
|
122
|
+
'name: todo-app\nversion: "2.0.0"\n'
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
await handleFork('@alice/todo-app', targetDir);
|
|
126
|
+
|
|
127
|
+
expect(mockAction).toHaveBeenCalledWith('specs.download', {
|
|
128
|
+
specId: 'spec123',
|
|
129
|
+
});
|
|
130
|
+
expect(mockSpinner.succeed).toHaveBeenCalledWith(
|
|
131
|
+
expect.stringContaining('Forked')
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
// Verify spec.yaml was updated with fork info
|
|
135
|
+
const updatedYaml = await readFile(join(targetDir, 'spec.yaml'), 'utf-8');
|
|
136
|
+
expect(updatedYaml).toContain('forked_from_id');
|
|
137
|
+
expect(updatedYaml).toContain('spec123');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('uses scopedName query for @-prefixed spec IDs', async () => {
|
|
141
|
+
mockQuery.mockResolvedValue(null);
|
|
142
|
+
|
|
143
|
+
await expect(
|
|
144
|
+
handleFork('@bob/crm', targetDir)
|
|
145
|
+
).rejects.toThrow('process.exit called');
|
|
146
|
+
|
|
147
|
+
expect(mockQuery).toHaveBeenCalledWith('specs.get', {
|
|
148
|
+
scopedName: '@bob/crm',
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('uses specId query for non-scoped spec IDs', async () => {
|
|
153
|
+
mockQuery.mockResolvedValue(null);
|
|
154
|
+
|
|
155
|
+
await expect(
|
|
156
|
+
handleFork('spec123', targetDir)
|
|
157
|
+
).rejects.toThrow('process.exit called');
|
|
158
|
+
|
|
159
|
+
expect(mockQuery).toHaveBeenCalledWith('specs.get', {
|
|
160
|
+
specId: 'spec123',
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
});
|