redfireforge-cli 0.8.2 → 0.8.3

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
@@ -52,6 +52,9 @@ redfireforge run tests/api-test.yaml -c 10 -i 100
52
52
  # Run a workflow performance test
53
53
  redfireforge workflow tests/checkout-flow.yaml -i 50 -c 5
54
54
 
55
+ # Print machine-readable JSON to stdout (for CI)
56
+ redfireforge run tests/api-test.yaml --output json
57
+
55
58
  # Every command above also works with the short "rff" alias:
56
59
  rff run tests/api-test.yaml -c 10 -i 100
57
60
  ```
@@ -92,7 +95,8 @@ npx tsx cli/index.ts mock start ./api-mock-workspace.json --standalone --wait-re
92
95
  | Option | Commands | Description |
93
96
  |--------|----------|-------------|
94
97
  | `--server <id>` | simulate, verify | Target server (default: active / first) |
95
- | `-o, --output <path>` | simulate | Write JSON results |
98
+ | `-o, --output <path>` | simulate | Write JSON results to a file |
99
+ | `-o, --output json\|junit` | simulate | Print results to stdout in that format |
96
100
  | `--junit <path>` | simulate | Write JUnit XML |
97
101
  | `--min-calls <n>` | verify | Require at least N matching journal calls (samples when `--simulate`) |
98
102
  | `--expect-outcome <outcome>` | verify | Require matching outcome |
@@ -131,11 +135,80 @@ npx tsx cli/index.ts mock start ./api-mock-workspace.json --standalone --wait-re
131
135
 
132
136
  | Option | Description |
133
137
  |--------|-------------|
134
- | `-o, --output <path>` | Write JSON report |
138
+ | `-o, --output <path>` | Write JSON report to a file |
139
+ | `-o, --output json` | Print a CI-friendly JSON report to **stdout** |
140
+ | `-o, --output junit` | Print JUnit XML to **stdout** |
135
141
  | `--junit <path>` | Write JUnit XML report |
136
142
  | `--markdown <path>` | Write Markdown report |
137
143
  | `-q, --quiet` | Suppress progress output |
138
144
 
145
+ > `json` and `junit` are format keywords, not filenames — supported by `run`,
146
+ > `workflow`, and `mock simulate`. To write to a file literally named `json`,
147
+ > qualify it: `--output ./json`.
148
+
149
+ ### Machine-Readable Output (CI)
150
+
151
+ `--output json` prints a flat, stable report to stdout and suppresses **all**
152
+ other stdout output, so the stream is safe to pipe straight into `jq`:
153
+
154
+ ```bash
155
+ rff run tests/api-test.yaml --output json | jq '.failed'
156
+ ```
157
+
158
+ ```json
159
+ {
160
+ "passed": 12,
161
+ "failed": 2,
162
+ "total": 14,
163
+ "durationMs": 3421,
164
+ "results": [
165
+ {
166
+ "name": "Get Users",
167
+ "status": "pass",
168
+ "durationMs": 123,
169
+ "error": null
170
+ },
171
+ {
172
+ "name": "Create Order",
173
+ "status": "fail",
174
+ "durationMs": 456,
175
+ "error": "Expected status 201 but got 500"
176
+ }
177
+ ]
178
+ }
179
+ ```
180
+
181
+ Notes:
182
+
183
+ - `status` is `"pass"` or `"fail"`; `error` is `null` for passing tests.
184
+ - Parameterized rows are qualified as `Scenario [Row label]` so names stay unique.
185
+ - Errors and diagnostics still go to **stderr**, keeping stdout pure.
186
+ - Exit codes are unchanged — `--fail-on-error` (1), `--fail-on-regression` (2/3)
187
+ and `--fail-on-sla` (4) all still fire, and the SLA / baseline reports that
188
+ normally print on failure are suppressed so they cannot corrupt the report.
189
+
190
+ #### Workflow runs
191
+
192
+ For `workflow`, one result is emitted **per iteration** — matching `--output junit`,
193
+ so both formats agree on `total`. Each iteration fails if any of its steps failed,
194
+ and the individual steps are preserved under `steps` (same shape, one level deep):
195
+
196
+ ```json
197
+ {
198
+ "name": "Iteration 1",
199
+ "status": "fail",
200
+ "durationMs": 56,
201
+ "error": "Create Order: (http): expected 2xx, got HTTP 500",
202
+ "steps": [
203
+ { "name": "Login", "status": "pass", "durationMs": 54, "error": null },
204
+ { "name": "Create Order", "status": "fail", "durationMs": 2, "error": "(http): expected 2xx, got HTTP 500" }
205
+ ]
206
+ }
207
+ ```
208
+
209
+ `steps` is additive — pipelines that only read the documented fields are unaffected.
210
+ The iteration `error` concatenates every failing step as `Step: error`, joined by `; `.
211
+
139
212
  ### CI/CD Options
140
213
 
141
214
  | Option | Description |
@@ -209,8 +282,16 @@ edges:
209
282
  | Code | Meaning |
210
283
  |------|---------|
211
284
  | 0 | Success — all tests passed |
212
- | 1 | Test failure some requests failed or threshold exceeded |
213
- | 2 | Error invalid file, missing file, or execution error |
285
+ | 1 | Test failure, or an execution error (invalid/missing file) |
286
+ | 2 | Performance regression vs. baseline (`--fail-on-regression`) |
287
+ | 3 | Regression **and** test failures (`--fail-on-regression`) |
288
+ | 4 | SLA violation (`--fail-on-sla`) |
289
+
290
+ A **test failure** exits `1` only with `--fail-on-error` or `--fail-threshold <pct>`;
291
+ otherwise failures are reported and the run still exits `0`. An **execution error**
292
+ (invalid or missing file) always exits `1`, no flag required. Codes `2`/`3` need
293
+ `--fail-on-regression`, `4` needs `--fail-on-sla`. The `workflow` command uses `1`
294
+ for failures and `2` for execution errors. Exit codes are unaffected by `--output json`.
214
295
 
215
296
  ## CI/CD Example
216
297
 
@@ -226,6 +307,21 @@ edges:
226
307
  -q
227
308
  ```
