@boolesai/tspec-cli 1.0.0 → 1.2.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 CHANGED
@@ -21,7 +21,7 @@ npx @boolesai/tspec-cli <command>
21
21
 
22
22
  ### `tspec validate`
23
23
 
24
- Validate `.tspec` files for schema correctness.
24
+ Validate `.tcase` files for schema correctness.
25
25
 
26
26
  ```bash
27
27
  tspec validate <files...> [options]
@@ -34,13 +34,13 @@ tspec validate <files...> [options]
34
34
  **Examples:**
35
35
  ```bash
36
36
  # Validate a single file
37
- tspec validate tests/login.http.tspec
37
+ tspec validate tests/login.http.tcase
38
38
 
39
39
  # Validate multiple files with glob pattern
40
- tspec validate "tests/**/*.tspec"
40
+ tspec validate "tests/**/*.tcase"
41
41
 
42
42
  # JSON output for CI/CD
43
- tspec validate tests/*.tspec --output json
43
+ tspec validate tests/*.tcase --output json
44
44
  ```
45
45
 
46
46
  ### `tspec run`
@@ -63,25 +63,25 @@ tspec run <files...> [options]
63
63
  **Examples:**
64
64
  ```bash
65
65
  # Run tests with default settings
66
- tspec run tests/*.http.tspec
66
+ tspec run tests/*.http.tcase
67
67
 
68
68
  # Run with environment variables
69
- tspec run tests/*.tspec -e API_HOST=api.example.com -e API_KEY=secret
69
+ tspec run tests/*.tcase -e API_HOST=api.example.com -e API_KEY=secret
70
70
 
71
71
  # Run with parameters
72
- tspec run tests/*.tspec -p username=testuser -p timeout=5000
72
+ tspec run tests/*.tcase -p username=testuser -p timeout=5000
73
73
 
74
74
  # Run with higher concurrency
75
- tspec run tests/*.tspec -c 10
75
+ tspec run tests/*.tcase -c 10
76
76
 
77
77
  # Verbose output for debugging
78
- tspec run tests/*.tspec -v
78
+ tspec run tests/*.tcase -v
79
79
 
80
80
  # JSON output for CI/CD
81
- tspec run tests/*.tspec --output json
81
+ tspec run tests/*.tcase --output json
82
82
 
83
83
  # Stop on first failure
84
- tspec run tests/*.tspec --fail-fast
84
+ tspec run tests/*.tcase --fail-fast
85
85
  ```
86
86
 
87
87
  ### `tspec parse`
@@ -102,13 +102,13 @@ tspec parse <files...> [options]
102
102
  **Examples:**
103
103
  ```bash
104
104
  # Parse and display test cases
105
- tspec parse tests/login.http.tspec
105
+ tspec parse tests/login.http.tcase
106
106
 
107
107
  # JSON output for inspection
108
- tspec parse tests/*.tspec --output json
108
+ tspec parse tests/*.tcase --output json
109
109
 
110
110
  # With variable substitution
111
- tspec parse tests/*.tspec -e API_HOST=localhost
111
+ tspec parse tests/*.tcase -e API_HOST=localhost
112
112
  ```
113
113
 
114
114
  ### `tspec list`
@@ -150,7 +150,7 @@ TSpec CLI can run as an MCP server, exposing all commands as tools for AI assist
150
150
  | Tool | Description |
151
151
  |------|-------------|
152
152
  | `tspec_run` | Execute test cases and report results |
153
- | `tspec_validate` | Validate .tspec files for schema correctness |
153
+ | `tspec_validate` | Validate .tcase files for schema correctness |
154
154
  | `tspec_parse` | Parse and display test case information |
155
155
  | `tspec_list` | List supported protocols |
156
156
 
@@ -191,7 +191,7 @@ Or if installed globally:
191
191
 
192
192
  ```json
193
193
  {
194
- "files": ["tests/*.tspec"],
194
+ "files": ["tests/*.tcase"],
195
195
  "concurrency": 5,
196
196
  "env": { "API_HOST": "localhost" },
197
197
  "params": { "timeout": "5000" },
@@ -204,7 +204,7 @@ Or if installed globally:
204
204
 
205
205
  ```json
206
206
  {
207
- "files": ["tests/*.tspec"],
207
+ "files": ["tests/*.tcase"],
208
208
  "output": "text"
209
209
  }
210
210
  ```
@@ -213,7 +213,7 @@ Or if installed globally:
213
213
 
214
214
  ```json
215
215
  {
216
- "files": ["tests/*.tspec"],
216
+ "files": ["tests/*.tcase"],
217
217
  "env": { "API_HOST": "localhost" },
218
218
  "params": { "timeout": "5000" },
219
219
  "verbose": true,
@@ -244,10 +244,10 @@ Or if installed globally:
244
244
  ```yaml
245
245
  - name: Run TSpec tests
246
246
  run: |
247
- npx @boolesai/tspec-cli run tests/*.tspec --output json > results.json
247
+ npx @boolesai/tspec-cli run tests/*.tcase --output json > results.json
248
248
 
249
249
  - name: Validate TSpec files
250
- run: npx @boolesai/tspec-cli validate tests/*.tspec
250
+ run: npx @boolesai/tspec-cli validate tests/*.tcase
251
251
  ```
252
252
 
253
253
  ### GitLab CI
@@ -255,7 +255,7 @@ Or if installed globally:
255
255
  ```yaml
256
256
  test:
257
257
  script:
258
- - npx @boolesai/tspec-cli run tests/*.tspec --output json
258
+ - npx @boolesai/tspec-cli run tests/*.tcase --output json
259
259
  artifacts:
260
260
  reports:
261
261
  junit: results.json
package/dist/index.js CHANGED
@@ -1,9 +1,10 @@
1
1
  import { Command } from "commander";
2
+ import { existsSync, statSync, readFileSync } from "fs";
3
+ import { fileURLToPath } from "url";
4
+ import { isAbsolute, resolve as resolve$1, basename, relative, dirname, join } from "path";
2
5
  import ora from "ora";
3
- import { getTypeFromFilePath, validateTestCase, clearTemplateCache, parseTestCases, scheduler, registry as registry$1 } from "@boolesai/tspec";
6
+ import { getTypeFromFilePath, isSuiteFile, getSuiteProtocolType, validateTestCase, clearTemplateCache, executeSuite, parseTestCases, scheduler, registry as registry$1 } from "@boolesai/tspec";
4
7
  import { glob } from "glob";
5
- import { isAbsolute, resolve as resolve$1, basename, relative } from "path";
6
- import { existsSync, statSync } from "fs";
7
8
  import chalk from "chalk";
8
9
  import process$2 from "node:process";
9
10
  async function discoverTSpecFiles(patterns, cwd) {
@@ -15,14 +16,14 @@ async function discoverTSpecFiles(patterns, cwd) {
15
16
  if (existsSync(absolutePath)) {
16
17
  const stat = statSync(absolutePath);
17
18
  if (stat.isFile()) {
18
- if (absolutePath.endsWith(".tspec")) {
19
+ if (absolutePath.endsWith(".tcase")) {
19
20
  filePaths.push(absolutePath);
20
21
  }
21
22
  continue;
22
23
  } else if (stat.isDirectory()) {
23
- const dirFiles = await glob("**/*.tspec", { cwd: absolutePath, absolute: true });
24
+ const dirFiles = await glob("**/*.tcase", { cwd: absolutePath, absolute: true });
24
25
  if (dirFiles.length === 0) {
25
- errors2.push(`No .tspec files found in directory: ${pattern2}`);
26
+ errors2.push(`No .tcase files found in directory: ${pattern2}`);
26
27
  } else {
27
28
  filePaths.push(...dirFiles);
28
29
  }
