@vibedx/vibekit 0.8.7 ā 0.9.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/README.md +34 -0
- package/assets/config.yml +4 -0
- package/assets/standards/coding/default.md +15 -0
- package/assets/standards/coding/karpathy.md +58 -0
- package/assets/standards/frameworks/react.md +36 -0
- package/assets/standards/languages/node.md +34 -0
- package/assets/standards/languages/python.md +34 -0
- package/index.js +1 -1
- package/package.json +1 -1
- package/skills/vibekit/SKILL.md +54 -0
- package/src/commands/init/index.js +117 -37
- package/src/commands/init/index.test.js +38 -15
- package/src/commands/plan/index.js +283 -0
- package/src/commands/plan/index.test.js +127 -0
- package/src/commands/plan/to-ticket.js +312 -0
- package/src/commands/plan/to-ticket.test.js +149 -0
- package/src/commands/start/index.js +9 -37
- package/src/commands/swarm/index.js +375 -0
- package/src/utils/agent.js +75 -0
- package/src/utils/swarm.js +75 -0
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import yaml from 'js-yaml';
|
|
4
|
+
import { execSync, spawn } from 'child_process';
|
|
5
|
+
import { getTicketsDir, getConfig } from '../../utils/index.js';
|
|
6
|
+
import {
|
|
7
|
+
isGitRepository,
|
|
8
|
+
getRepoName,
|
|
9
|
+
getRepoRoot,
|
|
10
|
+
getWorktreePath,
|
|
11
|
+
createWorktree,
|
|
12
|
+
createWorktreeExistingBranch,
|
|
13
|
+
branchExistsLocally,
|
|
14
|
+
branchExistsRemotely,
|
|
15
|
+
getDefaultBaseBranch
|
|
16
|
+
} from '../../utils/git.js';
|
|
17
|
+
import toTicketCommand from './to-ticket.js';
|
|
18
|
+
|
|
19
|
+
function parseTicketIds(args) {
|
|
20
|
+
const ids = [];
|
|
21
|
+
const flags = {};
|
|
22
|
+
let i = 0;
|
|
23
|
+
while (i < args.length) {
|
|
24
|
+
const arg = args[i];
|
|
25
|
+
if (arg === '--base' && i + 1 < args.length) {
|
|
26
|
+
flags.baseBranch = args[i + 1];
|
|
27
|
+
i += 2;
|
|
28
|
+
} else if (arg === '--dry-run') {
|
|
29
|
+
flags.dryRun = true;
|
|
30
|
+
i++;
|
|
31
|
+
} else if (arg === '--no-install') {
|
|
32
|
+
flags.noInstall = true;
|
|
33
|
+
i++;
|
|
34
|
+
} else if (arg === '--prompt' && i + 1 < args.length) {
|
|
35
|
+
flags.prompt = args[i + 1];
|
|
36
|
+
i += 2;
|
|
37
|
+
} else if (arg.startsWith('--status=')) {
|
|
38
|
+
flags.statusFilter = arg.split('=')[1];
|
|
39
|
+
i++;
|
|
40
|
+
} else if (!arg.startsWith('-')) {
|
|
41
|
+
ids.push(arg);
|
|
42
|
+
i++;
|
|
43
|
+
} else {
|
|
44
|
+
i++;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return { ids, flags };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function normalizeTicketId(input) {
|
|
51
|
+
if (input.startsWith('TKT-')) return input;
|
|
52
|
+
if (/^\d+$/.test(input)) return `TKT-${input.padStart(3, '0')}`;
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadTicket(ticketsDir, ticketId) {
|
|
57
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.startsWith(`${ticketId}-`));
|
|
58
|
+
if (files.length === 0) return null;
|
|
59
|
+
const filePath = path.join(ticketsDir, files[0]);
|
|
60
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
61
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
62
|
+
if (!match) return null;
|
|
63
|
+
const frontmatter = yaml.load(match[1]);
|
|
64
|
+
return { frontmatter, content, filePath, fileName: files[0] };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function loadTicketsByStatus(ticketsDir, status) {
|
|
68
|
+
const files = fs.readdirSync(ticketsDir).filter(f => f.endsWith('.md'));
|
|
69
|
+
const tickets = [];
|
|
70
|
+
for (const file of files) {
|
|
71
|
+
const filePath = path.join(ticketsDir, file);
|
|
72
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
73
|
+
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
74
|
+
if (match) {
|
|
75
|
+
const frontmatter = yaml.load(match[1]);
|
|
76
|
+
if (frontmatter.status === status) {
|
|
77
|
+
tickets.push({ frontmatter, content, filePath, fileName: file });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return tickets;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getBranchName(ticket, config) {
|
|
85
|
+
const branchPrefix = config.git?.branch_prefix || '';
|
|
86
|
+
const slug = ticket.frontmatter.slug || ticket.frontmatter.id;
|
|
87
|
+
return slug.includes(ticket.frontmatter.id)
|
|
88
|
+
? `${branchPrefix}${slug}`
|
|
89
|
+
: `${branchPrefix}${ticket.frontmatter.id}-${slug}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function updateTicketStatus(ticket, worktreePath) {
|
|
93
|
+
const now = new Date().toISOString();
|
|
94
|
+
let updatedContent = ticket.content;
|
|
95
|
+
|
|
96
|
+
if (updatedContent.match(/^worktree_path: .+$/m)) {
|
|
97
|
+
updatedContent = updatedContent.replace(/^worktree_path: .+$/m, `worktree_path: "${worktreePath}"`);
|
|
98
|
+
} else {
|
|
99
|
+
updatedContent = updatedContent.replace(/^(updated_at: .+)$/m, `$1\nworktree_path: "${worktreePath}"`);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
updatedContent = updatedContent
|
|
103
|
+
.replace(/^status: (.+)$/m, 'status: in_progress')
|
|
104
|
+
.replace(/^updated_at: (.+)$/m, `updated_at: "${now}"`);
|
|
105
|
+
|
|
106
|
+
fs.writeFileSync(ticket.filePath, updatedContent, 'utf-8');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function planCommand(args) {
|
|
110
|
+
// Handle subcommands
|
|
111
|
+
if (args.length > 0 && args[0] === 'to-ticket') {
|
|
112
|
+
return toTicketCommand(args.slice(1));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (!isGitRepository()) {
|
|
116
|
+
console.error('ā Not in a git repository.');
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const { ids, flags } = parseTicketIds(args);
|
|
121
|
+
|
|
122
|
+
if (ids.length === 0 && !flags.statusFilter) {
|
|
123
|
+
console.error('ā Usage:');
|
|
124
|
+
console.error(' vibe plan <TKT-001> <TKT-002> ... Spawn parallel agents for tickets');
|
|
125
|
+
console.error(' vibe plan to-ticket <file> [--auto] [--dry-run] Convert plan to tickets');
|
|
126
|
+
console.error('');
|
|
127
|
+
console.error('Options for parallel agents:');
|
|
128
|
+
console.error(' --status=<status> Start all tickets with this status (e.g. --status=open)');
|
|
129
|
+
console.error(' --base <branch> Base branch for worktrees (default: main)');
|
|
130
|
+
console.error(' --dry-run Show what would happen without doing it');
|
|
131
|
+
console.error(' --no-install Skip npm install in worktrees');
|
|
132
|
+
console.error(' --prompt "..." Custom prompt to pass to each Claude agent');
|
|
133
|
+
console.error('');
|
|
134
|
+
console.error('Options for plan to-ticket:');
|
|
135
|
+
console.error(' --auto Automatically create tickets (shows preview by default)');
|
|
136
|
+
console.error(' --dry-run Show extracted tickets without creating them');
|
|
137
|
+
process.exit(1);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const config = getConfig();
|
|
141
|
+
const ticketsDir = getTicketsDir();
|
|
142
|
+
const repoName = getRepoName();
|
|
143
|
+
const repoRoot = getRepoRoot();
|
|
144
|
+
|
|
145
|
+
let tickets = [];
|
|
146
|
+
|
|
147
|
+
if (flags.statusFilter) {
|
|
148
|
+
tickets = loadTicketsByStatus(ticketsDir, flags.statusFilter);
|
|
149
|
+
if (tickets.length === 0) {
|
|
150
|
+
console.error(`ā No tickets found with status: ${flags.statusFilter}`);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
}
|
|
153
|
+
console.log(`š Found ${tickets.length} ticket(s) with status "${flags.statusFilter}"`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
for (const id of ids) {
|
|
157
|
+
const ticketId = normalizeTicketId(id);
|
|
158
|
+
if (!ticketId) {
|
|
159
|
+
console.error(`ā Invalid ticket ID: ${id}`);
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
const ticket = loadTicket(ticketsDir, ticketId);
|
|
163
|
+
if (!ticket) {
|
|
164
|
+
console.error(`ā Ticket ${ticketId} not found.`);
|
|
165
|
+
process.exit(1);
|
|
166
|
+
}
|
|
167
|
+
if (!tickets.find(t => t.frontmatter.id === ticket.frontmatter.id)) {
|
|
168
|
+
tickets.push(ticket);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (tickets.length === 0) {
|
|
173
|
+
console.error('ā No tickets to start.');
|
|
174
|
+
process.exit(1);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
console.log('');
|
|
178
|
+
console.log(`š Planning ${tickets.length} ticket(s) for parallel work:\n`);
|
|
179
|
+
|
|
180
|
+
const worktreeInfos = [];
|
|
181
|
+
|
|
182
|
+
for (const ticket of tickets) {
|
|
183
|
+
const branchName = getBranchName(ticket, config);
|
|
184
|
+
const worktreePath = getWorktreePath(repoName, branchName);
|
|
185
|
+
const title = ticket.frontmatter.title || 'Untitled';
|
|
186
|
+
|
|
187
|
+
worktreeInfos.push({
|
|
188
|
+
ticket,
|
|
189
|
+
branchName,
|
|
190
|
+
worktreePath,
|
|
191
|
+
title,
|
|
192
|
+
alreadyExists: fs.existsSync(worktreePath)
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const status = fs.existsSync(worktreePath) ? '(exists)' : '(new)';
|
|
196
|
+
console.log(` ${ticket.frontmatter.id} ā ${title}`);
|
|
197
|
+
console.log(` šæ ${branchName} ${status}`);
|
|
198
|
+
console.log(` š ${worktreePath}`);
|
|
199
|
+
console.log('');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (flags.dryRun) {
|
|
203
|
+
console.log('š Dry run complete. No changes made.');
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Create worktrees
|
|
208
|
+
for (const info of worktreeInfos) {
|
|
209
|
+
if (info.alreadyExists) {
|
|
210
|
+
console.log(`ā
${info.ticket.frontmatter.id}: Worktree already exists`);
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
try {
|
|
215
|
+
const branchExists = branchExistsLocally(info.branchName) || branchExistsRemotely(info.branchName);
|
|
216
|
+
if (branchExists) {
|
|
217
|
+
createWorktreeExistingBranch(info.worktreePath, info.branchName);
|
|
218
|
+
} else {
|
|
219
|
+
createWorktree(info.worktreePath, info.branchName, flags.baseBranch);
|
|
220
|
+
}
|
|
221
|
+
console.log(`ā
${info.ticket.frontmatter.id}: Worktree created`);
|
|
222
|
+
} catch (error) {
|
|
223
|
+
console.error(`ā ${info.ticket.frontmatter.id}: Failed to create worktree ā ${error.message}`);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
updateTicketStatus(info.ticket, info.worktreePath);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Install dependencies in worktrees
|
|
231
|
+
if (!flags.noInstall) {
|
|
232
|
+
const pkgExists = fs.existsSync(path.join(repoRoot, 'package.json'));
|
|
233
|
+
if (pkgExists) {
|
|
234
|
+
console.log('\nš¦ Installing dependencies in worktrees...');
|
|
235
|
+
for (const info of worktreeInfos) {
|
|
236
|
+
if (fs.existsSync(path.join(info.worktreePath, 'package.json'))) {
|
|
237
|
+
try {
|
|
238
|
+
execSync('npm install --silent', { cwd: info.worktreePath, stdio: 'ignore' });
|
|
239
|
+
console.log(` ā
${info.ticket.frontmatter.id}: npm install done`);
|
|
240
|
+
} catch (error) {
|
|
241
|
+
console.warn(` ā ļø ${info.ticket.frontmatter.id}: npm install failed ā ${error.message}`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Spawn Claude agents
|
|
249
|
+
console.log('\nš¤ Spawning Claude agents...\n');
|
|
250
|
+
|
|
251
|
+
const agents = [];
|
|
252
|
+
for (const info of worktreeInfos) {
|
|
253
|
+
const ticketContent = fs.readFileSync(info.ticket.filePath, 'utf-8');
|
|
254
|
+
const prompt = flags.prompt
|
|
255
|
+
? flags.prompt
|
|
256
|
+
: `You are working on ticket ${info.ticket.frontmatter.id}: ${info.title}\n\nHere is the full ticket:\n\n${ticketContent}\n\nImplement the ticket requirements. Follow the acceptance criteria. Commit your work when done.`;
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const agentProcess = spawn('claude', ['-p', prompt, '--allowedTools', 'Edit,Write,Bash,Read,Glob,Grep'], {
|
|
260
|
+
cwd: info.worktreePath,
|
|
261
|
+
stdio: 'ignore',
|
|
262
|
+
detached: true
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
agentProcess.unref();
|
|
266
|
+
agents.push({ id: info.ticket.frontmatter.id, pid: agentProcess.pid });
|
|
267
|
+
console.log(` š¤ ${info.ticket.frontmatter.id}: Agent spawned (PID ${agentProcess.pid}) in ${info.worktreePath}`);
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error(` ā ${info.ticket.frontmatter.id}: Failed to spawn agent ā ${error.message}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
console.log('\nš All agents launched!\n');
|
|
274
|
+
console.log('Monitor progress:');
|
|
275
|
+
console.log(' vibe list --status=in_progress # see active tickets');
|
|
276
|
+
console.log(' git worktree list # see all worktrees');
|
|
277
|
+
console.log('');
|
|
278
|
+
console.log('When agents finish, open PRs:');
|
|
279
|
+
console.log(' vibe pr --all # open PRs for all worktree branches');
|
|
280
|
+
console.log(` vibe pr ${tickets.map(t => t.frontmatter.id).join(' ')} # open PRs for specific tickets`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export default planCommand;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { jest } from '@jest/globals';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { execSync } from 'child_process';
|
|
5
|
+
|
|
6
|
+
// Mock dependencies
|
|
7
|
+
jest.unstable_mockModule('child_process', () => ({
|
|
8
|
+
execSync: jest.fn(),
|
|
9
|
+
spawn: jest.fn(() => ({ unref: jest.fn(), pid: 12345 }))
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
let ticketIdCounter = 1;
|
|
13
|
+
jest.unstable_mockModule('../../utils/index.js', () => ({
|
|
14
|
+
getTicketsDir: jest.fn(),
|
|
15
|
+
getConfig: jest.fn(() => ({ git: { branch_prefix: 'feature/' } })),
|
|
16
|
+
createSlug: jest.fn(title => title.toLowerCase().replace(/\s+/g, '-')),
|
|
17
|
+
getNextTicketId: jest.fn(() => `TKT-${String(ticketIdCounter++).padStart(3, '0')}`)
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
jest.unstable_mockModule('../../utils/git.js', () => ({
|
|
21
|
+
isGitRepository: jest.fn(() => true),
|
|
22
|
+
getRepoName: jest.fn(() => 'test-repo'),
|
|
23
|
+
getRepoRoot: jest.fn(() => '/tmp/test-repo'),
|
|
24
|
+
getWorktreePath: jest.fn((repo, branch) => `/tmp/.vibekit/worktrees/${repo}/${branch}`),
|
|
25
|
+
createWorktree: jest.fn(),
|
|
26
|
+
createWorktreeExistingBranch: jest.fn(),
|
|
27
|
+
branchExistsLocally: jest.fn(() => false),
|
|
28
|
+
branchExistsRemotely: jest.fn(() => false),
|
|
29
|
+
getDefaultBaseBranch: jest.fn(() => 'main')
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
const { default: planCommand } = await import('./index.js');
|
|
33
|
+
const { getTicketsDir, getConfig } = await import('../../utils/index.js');
|
|
34
|
+
const { isGitRepository, getWorktreePath, createWorktree } = await import('../../utils/git.js');
|
|
35
|
+
const childProcess = await import('child_process');
|
|
36
|
+
|
|
37
|
+
const TICKET_CONTENT = `---
|
|
38
|
+
id: TKT-001
|
|
39
|
+
title: Add login page
|
|
40
|
+
slug: TKT-001-add-login-page
|
|
41
|
+
status: open
|
|
42
|
+
priority: high
|
|
43
|
+
assignee: dev1
|
|
44
|
+
author: pm
|
|
45
|
+
created_at: "2026-01-01T00:00:00Z"
|
|
46
|
+
updated_at: "2026-01-01T00:00:00Z"
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Description
|
|
50
|
+
Add a login page.
|
|
51
|
+
|
|
52
|
+
## Acceptance Criteria
|
|
53
|
+
- [ ] Login form exists
|
|
54
|
+
`;
|
|
55
|
+
|
|
56
|
+
describe('plan command', () => {
|
|
57
|
+
let tempDir;
|
|
58
|
+
let mockExit;
|
|
59
|
+
let mockConsoleLog;
|
|
60
|
+
let mockConsoleError;
|
|
61
|
+
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
tempDir = fs.mkdtempSync(path.join('/tmp', 'vibe-plan-test-'));
|
|
64
|
+
const ticketsDir = path.join(tempDir, '.vibe', 'tickets');
|
|
65
|
+
fs.mkdirSync(ticketsDir, { recursive: true });
|
|
66
|
+
fs.writeFileSync(path.join(ticketsDir, 'TKT-001-add-login-page.md'), TICKET_CONTENT);
|
|
67
|
+
|
|
68
|
+
getTicketsDir.mockReturnValue(ticketsDir);
|
|
69
|
+
mockExit = jest.spyOn(process, 'exit').mockImplementation(() => { throw new Error('process.exit'); });
|
|
70
|
+
mockConsoleLog = jest.spyOn(console, 'log').mockImplementation(() => {});
|
|
71
|
+
mockConsoleError = jest.spyOn(console, 'error').mockImplementation(() => {});
|
|
72
|
+
childProcess.execSync.mockImplementation(() => '');
|
|
73
|
+
|
|
74
|
+
// Worktree path doesn't exist yet
|
|
75
|
+
getWorktreePath.mockReturnValue(path.join(tempDir, 'worktree-TKT-001'));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
afterEach(() => {
|
|
79
|
+
mockExit.mockRestore();
|
|
80
|
+
mockConsoleLog.mockRestore();
|
|
81
|
+
mockConsoleError.mockRestore();
|
|
82
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
83
|
+
jest.clearAllMocks();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('shows usage when no arguments provided', async () => {
|
|
87
|
+
await expect(planCommand([])).rejects.toThrow('process.exit');
|
|
88
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test('exits if not in git repo', async () => {
|
|
92
|
+
isGitRepository.mockReturnValueOnce(false);
|
|
93
|
+
await expect(planCommand(['TKT-001'])).rejects.toThrow('process.exit');
|
|
94
|
+
expect(mockConsoleError).toHaveBeenCalledWith('ā Not in a git repository.');
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test('dry run shows plan without creating worktrees', async () => {
|
|
98
|
+
await planCommand(['TKT-001', '--dry-run']);
|
|
99
|
+
expect(mockConsoleLog).toHaveBeenCalledWith('š Dry run complete. No changes made.');
|
|
100
|
+
expect(createWorktree).not.toHaveBeenCalled();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('creates worktree and spawns agent for ticket', async () => {
|
|
104
|
+
await planCommand(['TKT-001', '--no-install']);
|
|
105
|
+
expect(createWorktree).toHaveBeenCalled();
|
|
106
|
+
expect(childProcess.spawn).toHaveBeenCalledWith(
|
|
107
|
+
'claude',
|
|
108
|
+
expect.arrayContaining(['-p']),
|
|
109
|
+
expect.objectContaining({ stdio: 'ignore', detached: true })
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('handles --status=open filter', async () => {
|
|
114
|
+
await planCommand(['--status=open', '--dry-run']);
|
|
115
|
+
expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('TKT-001'));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('errors on invalid ticket ID', async () => {
|
|
119
|
+
await expect(planCommand(['INVALID'])).rejects.toThrow('process.exit');
|
|
120
|
+
expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Invalid ticket ID'));
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('errors when ticket not found', async () => {
|
|
124
|
+
await expect(planCommand(['TKT-999'])).rejects.toThrow('process.exit');
|
|
125
|
+
expect(mockConsoleError).toHaveBeenCalledWith('ā Ticket TKT-999 not found.');
|
|
126
|
+
});
|
|
127
|
+
});
|