228
309
 
310
+ To parse results in the pipeline instead of writing a file, stream JSON to stdout:
311
+
312
+ ```yaml
313
+ - name: Run API Tests and gate on failures
314
+ run: |
315
+ npx redfireforge-cli run tests/api-test.yaml \
316
+ --output json \
317
+ --fail-on-error > results.json
318
+ # Non-zero exit already fails the step; results.json is still valid JSON.
319
+
320
+ - name: Summarize
321
+ if: always()
322
+ run: jq -r '"\(.passed)/\(.total) passed in \(.durationMs)ms"' results.json
323
+ ```
324
+
229
325
  ## Links
230
326
 
231
327
  - [Full Documentation](https://github.com/your-org/redfireforge/blob/main/docs/guides/cli-reference.md)
@@ -63458,6 +63458,59 @@ function buildJsonReport(results, summary, config, meta) {
63458
63458
  projectName: meta.name
63459
63459
  };
63460
63460
  }
63461
+ function ciResultName(r) {
63462
+ return r.dataRowLabel ? `${r.scenarioName} [${r.dataRowLabel}]` : r.scenarioName;
63463
+ }
63464
+ function ciResultError(r) {
63465
+ if (r.passed) return null;
63466
+ const detail = r.errorMessage || (r.failureDetails.length > 0 ? formatFailureDetails(r.failureDetails) : null);
63467
+ if ((r.transportType ?? "http") === "http" && r.httpStatus >= 400) {
63468
+ return detail ? `HTTP ${r.httpStatus}: ${detail}` : `HTTP ${r.httpStatus}`;
63469
+ }
63470
+ return detail ?? formatTransportErrorFallback(r);
63471
+ }
63472
+ function ciStepResult(r) {
63473
+ return {
63474
+ name: ciResultName(r),
63475
+ status: r.passed ? "pass" : "fail",
63476
+ durationMs: Math.round(r.responseTimeMs),
63477
+ error: ciResultError(r)
63478
+ };
63479
+ }
63480
+ function buildCiJsonReport(results, durationMs) {
63481
+ const exportSafeResults = redactGrpcHarnessRunnerArtifactsForExport(results);
63482
+ const passed = exportSafeResults.filter((r) => r.passed).length;
63483
+ return {
63484
+ passed,
63485
+ failed: exportSafeResults.length - passed,
63486
+ total: exportSafeResults.length,
63487
+ durationMs: Math.round(durationMs),
63488
+ results: exportSafeResults.map(ciStepResult)
63489
+ };
63490
+ }
63491
+ function buildWorkflowCiJsonReport(results, iterations, durationMs) {
63492
+ const exportSafeResults = redactGrpcHarnessRunnerArtifactsForExport(results);
63493
+ const grouped = groupResultsByIteration(exportSafeResults, iterations);
63494
+ const iterationResults = grouped.map((iterResults, i) => {
63495
+ const steps = iterResults.map(ciStepResult);
63496
+ const failedSteps = steps.filter((s2) => s2.status === "fail");
63497
+ return {
63498
+ name: `Iteration ${i + 1}`,
63499
+ status: failedSteps.length === 0 ? "pass" : "fail",
63500
+ durationMs: steps.reduce((sum, s2) => sum + s2.durationMs, 0),
63501
+ error: failedSteps.length === 0 ? null : failedSteps.map((s2) => `${s2.name}: ${s2.error}`).join("; "),
63502
+ steps
63503
+ };
63504
+ });
63505
+ const passed = iterationResults.filter((r) => r.status === "pass").length;
63506
+ return {
63507
+ passed,
63508
+ failed: iterationResults.length - passed,
63509
+ total: iterationResults.length,
63510
+ durationMs: Math.round(durationMs),
63511
+ results: iterationResults
63512
+ };
63513
+ }
63461
63514
  function escapeXml(s2) {
63462
63515
  return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
63463
63516
  }
@@ -63655,26 +63708,26 @@ function computePerStepStats(results) {
63655
63708
  }
63656
63709
  return stats;
63657
63710
  }