@@ -30,9 +31,9 @@ async function discoverTSpecFiles(patterns, cwd) {
30
31
  }
31
32
  }
32
33
  const matches = await glob(pattern2, { cwd: workingDir, absolute: true });
33
- const tspecMatches = matches.filter((f) => f.endsWith(".tspec"));
34
+ const tspecMatches = matches.filter((f) => f.endsWith(".tcase"));
34
35
  if (tspecMatches.length === 0) {
35
- errors2.push(`No .tspec files matched pattern: ${pattern2}`);
36
+ errors2.push(`No .tcase files matched pattern: ${pattern2}`);
36
37
  } else {
37
38
  filePaths.push(...tspecMatches);
38
39
  }
@@ -46,6 +47,61 @@ async function discoverTSpecFiles(patterns, cwd) {
46
47
  }));
47
48
  return { files, errors: errors2 };
48
49
  }
50
+ async function discoverAllTestFiles(patterns, cwd) {
51
+ const workingDir = process.cwd();
52
+ const tspecPaths = [];
53
+ const suitePaths = [];
54
+ const errors2 = [];
55
+ for (const pattern2 of patterns) {
56
+ const absolutePath = isAbsolute(pattern2) ? pattern2 : resolve$1(workingDir, pattern2);
57
+ if (existsSync(absolutePath)) {
58
+ const stat = statSync(absolutePath);
59
+ if (stat.isFile()) {
60
+ if (absolutePath.endsWith(".tcase")) {
61
+ tspecPaths.push(absolutePath);
62
+ } else if (isSuiteFile(absolutePath)) {
63
+ suitePaths.push(absolutePath);
64
+ }
65
+ continue;
66
+ } else if (stat.isDirectory()) {
67
+ const tspecFiles2 = await glob("**/*.tcase", { cwd: absolutePath, absolute: true });
68
+ const suiteFiles2 = await glob("**/*.tsuite", { cwd: absolutePath, absolute: true });
69
+ if (tspecFiles2.length === 0 && suiteFiles2.length === 0) {
70
+ errors2.push(`No .tcase or .tsuite files found in directory: ${pattern2}`);
71
+ } else {
72
+ tspecPaths.push(...tspecFiles2);
73
+ suitePaths.push(...suiteFiles2);
74
+ }
75
+ continue;
76
+ }
77
+ }
78
+ const matches = await glob(pattern2, { cwd: workingDir, absolute: true });
79
+ const tspecMatches = matches.filter((f) => f.endsWith(".tcase"));
80
+ const suiteMatches = matches.filter((f) => isSuiteFile(f));
81
+ if (tspecMatches.length === 0 && suiteMatches.length === 0) {
82
+ errors2.push(`No .tcase or .tsuite files matched pattern: ${pattern2}`);
83
+ } else {
84
+ tspecPaths.push(...tspecMatches);
85
+ suitePaths.push(...suiteMatches);
86
+ }
87
+ }
88
+ const uniqueTspecPaths = [...new Set(tspecPaths)];
89
+ const uniqueSuitePaths = [...new Set(suitePaths)];
90
+ const tspecFiles = uniqueTspecPaths.map((filePath) => ({
91
+ path: filePath,
92
+ relativePath: relative(workingDir, filePath),
93
+ fileName: basename(filePath),
94
+ protocol: getTypeFromFilePath(filePath)
95
+ }));
96
+ const suiteFiles = uniqueSuitePaths.map((filePath) => ({
97
+ path: filePath,
98
+ relativePath: relative(workingDir, filePath),
99
+ fileName: basename(filePath),
100
+ protocol: getSuiteProtocolType(filePath),
101
+ isTemplate: filePath.endsWith(".tsuite.yaml")
102
+ }));
103
+ return { tspecFiles, suiteFiles, errors: errors2 };
104
+ }
49
105
  function formatJson(data) {
50
106
  return JSON.stringify(data, null, 2);
51
107
  }
