@testcollab/cli 1.3.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.
@@ -0,0 +1,430 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import readline from 'readline';
4
+ import Anthropic from '@anthropic-ai/sdk';
5
+ import { GoogleGenerativeAI } from '@google/generative-ai';
6
+ import { aiDiscoverTargets } from '../ai/discovery.js';
7
+
8
+ const DEFAULT_MODEL = 'claude-opus-4-5-20251101';
9
+ const IGNORED_DIRS = new Set(['node_modules', '.git', '.testcollab', 'dist', 'build', 'coverage', '.next', '.turbo']);
10
+ const MAX_FILES_FOR_AI_DISCOVERY = 40;
11
+ const SNIPPET_LINES = 80;
12
+
13
+ export async function specgen(options) {
14
+ const cwd = process.cwd();
15
+ const srcRoot = path.resolve(cwd, options.src || './src');
16
+ const outRoot = path.resolve(cwd, options.out || './features');
17
+ const cachePath = path.resolve(cwd, options.cache || '.testcollab/specgen.json');
18
+ const model = options.model || DEFAULT_MODEL;
19
+ const skipConfirm = options.yes === true;
20
+ const dryRun = options.dryRun === true;
21
+
22
+ console.log(`🔍 Discovering targets in ${srcRoot}`);
23
+ const srcExists = fs.existsSync(srcRoot);
24
+ if (!srcExists) {
25
+ console.error(`❌ Source directory not found: ${srcRoot}`);
26
+ process.exit(1);
27
+ }
28
+
29
+ const provider = detectProvider(model);
30
+ const { anthropic, gemini } = initProviders(provider);
31
+
32
+ const cacheInfo = await loadCache(cachePath);
33
+ let discovery;
34
+
35
+ if (!cacheInfo.exists) {
36
+ console.log('🧭 No cache found; running AI-assisted discovery for families and targets...');
37
+ discovery = await aiDiscoverTargets({
38
+ provider,
39
+ anthropic,
40
+ gemini,
41
+ model,
42
+ summaries: await collectFileSummaries(srcRoot, MAX_FILES_FOR_AI_DISCOVERY),
43
+ outRoot,
44
+ snippetLines: SNIPPET_LINES,
45
+ });
46
+ if (!discovery) {
47
+ console.warn('âš ī¸ AI discovery unavailable; aborting.');
48
+ process.exit(1);
49
+ }
50
+ } else {
51
+ const fresh = await discoverTargets(srcRoot, outRoot);
52
+ discovery = mergeCache(cacheInfo.data, fresh);
53
+ }
54
+
55
+ discovery = ensureOutputPaths(discovery, outRoot);
56
+
57
+ printDiscovery(discovery);
58
+
59
+ if (!skipConfirm) {
60
+ const ok = await confirm(`Proceed with ${dryRun ? 'dry run (no files written)' : 'generation'}? [y/N] `);
61
+ if (!ok) {
62
+ console.log('đŸšĢ Aborted.');
63
+ return;
64
+ }
65
+ }
66
+
67
+ await ensureDir(path.dirname(cachePath));
68
+ const merged = discovery;
69
+
70
+ if (dryRun) {
71
+ console.log('✅ Dry run complete (cache not updated, no features written).');
72
+ return;
73
+ }
74
+
75
+ for (const target of merged.targets) {
76
+ try {
77
+ const aiResult = await generateFeatureWithModel({
78
+ provider,
79
+ anthropic,
80
+ gemini,
81
+ model,
82
+ target,
83
+ });
84
+ const featureText = aiResult?.feature || fallbackFeature(target);
85
+ await writeFeature(target.output_path, featureText);
86
+ if (aiResult?.notes?.length) {
87
+ console.log(`â„šī¸ Notes for ${target.id}: ${aiResult.notes.join(' | ')}`);
88
+ }
89
+ console.log(`✨ Wrote ${target.output_path}`);
90
+ } catch (err) {
91
+ console.error(`❌ Failed to generate for ${target.id}: ${err.message}`);
92
+ }
93
+ }
94
+
95
+ await saveCache(cachePath, merged);
96
+ console.log(`💾 Cached discovery at ${cachePath}`);
97
+ console.log('✅ specgen finished');
98
+ }
99
+
100
+ async function discoverTargets(srcRoot, outRoot) {
101
+ const files = await walkFiles(srcRoot);
102
+ const families = new Map();
103
+ const targets = [];
104
+
105
+ for (const file of files) {
106
+ const rel = path.relative(srcRoot, file);
107
+ if (!rel || rel.startsWith('..')) continue;
108
+ const segments = rel.split(path.sep);
109
+ const familyName = segments.length > 1 ? segments[0] : 'root';
110
+ const familyKind = inferFamilyKind(familyName);
111
+ const targetId = rel.replace(/\.[^.]+$/, '').replace(/[\\/]/g, '-');
112
+ const type = inferTargetType(rel);
113
+ const output_path = path.join(outRoot, familyName, `${targetId}.feature`);
114
+ const snippet = await readSnippet(file);
115
+
116
+ if (!families.has(familyName)) {
117
+ families.set(familyName, {
118
+ name: familyName,
119
+ kind: familyKind,
120
+ paths: [path.join(srcRoot, familyName)],
121
+ priority: 'core',
122
+ output_root: path.join(outRoot, familyName),
123
+ });
124
+ }
125
+
126
+ targets.push({
127
+ id: targetId,
128
+ family: familyName,
129
+ type,
130
+ entry: [path.join(srcRoot, rel)],
131
+ context: [],
132
+ routes: [],
133
+ priority: 'core',
134
+ confidence_flags: [],
135
+ output_path,
136
+ snippet,
137
+ relative: rel,
138
+ });
139
+ }
140
+
141
+ return {
142
+ families: Array.from(families.values()),
143
+ targets,
144
+ };
145
+ }
146
+
147
+ async function collectFileSummaries(srcRoot, limit = MAX_FILES_FOR_AI_DISCOVERY) {
148
+ const files = await walkFiles(srcRoot);
149
+ const limited = files.slice(0, limit);
150
+ const summaries = [];
151
+ for (const file of limited) {
152
+ const rel = path.relative(srcRoot, file);
153
+ const snippet = await readSnippet(file, SNIPPET_LINES);
154
+ summaries.push({ rel, abs: file, snippet });
155
+ }
156
+ return summaries;
157
+ }
158
+
159
+ async function walkFiles(dir) {
160
+ const out = [];
161
+ const entries = await fs.promises.readdir(dir, { withFileTypes: true });
162
+ for (const entry of entries) {
163
+ if (IGNORED_DIRS.has(entry.name)) continue;
164
+ const full = path.join(dir, entry.name);
165
+ if (entry.isDirectory()) {
166
+ const nested = await walkFiles(full);
167
+ out.push(...nested);
168
+ } else if (entry.isFile() && isCodeFile(entry.name)) {
169
+ out.push(full);
170
+ }
171
+ }
172
+ return out;
173
+ }
174
+
175
+ function isCodeFile(name) {
176
+ return /\.(ts|tsx|js|jsx|mjs|cjs)$/.test(name);
177
+ }
178
+
179
+ function inferFamilyKind(familyName) {
180
+ if (familyName.includes('api')) return 'backend';
181
+ if (familyName.includes('component') || familyName.includes('page')) return 'ui';
182
+ return 'backend';
183
+ }
184
+
185
+ function inferTargetType(relPath) {
186
+ const lower = relPath.toLowerCase();
187
+ if (lower.includes('controller')) return 'api_controller';
188
+ if (lower.includes('service')) return 'service';
189
+ if (lower.includes('route') || lower.includes('router')) return 'api_controller';
190
+ if (lower.includes('component') || lower.endsWith('.tsx') || lower.endsWith('.jsx')) return 'ui_component';
191
+ if (lower.includes('job') || lower.includes('cron')) return 'job';
192
+ if (lower.includes('cli')) return 'cli_command';
193
+ return 'module';
194
+ }
195
+
196
+ async function readSnippet(file, lines = 120) {
197
+ try {
198
+ const contents = await fs.promises.readFile(file, 'utf8');
199
+ return contents.split('\n').slice(0, lines).join('\n');
200
+ } catch (err) {
201
+ return '';
202
+ }
203
+ }
204
+
205
+ const FEATURE_SCHEMA = {
206
+ name: 'FeatureSpec',
207
+ schema: {
208
+ type: 'object',
209
+ additionalProperties: false,
210
+ properties: {
211
+ feature: {
212
+ type: 'string',
213
+ description: 'A single Gherkin feature file with 2-4 scenarios.',
214
+ },
215
+ notes: {
216
+ type: 'array',
217
+ description: 'Optional warnings or low-confidence callouts.',
218
+ items: { type: 'string' },
219
+ },
220
+ },
221
+ required: ['feature'],
222
+ },
223
+ };
224
+
225
+ function buildPrompt(target) {
226
+ return [
227
+ 'You are specgen. Produce a concise Gherkin feature for the given target.',
228
+ '',
229
+ `Target ID: ${target.id}`,
230
+ `Family: ${target.family}`,
231
+ `Type: ${target.type}`,
232
+ `Relative path: ${target.relative}`,
233
+ '',
234
+ 'Code (trimmed):',
235
+ '---',
236
+ target.snippet || '[no content]',
237
+ '---',
238
+ '',
239
+ 'Instructions:',
240
+ '- Return JSON matching the provided schema.',
241
+ '- feature: valid Gherkin with one Feature and 2-4 Scenarios.',
242
+ '- Include at least one happy path and, if visible, one edge/error path.',
243
+ '- Use domain-neutral, clear steps; mark uncertain titles with "(draft)".',
244
+ '- notes: add warnings or gaps when context is thin.',
245
+ ].join('\n');
246
+ }
247
+
248
+ async function callAnthropic(client, model, target) {
249
+ const prompt = buildPrompt(target);
250
+ const resp = await client.messages.create({
251
+ model,
252
+ max_tokens: 4000,
253
+ temperature: 0.2,
254
+ response_format: { type: 'json_schema', json_schema: FEATURE_SCHEMA },
255
+ messages: [
256
+ {
257
+ role: 'user',
258
+ content: [
259
+ {
260
+ type: 'text',
261
+ text: prompt,
262
+ },
263
+ ],
264
+ },
265
+ ],
266
+ });
267
+ const text = resp?.content?.[0]?.text;
268
+ if (!text) {
269
+ throw new Error('Empty response from model');
270
+ }
271
+ return JSON.parse(text);
272
+ }
273
+
274
+ async function callGemini(geminiClient, model, target) {
275
+ const prompt = buildPrompt(target);
276
+ const modelHandle = geminiClient.getGenerativeModel({
277
+ model,
278
+ generationConfig: {
279
+ responseMimeType: 'application/json',
280
+ responseSchema: FEATURE_SCHEMA.schema,
281
+ },
282
+ });
283
+ const result = await modelHandle.generateContent([{ text: prompt }]);
284
+ const text = result?.response?.text();
285
+ if (!text) {
286
+ throw new Error('Empty response from model');
287
+ }
288
+ return JSON.parse(text);
289
+ }
290
+
291
+ async function generateFeatureWithModel({ provider, anthropic, gemini, model, target }) {
292
+ if (provider === 'anthropic') {
293
+ return callAnthropic(anthropic, model, target);
294
+ }
295
+ if (provider === 'gemini') {
296
+ return callGemini(gemini, model, target);
297
+ }
298
+ throw new Error(`Unsupported provider for feature generation: ${provider}`);
299
+ }
300
+
301
+ function fallbackFeature(target) {
302
+ return [
303
+ `Feature: ${target.id} (draft)`,
304
+ '',
305
+ ` Scenario: Basic flow for ${target.id}`,
306
+ ' Given the system initializes the module',
307
+ ` When the ${target.id} behavior executes`,
308
+ ' Then the expected outcome is observed',
309
+ ].join('\n');
310
+ }
311
+
312
+ async function writeFeature(outPath, body) {
313
+ await ensureDir(path.dirname(outPath));
314
+ await fs.promises.writeFile(outPath, body, 'utf8');
315
+ }
316
+
317
+ function printDiscovery(discovery) {
318
+ console.log(`📂 Families: ${discovery.families.length}`);
319
+ for (const fam of discovery.families) {
320
+ const count = discovery.targets.filter((t) => t.family === fam.name).length;
321
+ console.log(` - ${fam.name} (${count} targets) -> ${fam.output_root}`);
322
+ }
323
+ console.log(`đŸŽ¯ Targets: ${discovery.targets.length}`);
324
+ }
325
+
326
+ async function ensureDir(dir) {
327
+ await fs.promises.mkdir(dir, { recursive: true });
328
+ }
329
+
330
+ async function loadCache(cachePath) {
331
+ try {
332
+ const data = await fs.promises.readFile(cachePath, 'utf8');
333
+ return { exists: true, data: JSON.parse(data) };
334
+ } catch (err) {
335
+ if (err.code === 'ENOENT') {
336
+ return { exists: false, data: { families: [], targets: [] } };
337
+ }
338
+ throw err;
339
+ }
340
+ }
341
+
342
+ function mergeCache(cache, discovery) {
343
+ // Keep latest discovery; carry over existing when ids match
344
+ const familyMap = new Map((cache?.families || []).map((f) => [f.name, f]));
345
+ for (const fam of discovery.families || []) {
346
+ familyMap.set(fam.name, { ...familyMap.get(fam.name), ...fam });
347
+ }
348
+
349
+ const targetMap = new Map((cache?.targets || []).map((t) => [t.id, t]));
350
+ for (const t of discovery.targets || []) {
351
+ const prev = targetMap.get(t.id) || {};
352
+ targetMap.set(t.id, { ...prev, ...t });
353
+ }
354
+
355
+ return {
356
+ families: Array.from(familyMap.values()),
357
+ targets: Array.from(targetMap.values()),
358
+ updated_at: new Date().toISOString(),
359
+ };
360
+ }
361
+
362
+ async function saveCache(cachePath, data) {
363
+ await ensureDir(path.dirname(cachePath));
364
+ await fs.promises.writeFile(cachePath, JSON.stringify(data, null, 2), 'utf8');
365
+ }
366
+
367
+ async function confirm(question) {
368
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
369
+ const answer = await new Promise((resolve) => rl.question(question, resolve));
370
+ rl.close();
371
+ return ['y', 'yes'].includes(answer.trim().toLowerCase());
372
+ }
373
+
374
+ function ensureOutputPaths(discovery, outRoot) {
375
+ const famMap = new Map();
376
+ for (const fam of discovery.families || []) {
377
+ famMap.set(fam.name, {
378
+ ...fam,
379
+ output_root: fam.output_root || path.join(outRoot, fam.name),
380
+ });
381
+ }
382
+
383
+ const targets = (discovery.targets || []).map((t) => {
384
+ const outputRoot = famMap.get(t.family)?.output_root || outRoot;
385
+ const baseName = t.output_path
386
+ ? path.basename(t.output_path, path.extname(t.output_path))
387
+ : t.id || 'target';
388
+ const finalPath =
389
+ t.output_path ||
390
+ path.join(outputRoot, `${baseName}.feature`);
391
+ return {
392
+ ...t,
393
+ output_path: finalPath,
394
+ };
395
+ });
396
+
397
+ return {
398
+ families: Array.from(famMap.values()),
399
+ targets,
400
+ };
401
+ }
402
+
403
+ function detectProvider(model) {
404
+ const lower = (model || '').toLowerCase();
405
+ if (lower.includes('gemini')) return 'gemini';
406
+ return 'anthropic';
407
+ }
408
+
409
+ function initProviders(provider) {
410
+ if (provider === 'anthropic') {
411
+ const key = process.env.ANTHROPIC_API_KEY;
412
+ if (!key) {
413
+ console.error('❌ Error: ANTHROPIC_API_KEY is required for specgen when using Claude models.');
414
+ process.exit(1);
415
+ }
416
+ return { anthropic: new Anthropic({ apiKey: key }), gemini: null };
417
+ }
418
+
419
+ if (provider === 'gemini') {
420
+ const key = process.env.GOOGLE_GENAI_API_KEY || process.env.GEMINI_API_KEY;
421
+ if (!key) {
422
+ console.error('❌ Error: GOOGLE_GENAI_API_KEY (or GEMINI_API_KEY) is required for specgen when using Gemini models.');
423
+ process.exit(1);
424
+ }
425
+ return { anthropic: null, gemini: new GoogleGenerativeAI(key) };
426
+ }
427
+
428
+ console.error(`❌ Unsupported model/provider for specgen: ${provider}`);
429
+ process.exit(1);
430
+ }
package/src/index.js ADDED
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * TestCollab CLI - Main Entry Point
5
+ *
6
+ * A command-line interface for TestCollab operations.
7
+ * Provides various commands for managing TestCollab projects.
8
+ */
9
+
10
+ import { Command } from 'commander';
11
+ import { featuresync } from './commands/featuresync.js';
12
+ import { createTestPlan } from './commands/createTestPlan.js';
13
+ import { report } from './commands/report.js';
14
+ import { specgen } from './commands/specgen.js';
15
+
16
+ // Initialize commanderq
17
+ const program = new Command();
18
+
19
+ program
20
+ .name('tc')
21
+ .description('TestCollab CLI - Command-line interface for TestCollab operations')
22
+ .version('1.0.0');
23
+
24
+ // Add sync command
25
+ program
26
+ .command('sync')
27
+ .description('Synchronize Gherkin feature files with TestCollab using Git')
28
+ .option('--api-key <key>', 'TestCollab API key (or set TESTCOLLAB_TOKEN env var)')
29
+ .requiredOption('--project <id>', 'TestCollab project ID')
30
+ .option('--api-url <url>', 'TestCollab API base URL', 'https://api.testcollab.io')
31
+ .action(featuresync);
32
+
33
+ // Add createTestPlan command
34
+ program
35
+ .command('createTestPlan')
36
+ .description('Create a new Test Plan, add CI-tagged cases, and assign it')
37
+ .option('--api-key <key>', 'TestCollab API key (or set TESTCOLLAB_TOKEN env var)')
38
+ .requiredOption('--project <id>', 'TestCollab project ID')
39
+ .requiredOption('--ci-tag-id <id>', 'CI tag ID to include cases')
40
+ .requiredOption('--assignee-id <id>', 'User ID to assign execution')
41
+ .option('--api-url <url>', 'TestCollab API base URL', 'https://api.testcollab.io')
42
+ .action(createTestPlan);
43
+
44
+ // Add report command
45
+ program
46
+ .command('report')
47
+ .description('Upload test results (Mochawesome JSON or JUnit XML) to TestCollab and attach to a Test Plan')
48
+ .option('--api-key <key>', 'TestCollab API key (or set TESTCOLLAB_TOKEN env var)')
49
+ .requiredOption('--project <id>', 'TestCollab project ID')
50
+ .requiredOption('--test-plan-id <id>', 'Test Plan ID')
51
+ .requiredOption('--format <type>', 'Result format: mochawesome or junit')
52
+ .requiredOption('--result-file <path>', 'Path to test result file')
53
+ .option('--api-url <url>', 'TestCollab API base URL override', 'https://api.testcollab.io')
54
+ .action(report);
55
+
56
+ // Add specgen command
57
+ program
58
+ .command('specgen')
59
+ .description('Generate Gherkin `.feature` files by crawling source code with AI assistance')
60
+ .option('--src <path>', 'Source directory to analyze', './src')
61
+ .option('--out <path>', 'Output directory for generated `.feature` files', './features')
62
+ .option('--cache <path>', 'Cache file for discovered targets/families', '.testcollab/specgen.json')
63
+ .option('--model <name>', 'Anthropic model to use', 'claude-sonnet-4-5-20250929')
64
+ .option('--yes', 'Skip confirmation prompts', false)
65
+ .option('--dry-run', 'Discover and preview targets without generating files', false)
66
+ .action(specgen);
67
+
68
+ // Parse command line arguments and execute the program
69
+ program.parse(process.argv);
70
+
71
+ // Show help if no command is provided
72
+ if (!process.argv.slice(2).length) {
73
+ program.outputHelp();
74
+ }