63658
- function computePerIterationStats(results, iterations) {
63711
+ function groupResultsByIteration(results, iterations) {
63659
63712
  const byIteration = /* @__PURE__ */ new Map();
63660
63713
  for (const r of results) {
63661
63714
  const idx = r.iterationIndex ?? 0;
63662
63715
  if (!byIteration.has(idx)) byIteration.set(idx, []);
63663
63716
  byIteration.get(idx).push(r);
63664
63717
  }
63665
- const stats = [];
63718
+ const grouped = [];
63666
63719
  for (let i = 0; i < iterations; i++) {
63667
- const iterResults = byIteration.get(i) || [];
63668
- const allPassed = iterResults.every((r) => r.passed);
63669
- const totalDuration = iterResults.reduce((sum, r) => sum + r.responseTimeMs, 0);
63670
- stats.push({
63671
- index: i,
63672
- passed: allPassed,
63673
- durationMs: totalDuration,
63674
- stepCount: iterResults.length
63675
- });
63720
+ grouped.push(byIteration.get(i) || []);
63676
63721
  }
63677
- return stats;
63722
+ return grouped;
63723
+ }
63724
+ function computePerIterationStats(results, iterations) {
63725
+ return groupResultsByIteration(results, iterations).map((iterResults, i) => ({
63726
+ index: i,
63727
+ passed: iterResults.every((r) => r.passed),
63728
+ durationMs: iterResults.reduce((sum, r) => sum + r.responseTimeMs, 0),
63729
+ stepCount: iterResults.length
63730
+ }));
63678
63731
  }
63679
63732
  function formatRowLabel(r) {
63680
63733
  return r.dataRowLabel || r.dataRowId || "?";
@@ -63894,6 +63947,22 @@ function buildWorkflowMarkdownReport(summary, workflow, iterations, concurrency,
63894
63947
  return lines.join("\n");
63895
63948
  }
63896
63949
 
63950
+ // cli/outputTarget.ts
63951
+ var STDOUT_FORMATS = ["json", "junit"];
63952
+ function resolveOutputTarget(value) {
63953
+ if (value == null) return null;
63954
+ const trimmed = value.trim();
63955
+ if (trimmed === "") return null;
63956
+ const keyword = trimmed.toLowerCase();
63957
+ if (STDOUT_FORMATS.includes(keyword)) {
63958
+ return { kind: "stdout", format: keyword };
63959
+ }
63960
+ return { kind: "file", path: trimmed };
63961
+ }
63962
+ function stdoutFormatOf(target) {
63963
+ return target?.kind === "stdout" ? target.format : null;
63964
+ }
63965
+
63897
63966
  // cli/slaEval.ts
63898
63967
  import { readFileSync as readFileSync3 } from "node:fs";
63899
63968
  function getMetricFromSummary(metric, summary) {
@@ -67063,7 +67132,7 @@ var ApiMockNetworkListener = class {
67063
67132
  handler
67064
67133
  );
67065
67134
  } catch (err) {
67066
- throw new Error(`TLS material rejected: ${err instanceof Error ? err.message : String(err)}`);
67135
+ throw new Error(`TLS material rejected: ${err instanceof Error ? err.message : String(err)}`, { cause: err });
67067
67136
  }
67068
67137
  } else {
67069
67138
  server = http.createServer(handler);
@@ -67814,6 +67883,8 @@ function sleep4(ms) {
67814
67883
 
67815
67884
  // cli/mockCommands.ts
67816
67885
  async function runMockSimulate(opts) {
67886
+ const outputTarget = resolveOutputTarget(opts.output);
67887
+ const stdoutFormat = stdoutFormatOf(outputTarget);
67817
67888
  const raw = asWorkspace(loadDefinitionFile(opts.file));
67818
67889
  const loaded = cliLoadAndValidate(raw);
67819
67890
  if (reportValidation(loaded.validationErrors)) return 1;
@@ -67832,10 +67903,7 @@ async function runMockSimulate(opts) {
67832
67903
  failed: failed.length,
67833
67904
  results
67834
67905
  };
67835
- const json = JSON.stringify(summary, null, 2);
67836
- if (opts.output) writeFileSync2(resolve4(opts.output), json, "utf8");
67837
- else console.log(json);
67838
- if (opts.junit) {
67906
+ const buildJunit = () => {
67839
67907
  const cases = results.map((r) => {
67840
67908
  const name2 = r.sampleId;
67841
67909
  if (isFailedSimulation(r)) {
@@ -67843,12 +67911,18 @@ async function runMockSimulate(opts) {
67843
67911
  }
67844
67912
  return `<testcase classname="api-mock" name="${escapeXml2(name2)}"/>`;
67845
67913
  }).join("\n");
67846
- const xml = `<?xml version="1.0" encoding="UTF-8"?>
67914
+ return `<?xml version="1.0" encoding="UTF-8"?>
67847
67915
  <testsuite name="api-mock-simulate" tests="${results.length}" failures="${failed.length}">
67848
67916
  ${cases}
67849
67917
  </testsuite>
67850
67918
  `;
67851
- writeFileSync2(resolve4(opts.junit), xml, "utf8");
67919
+ };
67920
+ const json = JSON.stringify(summary, null, 2);
67921
+ if (stdoutFormat === "junit") process.stdout.write(buildJunit());
67922
+ else if (outputTarget?.kind === "file") writeFileSync2(resolve4(outputTarget.path), json, "utf8");
67923
+ else console.log(json);
67924
+ if (opts.junit) {
67925
+ writeFileSync2(resolve4(opts.junit), buildJunit(), "utf8");
67852
67926
  }
67853
67927
  console.error(`Simulated ${results.length} sample(s); ${failed.length} failure(s).`);
67854
67928
  return failed.length > 0 ? 1 : 0;
@@ -68272,7 +68346,7 @@ async function runMockVerifyAllRoutes(opts) {
68272
68346
  // cli/mockCommandRegistration.ts
68273
68347
  function registerMockCommands(program2) {
68274
68348
  const mock = program2.command("mock").description("API Mock Studio headless commands");
68275
- mock.command("simulate").description("Run saved simulation samples against a mock definition (side-effect-free)").argument("<file>", "Workspace / server JSON or YAML (or native export envelope)").option("--server <id>", "Server id (defaults to activeServerId or first)").option("-o, --output <path>", "Write JSON results to file").option("--junit <path>", "Write JUnit XML results to file").action(async (file, opts) => {
68349
+ mock.command("simulate").description("Run saved simulation samples against a mock definition (side-effect-free)").argument("<file>", "Workspace / server JSON or YAML (or native export envelope)").option("--server <id>", "Server id (defaults to activeServerId or first)").option("-o, --output <path|json|junit>", "Write JSON results to file, or print `json`/`junit` to stdout").option("--junit <path>", "Write JUnit XML results to file").action(async (file, opts) => {
68276
68350
  const code = await runMockSimulate({ file, serverId: opts.server, output: opts.output, junit: opts.junit });
68277
68351
  process.exit(code);
68278
68352
  });
@@ -68348,15 +68422,18 @@ var __dirname = dirname2(fileURLToPath(import.meta.url));
68348
68422
  var pkg = JSON.parse(readFileSync7(resolve5(__dirname, "..", "package.json"), "utf-8"));
68349
68423
  var program = new Command();
68350
68424
  program.name("redfireforge").description("RedfireForge CLI \u2014 run API performance tests from YAML/JSON files").version(pkg.version);
68351
- program.command("run").description("Execute a test file").argument("<file>", "Path to a .yaml, .yml, or .json test file").option("-c, --concurrency <n>", "Number of concurrent requests", parseInt).option("-i, --iterations <n>", "Number of iterations (how many times each test runs)", parseInt).option("-m, --mode <mode>", "Execution mode: sequential, batch, pool, load-profile").option("--timeout <sec>", "Per-request timeout in seconds", parseInt).option("--retries <n>", "Retry count on failure", parseInt).option("--retry-delay <ms>", "Delay between retries in milliseconds", parseInt).option("--duration <sec>", "Duration in seconds (load-profile mode)", parseInt).option("--base-url <url>", "Override the base URL for all tests").option("--data <file>", "External data file (CSV or JSON) for parameterized testing").option("--scenario <name>", "Run only the test matching this name (used with --data)").option("--env <name>", "Environment name (metadata only)").option("--error-policy <policy>", "Error policy: continue, stop-first, stop-threshold").option("--max-errors <n>", "Stop after N errors (threshold mode)", parseInt).option("--max-error-rate <pct>", "Stop at error rate % (threshold mode)", parseFloat).option("--fail-on-error", "Exit code 1 if any request fails (HTTP or validation)").option("--fail-threshold <pct>", "Exit code 1 if error rate exceeds this %", parseFloat).option("-o, --output <path>", "Write JSON report to file").option("--junit <path>", "Write JUnit XML report to file").option("--markdown <path>", "Write Markdown report to file").option("--data-rows-summary <path>", "Write data row summary JSON (CI/CD format)").option("--tags <tags>", "Run only data rows with these tags (comma-separated)").option("--tag-mode <mode>", "Tag matching mode: any (default) or all", "any").option("--scenario-tags <tags>", "Run only scenarios with these tags (comma-separated)").option("--scenario-tag-mode <mode>", "Scenario tag matching mode: any (default) or all", "any").option("--sla-config <path>", "JSON file of SLA targets to evaluate after the run (SlaTarget[])").option("--fail-on-sla", "Exit code 4 if any SLA violations are detected (requires --sla-config)").option("--compare-baseline <id>", `Compare run against a saved baseline. Use "latest-baseline" to pick the most recent one automatically, or pass the runId of a specific saved baseline.`).option("--fail-on-regression", "Exit code 2 (regression only) or 3 (also test failures) when regressions are detected").option("--save-baseline", "Save this run as a new baseline after completion (only when no failures or regressions)").option("--baseline-label <label>", "Human-readable label for the saved baseline").option("--baselines-dir <dir>", `Directory for the baseline store (default: ${DEFAULT_BASELINES_DIR})`).option("--comparison-report <path>", "Write the Markdown comparison report to a file").option("-q, --quiet", "Suppress progress output").action(async (filePath, opts) => {
68425
+ program.command("run").description("Execute a test file").argument("<file>", "Path to a .yaml, .yml, or .json test file").option("-c, --concurrency <n>", "Number of concurrent requests", parseInt).option("-i, --iterations <n>", "Number of iterations (how many times each test runs)", parseInt).option("-m, --mode <mode>", "Execution mode: sequential, batch, pool, load-profile").option("--timeout <sec>", "Per-request timeout in seconds", parseInt).option("--retries <n>", "Retry count on failure", parseInt).option("--retry-delay <ms>", "Delay between retries in milliseconds", parseInt).option("--duration <sec>", "Duration in seconds (load-profile mode)", parseInt).option("--base-url <url>", "Override the base URL for all tests").option("--data <file>", "External data file (CSV or JSON) for parameterized testing").option("--scenario <name>", "Run only the test matching this name (used with --data)").option("--env <name>", "Environment name (metadata only)").option("--error-policy <policy>", "Error policy: continue, stop-first, stop-threshold").option("--max-errors <n>", "Stop after N errors (threshold mode)", parseInt).option("--max-error-rate <pct>", "Stop at error rate % (threshold mode)", parseFloat).option("--fail-on-error", "Exit code 1 if any request fails (HTTP or validation)").option("--fail-threshold <pct>", "Exit code 1 if error rate exceeds this %", parseFloat).option("-o, --output <path|json|junit>", "Write JSON report to file, or print `json`/`junit` to stdout for CI").option("--junit <path>", "Write JUnit XML report to file").option("--markdown <path>", "Write Markdown report to file").option("--data-rows-summary <path>", "Write data row summary JSON (CI/CD format)").option("--tags <tags>", "Run only data rows with these tags (comma-separated)").option("--tag-mode <mode>", "Tag matching mode: any (default) or all", "any").option("--scenario-tags <tags>", "Run only scenarios with these tags (comma-separated)").option("--scenario-tag-mode <mode>", "Scenario tag matching mode: any (default) or all", "any").option("--sla-config <path>", "JSON file of SLA targets to evaluate after the run (SlaTarget[])").option("--fail-on-sla", "Exit code 4 if any SLA violations are detected (requires --sla-config)").option("--compare-baseline <id>", `Compare run against a saved baseline. Use "latest-baseline" to pick the most recent one automatically, or pass the runId of a specific saved baseline.`).option("--fail-on-regression", "Exit code 2 (regression only) or 3 (also test failures) when regressions are detected").option("--save-baseline", "Save this run as a new baseline after completion (only when no failures or regressions)").option("--baseline-label <label>", "Human-readable label for the saved baseline").option("--baselines-dir <dir>", `Directory for the baseline store (default: ${DEFAULT_BASELINES_DIR})`).option("--comparison-report <path>", "Write the Markdown comparison report to a file").option("-q, --quiet", "Suppress progress output").action(async (filePath, opts) => {
68352
68426
  try {
68427
+ const outputTarget = resolveOutputTarget(opts.output);
68428
+ const stdoutFormat = stdoutFormatOf(outputTarget);
68429
+ const quiet = Boolean(opts.quiet) || stdoutFormat !== null;
68353
68430
  const absPath = resolve5(filePath);
68354
68431
  const file = loadTestFile(absPath);
68355
68432
  let externalDataSource;
68356
68433
  if (opts.data) {
68357
68434
  const dataPath = resolve5(opts.data);
68358
68435
  externalDataSource = loadDataFile(dataPath);
68359
- if (!opts.quiet) {
68436
+ if (!quiet) {
68360
68437
  console.log(` Data: ${basename2(dataPath)} (${externalDataSource.rows.length} rows)`);
68361
68438
  }
68362
68439
  }
@@ -68366,7 +68443,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68366
68443
  throw new Error(`No test found matching --scenario "${opts.scenario}"`);
68367
68444
  }
68368
68445
  }
68369
- if (!opts.quiet) {
68446
+ if (!quiet) {
68370
68447
  console.log(`
68371
68448
  Loading: ${basename2(absPath)}`);
68372
68449
  console.log(` Tests: ${file.tests.length}`);
@@ -68382,7 +68459,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68382
68459
  if (scTags.length === 0) return false;
68383
68460
  return tagMode === "any" ? filterTags.some((t) => scTags.includes(t)) : filterTags.every((t) => scTags.includes(t));
68384
68461
  });
68385
- if (!opts.quiet) {
68462
+ if (!quiet) {
68386
68463
  console.log(` Scenario tags: ${filterTags.join(", ")} (mode: ${tagMode}, ${scenarios.length}/${before} scenarios matched)`);
68387
68464
  }
68388
68465
  if (scenarios.length === 0) {
@@ -68396,7 +68473,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68396
68473
  const before = scenarios.length;
68397
68474
  const result = filterScenariosByRowTags(scenarios, filterTags, tagMode);
68398
68475
  scenarios = result.scenarios;
68399
- if (!opts.quiet) {
68476
+ if (!quiet) {
68400
68477
  console.log(` Tags: ${filterTags.join(", ")} (mode: ${tagMode}, ${result.matchingRowCount} matching rows, ${scenarios.length}/${before} scenarios retained)`);
68401
68478
  if (result.droppedScenarioNames.length > 0) {
68402
68479
  console.log(` Dropped: ${result.droppedScenarioNames.join(", ")} (no rows matched the tag filter)`);
@@ -68419,7 +68496,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68419
68496
  maxErrors: opts.maxErrors,
68420
68497
  maxErrorRate: opts.maxErrorRate
68421
68498
  });
68422
- if (!opts.quiet) {
68499
+ if (!quiet) {
68423
68500
  console.log(` Mode: ${config.executionMode} (C:${config.concurrency} I:${config.iterations})`);
68424
68501
  const paramTests = scenarios.filter((s2) => s2.dataSource && s2.dataSource.rows.length > 0);
68425
68502
  if (paramTests.length > 0) {
@@ -68430,12 +68507,12 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68430
68507
  }
68431
68508
  const abortController = new AbortController();
68432
68509
  process.on("SIGINT", () => {
68433
- if (!opts.quiet) console.log("\n Aborting...");
68510
+ if (!quiet) console.log("\n Aborting...");
68434
68511
  abortController.abort();
68435
68512
  });
68436
68513
  let lastPrinted = 0;
68437
68514
  const onProgress = (completed, total, _results, meta2) => {
68438
- if (opts.quiet) return;
68515
+ if (quiet) return;
68439
68516
  const now = Date.now();
68440
68517
  if (now - lastPrinted < 500 && completed < total) return;
68441
68518
  lastPrinted = now;
@@ -68452,41 +68529,47 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68452
68529
  const { results } = await runTest(config, scenarios, onProgress, abortController.signal);
68453
68530
  const elapsed = performance.now() - t0;
68454
68531
  const summary = computeMetrics(results, elapsed);
68455
- if (!opts.quiet) {
68532
+ if (!quiet) {
68456
68533
  process.stdout.write("\r" + " ".repeat(60) + "\r");
68457
68534
  }
68458
- printConsoleSummary(summary, config, results);
68535
+ if (!stdoutFormat) {
68536
+ printConsoleSummary(summary, config, results);
68537
+ }
68459
68538
  const suiteName = file.name || basename2(absPath, ".yaml").replace(/\.yml$|\.json$/, "");
68460
68539
  const meta = { name: file.name, env: opts.env || file.env, file: basename2(absPath) };
68461
68540
  const slaTargets = opts.slaConfig ? loadSlaTargetFile(resolve5(opts.slaConfig)) : void 0;
68462
68541
  if (slaTargets) {
68463
68542
  config.slaTargets = slaTargets;
68464
68543
  }
68465
- if (opts.output) {
68544
+ if (stdoutFormat === "json") {
68545
+ process.stdout.write(JSON.stringify(buildCiJsonReport(results, elapsed), null, 2) + "\n");
68546
+ } else if (stdoutFormat === "junit") {
68547
+ process.stdout.write(buildJunitXml(results, summary, suiteName) + "\n");
68548
+ } else if (outputTarget?.kind === "file") {
68466
68549
  const report = buildJsonReport(results, summary, config, meta);
68467
- writeFileSync3(resolve5(opts.output), JSON.stringify(report, null, 2));
68468
- console.log(` JSON report: ${opts.output}`);
68550
+ writeFileSync3(resolve5(outputTarget.path), JSON.stringify(report, null, 2));
68551
+ console.log(` JSON report: ${outputTarget.path}`);
68469
68552
  }
68470
68553
  if (opts.junit) {
68471
68554
  const xml = buildJunitXml(results, summary, suiteName);
68472
68555
  writeFileSync3(resolve5(opts.junit), xml);
68473
- console.log(` JUnit XML: ${opts.junit}`);
68556
+ if (!quiet) console.log(` JUnit XML: ${opts.junit}`);
68474
68557
  }
68475
68558
  if (opts.markdown) {
68476
68559
  const md = buildMarkdownReport(summary, config, meta, results);
68477
68560
  writeFileSync3(resolve5(opts.markdown), md);
68478
- console.log(` Markdown: ${opts.markdown}`);
68561
+ if (!quiet) console.log(` Markdown: ${opts.markdown}`);
68479
68562
  }
68480
68563
  if (opts.dataRowsSummary) {
68481
68564
  const rowSummary = buildDataRowSummary(results);
68482
68565
  writeFileSync3(resolve5(opts.dataRowsSummary), JSON.stringify(rowSummary, null, 2));
68483
- console.log(` Data Rows: ${opts.dataRowsSummary}`);
68566
+ if (!quiet) console.log(` Data Rows: ${opts.dataRowsSummary}`);
68484
68567
  }
68485
68568
  let hasSlaFail = false;
68486
68569
  if (slaTargets) {
68487
68570
  const checks = evaluateCliSla(summary, results, slaTargets);
68488
68571
  hasSlaFail = !!(opts.failOnSla && overallSlaStatus(checks) === "fail");
68489
- printSlaReport(checks, opts.quiet && !hasSlaFail);
68572
+ printSlaReport(checks, stdoutFormat !== null || quiet && !hasSlaFail);
68490
68573
  }
68491
68574
  let hasRegression = false;
68492
68575
  if (opts.compareBaseline) {
@@ -68495,12 +68578,12 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68495
68578
  let baselineEntry = null;
68496
68579
  if (sentinel === LATEST_BASELINE_SENTINEL) {
68497
68580
  baselineEntry = findLatestBaseline(absPath, baselinesDir);
68498
- if (!baselineEntry && !opts.quiet) {
68581
+ if (!baselineEntry && !quiet) {
68499
68582
  console.warn(` \u26A0 No baselines found for ${basename2(absPath)} \u2014 skipping regression check`);
68500
68583
  }
68501
68584
  } else {
68502
68585
  baselineEntry = findBaselineById(sentinel, baselinesDir);
68503
- if (!baselineEntry && !opts.quiet) {
68586
+ if (!baselineEntry && !quiet) {
68504
68587
  console.warn(` \u26A0 Baseline not found: "${sentinel}" \u2014 skipping regression check`);
68505
68588
  }
68506
68589
  }
@@ -68526,13 +68609,13 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68526
68609
  };
68527
68610
  const comparison = compareRuns(baselineRun, currentRun, DEFAULT_THRESHOLDS);
68528
68611
  printComparisonSummary(comparison, {
68529
- quiet: opts.quiet,
68612
+ quiet,
68530
68613
  baselineLabel: baselineEntry.label
68531
68614
  });
68532
68615
  if (opts.comparisonReport) {
68533
68616
  const md = buildComparisonMarkdown(comparison, baselineEntry.label);
68534
68617
  writeFileSync3(resolve5(opts.comparisonReport), md);
68535
- if (!opts.quiet) {
68618
+ if (!quiet) {
68536
68619
  console.log(` Comparison: ${opts.comparisonReport}`);
68537
68620
  }
68538
68621
  }
@@ -68552,7 +68635,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68552
68635
  summary
68553
68636
  };
68554
68637
  addCliBaseline(entry, baselinesDir);
68555
- if (!opts.quiet) {
68638
+ if (!quiet) {
68556
68639
  console.log(` Baseline saved${entry.label ? ` (${entry.label})` : ""}: ${entry.runId}`);
68557
68640
  }
68558
68641
  }
@@ -68563,7 +68646,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68563
68646
  process.exit(testFail ? 3 : 2);
68564
68647
  }
68565
68648
  if (testFail) {
68566
- if (overThreshold) {
68649
+ if (overThreshold && !stdoutFormat) {
68567
68650
  console.log(` Error rate ${summary.errorRate}% exceeds threshold ${opts.failThreshold}%`);
68568
68651
  }
68569
68652
  process.exit(1);
@@ -68575,14 +68658,17 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68575
68658
  process.exit(1);
68576
68659
  }
68577
68660
  });
68578
- program.command("workflow").description("Execute a workflow file as a performance test").argument("<file>", "Path to a workflow .yaml, .yml, or .json file").option("-i, --iterations <n>", "Total number of workflow iterations", (v) => parseInt(v, 10)).option("-c, --concurrency <n>", "Number of concurrent iterations", (v) => parseInt(v, 10)).option("--var <vars...>", "Set workflow variables (format: name=value)").option("--timeout <sec>", "Per-request timeout in seconds", (v) => parseInt(v, 10)).option("--error-policy <policy>", "Error policy: continue, stop-first, stop-threshold").option("--max-errors <n>", "Stop after N errors (threshold mode)", (v) => parseInt(v, 10)).option("--max-error-rate <pct>", "Stop at error rate % (threshold mode)", (v) => parseFloat(v)).option("--base-url <url>", "Base URL for HTTP nodes with relative paths").option("--trace-level <level>", "Trace capture level: minimal, standard, full, debug (default: standard)").option("--trace-output <path>", "Write the full execution trace (per-node/per-iteration) as JSON to file").option("--fail-on-error", "Exit code 1 if any request fails").option("--fail-threshold <pct>", "Exit code 1 if error rate exceeds this %", (v) => parseFloat(v)).option("-o, --output <path>", "Write JSON report to file").option("--junit <path>", "Write JUnit XML report to file").option("--markdown <path>", "Write Markdown report to file").option("-q, --quiet", "Suppress progress output").action(async (filePath, opts) => {
68661
+ program.command("workflow").description("Execute a workflow file as a performance test").argument("<file>", "Path to a workflow .yaml, .yml, or .json file").option("-i, --iterations <n>", "Total number of workflow iterations", (v) => parseInt(v, 10)).option("-c, --concurrency <n>", "Number of concurrent iterations", (v) => parseInt(v, 10)).option("--var <vars...>", "Set workflow variables (format: name=value)").option("--timeout <sec>", "Per-request timeout in seconds", (v) => parseInt(v, 10)).option("--error-policy <policy>", "Error policy: continue, stop-first, stop-threshold").option("--max-errors <n>", "Stop after N errors (threshold mode)", (v) => parseInt(v, 10)).option("--max-error-rate <pct>", "Stop at error rate % (threshold mode)", (v) => parseFloat(v)).option("--base-url <url>", "Base URL for HTTP nodes with relative paths").option("--trace-level <level>", "Trace capture level: minimal, standard, full, debug (default: standard)").option("--trace-output <path>", "Write the full execution trace (per-node/per-iteration) as JSON to file").option("--fail-on-error", "Exit code 1 if any request fails").option("--fail-threshold <pct>", "Exit code 1 if error rate exceeds this %", (v) => parseFloat(v)).option("-o, --output <path|json|junit>", "Write JSON report to file, or print `json`/`junit` to stdout for CI").option("--junit <path>", "Write JUnit XML report to file").option("--markdown <path>", "Write Markdown report to file").option("-q, --quiet", "Suppress progress output").action(async (filePath, opts) => {
68579
68662
  try {
68663
+ const outputTarget = resolveOutputTarget(opts.output);
68664
+ const stdoutFormat = stdoutFormatOf(outputTarget);
68665
+ const quiet = Boolean(opts.quiet) || stdoutFormat !== null;
68580
68666
  const absPath = resolve5(filePath);
68581
68667
  if (!existsSync5(absPath)) {
68582
68668
  throw new Error(`Workflow file not found: ${absPath}`);
68583
68669
  }
68584
68670
  const workflow = loadWorkflowFile(absPath);
68585
- if (!opts.quiet) {
68671
+ if (!quiet) {
68586
68672
  console.log(`
68587
68673
  Loading: ${basename2(absPath)}`);
68588
68674
  console.log(` Workflow: ${workflow.name}`);
@@ -68601,7 +68687,7 @@ program.command("workflow").description("Execute a workflow file as a performanc
68601
68687
  variables[name2] = value;
68602
68688
  }
68603
68689
  }
68604
- if (!opts.quiet && Object.keys(variables).length > 0) {
68690
+ if (!quiet && Object.keys(variables).length > 0) {
68605
68691
  console.log(` Variables: ${Object.keys(variables).length}`);
68606
68692
  for (const [k, v] of Object.entries(variables)) {
68607
68693
  const display = v.length > 40 ? v.slice(0, 37) + "..." : v;
@@ -68610,13 +68696,13 @@ program.command("workflow").description("Execute a workflow file as a performanc
68610
68696
  }
68611
68697
  const iterations = opts.iterations ?? 10;
68612
68698
  const concurrency = opts.concurrency ?? 1;
68613
- if (!opts.quiet) {
68699
+ if (!quiet) {
68614
68700
  console.log(` Mode: workflow (I:${iterations} C:${concurrency})`);
68615
68701
  console.log("");
68616
68702
  }
68617
68703
  const abortController = new AbortController();
68618
68704
  process.on("SIGINT", () => {
68619
- if (!opts.quiet) console.log("\n Aborting...");
68705
+ if (!quiet) console.log("\n Aborting...");
68620
68706
  abortController.abort();
68621
68707
  });
68622
68708
  const breaker = new CircuitBreaker({
@@ -68626,7 +68712,7 @@ program.command("workflow").description("Execute a workflow file as a performanc
68626
68712
  });
68627
68713
  let lastPrinted = 0;
68628
68714
  const onProgress = (completed, total, _results, _meta) => {
68629
- if (opts.quiet) return;
68715
+ if (quiet) return;
68630
68716
  const now = Date.now();
68631
68717
  if (now - lastPrinted < 500 && completed < total) return;
68632
68718
  lastPrinted = now;
@@ -68634,7 +68720,7 @@ program.command("workflow").description("Execute a workflow file as a performanc
68634
68720
  process.stdout.write(`\r Progress: ${completed}/${total} iterations (${pct}%)`);
68635
68721
  };
68636
68722
  const baseUrl = opts.baseUrl?.trim();
68637
- if (!opts.quiet && baseUrl) {
68723
+ if (!quiet && baseUrl) {
68638
68724
  console.log(` Base URL: ${baseUrl}`);
68639
68725
  }
68640
68726
  const t0 = performance.now();
@@ -68659,12 +68745,18 @@ program.command("workflow").description("Execute a workflow file as a performanc
68659
68745
  });
68660
68746
  const elapsed = performance.now() - t0;
68661
68747
  const summary = computeMetrics(results, elapsed);
68662
- if (!opts.quiet) {
68748
+ if (!quiet) {
68663
68749
  process.stdout.write("\r" + " ".repeat(60) + "\r");
68664
68750
  }
68665
- printWorkflowConsoleSummary(summary, workflow, iterations, concurrency, results);
68751
+ if (!stdoutFormat) {
68752
+ printWorkflowConsoleSummary(summary, workflow, iterations, concurrency, results);
68753
+ }
68666
68754
  const meta = { name: workflow.name, file: basename2(absPath) };
68667
- if (opts.output) {
68755
+ if (stdoutFormat === "json") {
68756
+ process.stdout.write(JSON.stringify(buildWorkflowCiJsonReport(results, iterations, elapsed), null, 2) + "\n");
68757
+ } else if (stdoutFormat === "junit") {
68758
+ process.stdout.write(buildWorkflowJunitXml(results, summary, workflow.name, iterations) + "\n");
68759
+ } else if (outputTarget?.kind === "file") {
68668
68760
  const report = buildJsonReport(results, summary, {
68669
68761
  concurrency,
68670
68762
  iterations,
@@ -68679,29 +68771,31 @@ program.command("workflow").description("Execute a workflow file as a performanc
68679
68771
  maxErrors: opts.maxErrors ?? 10,
68680
68772
  maxErrorRate: opts.maxErrorRate ?? 50
68681
68773
  }, meta);
68682
- writeFileSync3(resolve5(opts.output), JSON.stringify(report, null, 2));
68683
- console.log(` JSON report: ${opts.output}`);
68774
+ writeFileSync3(resolve5(outputTarget.path), JSON.stringify(report, null, 2));
68775
+ console.log(` JSON report: ${outputTarget.path}`);
68684
68776
  }
68685
68777
  if (opts.junit) {
68686
68778
  const xml = buildWorkflowJunitXml(results, summary, workflow.name, iterations);
68687
68779
  writeFileSync3(resolve5(opts.junit), xml);
68688
- console.log(` JUnit XML: ${opts.junit}`);
68780
+ if (!quiet) console.log(` JUnit XML: ${opts.junit}`);
68689
68781
  }
68690
68782
  if (opts.markdown) {
68691
68783
  const md = buildWorkflowMarkdownReport(summary, workflow, iterations, concurrency, results);
68692
68784
  writeFileSync3(resolve5(opts.markdown), md);
68693
- console.log(` Markdown: ${opts.markdown}`);
68785
+ if (!quiet) console.log(` Markdown: ${opts.markdown}`);
68694
68786
  }
68695
68787
  if (opts.traceOutput) {
68696
68788
  writeFileSync3(resolve5(opts.traceOutput), JSON.stringify(trace, null, 2));
68697
- console.log(` Trace: ${opts.traceOutput}`);
68789
+ if (!quiet) console.log(` Trace: ${opts.traceOutput}`);
68698
68790
  }
68699
68791
  const passed = summary.failedRequests === 0 && summary.failedValidations === 0;
68700
68792
  if (opts.failOnError && !passed) {
68701
68793
  process.exit(1);
68702
68794
  }
68703
68795
  if (opts.failThreshold != null && summary.errorRate > opts.failThreshold) {
68704
- console.log(` Error rate ${summary.errorRate}% exceeds threshold ${opts.failThreshold}%`);
68796
+ if (!stdoutFormat) {
68797
+ console.log(` Error rate ${summary.errorRate}% exceeds threshold ${opts.failThreshold}%`);
68798
+ }
68705
68799
  process.exit(1);
68706
68800
  }
68707
68801
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redfireforge-cli",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "description": "RedfireForge CLI — API Performance Testing from the Command Line",
5
5
  "keywords": [
6
6
  "api",
@@ -14,13 +14,13 @@
14
14
  "workflow",
15
15
  "cli"
16
16
  ],
17
- "homepage": "https://github.com/redfireforge/redfire-forge#readme",
17
+ "homepage": "https://github.com/redfireforge/redfireforge-public#readme",
18
18
  "bugs": {
19
- "url": "https://github.com/redfireforge/redfire-forge/issues"
19
+ "url": "https://github.com/redfireforge/redfireforge-public/issues"
20
20
  },
21
21
  "repository": {
22
22
  "type": "git",
23
- "url": "git+https://github.com/redfireforge/redfire-forge.git"
23
+ "url": "git+https://github.com/redfireforge/redfireforge-public.git"
24
24
  },
25
25
  "license": "AGPL-3.0-or-later",
26
26
  "author": "RedfireForge",