@@ -86,6 +142,13 @@ function formatTestResult(result, verbose = false) {
86
142
  output += "\n" + assertionLines.join("\n");
87
143
  }
88
144
  }
145
+ if (verbose && result.extracted && Object.keys(result.extracted).length > 0) {
146
+ output += "\n" + chalk.gray(" Extracted:");
147
+ for (const [key, value] of Object.entries(result.extracted)) {
148
+ output += `
149
+ ${key}: ${JSON.stringify(value)}`;
150
+ }
151
+ }
89
152
  return output;
90
153
  }
91
154
  function formatTestSummary(summary) {
@@ -131,7 +194,16 @@ function formatParsedTestCase(testCase, options = {}) {
131
194
  const lines = [];
132
195
  lines.push(chalk.bold(`Test Case: ${tc.id || "unknown"}`));
133
196
  if (tc.description) lines.push(` Description: ${tc.description}`);
134
- if (tc.type) lines.push(` Protocol: ${tc.type}`);
197
+ if (tc.protocol) lines.push(` Protocol: ${tc.protocol}`);
198
+ if (tc.lifecycle) {
199
+ const lifecycle = tc.lifecycle;
200
+ if (lifecycle.setup?.length) {
201
+ lines.push(` Lifecycle Setup: ${lifecycle.setup.length} action(s)`);
202
+ }
203
+ if (lifecycle.teardown?.length) {
204
+ lines.push(` Lifecycle Teardown: ${lifecycle.teardown.length} action(s)`);
205
+ }
206
+ }
135
207
  return lines.join("\n");
136
208
  }
137
209
  let globalOptions = {};
@@ -183,7 +255,7 @@ async function executeValidate(params) {
183
255
  if (fileDescriptors.length === 0) {
184
256
  return {
185
257
  success: false,
186
- output: "No .tspec files found",
258
+ output: "No .tcase files found",
187
259
  data: { results: [] }
188
260
  };
189
261
  }
@@ -206,7 +278,7 @@ async function executeValidate(params) {
206
278
  data: { results }
207
279
  };
208
280
  }
209
- const validateCommand = new Command("validate").description("Validate .tspec files for schema correctness").argument("<files...>", "Files or glob patterns to validate").option("-o, --output <format>", "Output format: json, text", "text").option("-q, --quiet", "Only output errors").action(async (files, options) => {
281
+ const validateCommand = new Command("validate").description("Validate .tcase files for schema correctness").argument("<files...>", "Files or glob patterns to validate").option("-o, --output <format>", "Output format: json, text", "text").option("-q, --quiet", "Only output errors").action(async (files, options) => {
210
282
  setLoggerOptions({ quiet: options.quiet });
211
283
  const spinner = options.quiet ? null : ora("Validating...").start();
212
284
  try {
@@ -236,6 +308,7 @@ function formatResult(result) {
236
308
  testCaseId: result.testCaseId,
237
309
  passed: result.passed,
238
310
  duration: result.duration,
311
+ extracted: Object.keys(result.extracted).length > 0 ? result.extracted : void 0,
239
312
  assertions: result.assertions.map((a) => ({
240
313
  passed: a.passed,
241
314
  type: a.type,
@@ -287,11 +360,11 @@ async function executeRun(params) {
287
360
  const output = params.output ?? "text";
288
361
  const verbose = params.verbose ?? false;
289
362
  const quiet = params.quiet ?? false;
290
- const { files: fileDescriptors, errors: resolveErrors } = await discoverTSpecFiles(params.files);
291
- if (fileDescriptors.length === 0) {
363
+ const { tspecFiles, suiteFiles, errors: resolveErrors } = await discoverAllTestFiles(params.files);
364
+ if (tspecFiles.length === 0 && suiteFiles.length === 0) {
292
365
  return {
293
366
  success: false,
294
- output: "No .tspec files found",
367
+ output: "No .tcase or .tsuite files found",
295
368
  data: {
296
369
  results: [],
297
370
  summary: { total: 0, passed: 0, failed: 0, passRate: 0, duration: 0 },
@@ -299,6 +372,29 @@ async function executeRun(params) {
299
372
  }
300
373
  };
301
374
  }
375
+ if (suiteFiles.length > 0) {
376
+ return executeSuiteRun(suiteFiles, tspecFiles, {
377
+ env,
378
+ params: paramValues,
379
+ concurrency,
380
+ failFast,
381
+ output,
382
+ verbose,
383
+ quiet
384
+ });
385
+ }
386
+ return executeTspecRun(tspecFiles, {
387
+ env,
388
+ params: paramValues,
389
+ concurrency,
390
+ failFast,
391
+ output,
392
+ verbose,
393
+ quiet
394
+ });
395
+ }
396
+ async function executeTspecRun(fileDescriptors, options) {
397
+ const { env, params: paramValues, concurrency, failFast, output, verbose, quiet } = options;
302
398
  const allResults = [];
303
399
  const parseErrors = [];
304
400
  let totalTestCases = 0;
@@ -367,6 +463,116 @@ ${parseErrors.length} file(s) failed to parse:`);
367
463
  data: { results: formattedResults, summary, parseErrors }
368
464
  };
369
465
  }
466
+ async function executeSuiteRun(suiteFiles, additionalTspecFiles, options) {
467
+ const { env, params: paramValues, failFast, output, verbose, quiet } = options;
468
+ const allResults = [];
469
+ const parseErrors = [];
470
+ let totalTests = 0;
471
+ let totalPassed = 0;
472
+ let totalFailed = 0;
473
+ let stopped = false;
474
+ for (const suiteDescriptor of suiteFiles) {
475
+ if (stopped) break;
476
+ if (suiteDescriptor.isTemplate) continue;
477
+ try {
478
+ const suiteResult = await executeSuite(suiteDescriptor.path, {
479
+ env,
480
+ params: paramValues,
481
+ onSuiteStart: (name) => {
482
+ if (!quiet) logger.log(chalk.blue(`
483
+ Suite: ${name}`));
484
+ },
485
+ onTestStart: (file) => {
486
+ if (verbose) logger.log(chalk.gray(` Running: ${file}`));
487
+ },
488
+ onTestComplete: (file, result) => {
489
+ const statusIcon = result.status === "passed" ? chalk.green("✓") : chalk.red("✗");
490
+ if (!quiet) logger.log(` ${statusIcon} ${result.name} (${result.duration}ms)`);
491
+ }
492
+ });
493
+ totalTests += suiteResult.stats.total;
494
+ totalPassed += suiteResult.stats.passed;
495
+ totalFailed += suiteResult.stats.failed + suiteResult.stats.error;
496
+ for (const testResult of suiteResult.tests) {
497
+ allResults.push({
498
+ testCaseId: testResult.name,
499
+ passed: testResult.status === "passed",
500
+ duration: testResult.duration,
501
+ assertions: (testResult.assertions || []).map((a) => ({
502
+ passed: a.passed,
503
+ type: a.type,
504
+ message: a.message || ""
505
+ }))
506
+ });
507
+ }
508
+ if (suiteResult.suites) {
509
+ for (const nestedSuite of suiteResult.suites) {
510
+ totalTests += nestedSuite.stats.total;
511
+ totalPassed += nestedSuite.stats.passed;
512
+ totalFailed += nestedSuite.stats.failed + nestedSuite.stats.error;
513
+ for (const testResult of nestedSuite.tests) {
514
+ allResults.push({
515
+ testCaseId: `${nestedSuite.name}/${testResult.name}`,
516
+ passed: testResult.status === "passed",
517
+ duration: testResult.duration,
518
+ assertions: (testResult.assertions || []).map((a) => ({
519
+ passed: a.passed,
520
+ type: a.type,
521
+ message: a.message || ""
522
+ }))
523
+ });
524
+ }
525
+ }
526
+ }
527
+ if (failFast && (suiteResult.status === "failed" || suiteResult.status === "error")) {
528
+ stopped = true;
529
+ }
530
+ } catch (err) {
531
+ parseErrors.push({
532
+ file: suiteDescriptor.path,
533
+ error: err instanceof Error ? err.message : String(err)
534
+ });
535
+ }
536
+ }
537
+ if (additionalTspecFiles.length > 0 && !stopped) {
538
+ const tspecResult = await executeTspecRun(additionalTspecFiles, options);
539
+ allResults.push(...tspecResult.data.results);
540
+ parseErrors.push(...tspecResult.data.parseErrors);
541
+ totalTests += tspecResult.data.summary.total;
542
+ totalPassed += tspecResult.data.summary.passed;
543
+ totalFailed += tspecResult.data.summary.failed;
544
+ }
545
+ const summary = {
546
+ total: totalTests,
547
+ passed: totalPassed,
548
+ failed: totalFailed,
549
+ passRate: totalTests > 0 ? totalPassed / totalTests * 100 : 0,
550
+ duration: 0
551
+ };
552
+ let outputStr;
553
+ if (output === "json") {
554
+ outputStr = formatJson({ results: allResults, summary, parseErrors });
555
+ } else {
556
+ const parts = [];
557
+ if (!quiet) {
558
+ parts.push("\n" + chalk.bold("Results:"));
559
+ parts.push(formatTestResults(allResults, summary, { format: output, verbose }));
560
+ } else {
561
+ parts.push(`${summary.passed}/${summary.total} tests passed (${summary.passRate.toFixed(1)}%)`);
562
+ }
563
+ if (parseErrors.length > 0) {
564
+ parts.push(`
565
+ ${parseErrors.length} file(s) failed to parse:`);
566
+ parseErrors.forEach(({ file, error: error2 }) => parts.push(` ${file}: ${error2}`));
567
+ }
568
+ outputStr = parts.join("\n");
569
+ }
570
+ return {
571
+ success: totalFailed === 0 && parseErrors.length === 0,
572
+ output: outputStr,
573
+ data: { results: allResults, summary, parseErrors }
574
+ };
575
+ }
370
576
  const runCommand = new Command("run").description("Execute test cases and report results").argument("<files...>", "Files or glob patterns to run").option("-o, --output <format>", "Output format: json, text", "text").option("-c, --concurrency <number>", "Max concurrent tests", "5").option("-e, --env <key=value>", "Environment variables", parseKeyValue$1, {}).option("-p, --params <key=value>", "Parameters", parseKeyValue$1, {}).option("-v, --verbose", "Verbose output").option("-q, --quiet", "Only output summary").option("--fail-fast", "Stop on first failure").action(async (files, options) => {
371
577
  setLoggerOptions({ verbose: options.verbose, quiet: options.quiet });
372
578
  const spinner = options.quiet ? null : ora("Running tests...").start();
@@ -421,7 +627,7 @@ async function executeParse(params) {
421
627
  if (fileDescriptors.length === 0) {
422
628
  return {
423
629
  success: false,
424
- output: "No .tspec files found",
630
+ output: "No .tcase files found",
425
631
  data: {
426
632
  testCases: [],
427
633
  parseErrors: [],
@@ -14739,7 +14945,7 @@ const TOOL_DEFINITIONS = [
14739
14945
  files: {
14740
14946
  type: "array",
14741
14947
  items: { type: "string" },
14742
- description: 'Files or glob patterns to run (e.g., ["tests/*.tspec"])'
14948
+ description: 'Files or glob patterns to run (e.g., ["tests/*.tcase"])'
14743
14949
  },
14744
14950
  concurrency: {
14745
14951
  type: "number",
@@ -14770,7 +14976,7 @@ const TOOL_DEFINITIONS = [
14770
14976
  },
14771
14977
  {
14772
14978
  name: "tspec_validate",
14773
- description: "Validate .tspec files for schema correctness",
14979
+ description: "Validate .tcase files for schema correctness",
14774
14980
  inputSchema: {
14775
14981
  type: "object",
14776
14982
  properties: {
@@ -14932,8 +15138,11 @@ const mcpCommand = new Command("mcp").description("Start MCP server for tool int
14932
15138
  setLoggerOptions({ quiet: true });
14933
15139
  await startMcpServer();
14934
15140
  });
15141
+ const __filename$1 = fileURLToPath(import.meta.url);
15142
+ const __dirname$1 = dirname(__filename$1);
15143
+ const packageJson = JSON.parse(readFileSync(join(__dirname$1, "../package.json"), "utf-8"));
14935
15144
  const program = new Command();
14936
- program.name("tspec").description("CLI for @boolesai/tspec testing framework").version("1.0.0");
15145
+ program.name("tspec").description("CLI for @boolesai/tspec testing framework").version(packageJson.version);
14937
15146
  program.addCommand(validateCommand);
14938
15147
  program.addCommand(runCommand);
14939
15148
  program.addCommand(parseCommand);