redfireforge-cli 0.8.1 → 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)
@@ -30920,7 +30920,11 @@ async function nodeFetch(url, method, headers, body, signal) {
30920
30920
  if (signal) opts.signal = signal;
30921
30921
  const doFetch = async (fetchOpts) => {
30922
30922
  const t0 = performance.now();
30923
- const response = await fetch(targetUrl, fetchOpts);
30923
+ const fetchFn = fetchOpts.dispatcher ? (await import(
30924
+ /* @vite-ignore */
30925
+ "undici"
30926
+ )).fetch : fetch;
30927
+ const response = await fetchFn(targetUrl, fetchOpts);
30924
30928
  const tFirstByte = performance.now();
30925
30929
  const responseBody = await response.text();
30926
30930
  const tDone = performance.now();
@@ -63454,6 +63458,59 @@ function buildJsonReport(results, summary, config, meta) {
63454
63458
  projectName: meta.name
63455
63459
  };
63456
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
+ }
63457
63514
  function escapeXml(s2) {
63458
63515
  return s2.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
63459
63516
  }
@@ -63651,26 +63708,26 @@ function computePerStepStats(results) {
63651
63708
  }
63652
63709
  return stats;
63653
63710
  }
63654
- function computePerIterationStats(results, iterations) {
63711
+ function groupResultsByIteration(results, iterations) {
63655
63712
  const byIteration = /* @__PURE__ */ new Map();
63656
63713
  for (const r of results) {
63657
63714
  const idx = r.iterationIndex ?? 0;
63658
63715
  if (!byIteration.has(idx)) byIteration.set(idx, []);
63659
63716
  byIteration.get(idx).push(r);
63660
63717
  }
63661
- const stats = [];
63718
+ const grouped = [];
63662
63719
  for (let i = 0; i < iterations; i++) {
63663
- const iterResults = byIteration.get(i) || [];
63664
- const allPassed = iterResults.every((r) => r.passed);
63665
- const totalDuration = iterResults.reduce((sum, r) => sum + r.responseTimeMs, 0);
63666
- stats.push({
63667
- index: i,
63668
- passed: allPassed,
63669
- durationMs: totalDuration,
63670
- stepCount: iterResults.length
63671
- });
63720
+ grouped.push(byIteration.get(i) || []);
63672
63721
  }
63673
- 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
+ }));
63674
63731
  }
63675
63732
  function formatRowLabel(r) {
63676
63733
  return r.dataRowLabel || r.dataRowId || "?";
@@ -63890,6 +63947,22 @@ function buildWorkflowMarkdownReport(summary, workflow, iterations, concurrency,
63890
63947
  return lines.join("\n");
63891
63948
  }
63892
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
+
63893
63966
  // cli/slaEval.ts
63894
63967
  import { readFileSync as readFileSync3 } from "node:fs";
63895
63968
  function getMetricFromSummary(metric, summary) {
@@ -67059,7 +67132,7 @@ var ApiMockNetworkListener = class {
67059
67132
  handler
67060
67133
  );
67061
67134
  } catch (err) {
67062
- 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 });
67063
67136
  }
67064
67137
  } else {
67065
67138
  server = http.createServer(handler);
@@ -67810,6 +67883,8 @@ function sleep4(ms) {
67810
67883
 
67811
67884
  // cli/mockCommands.ts
67812
67885
  async function runMockSimulate(opts) {
67886
+ const outputTarget = resolveOutputTarget(opts.output);
67887
+ const stdoutFormat = stdoutFormatOf(outputTarget);
67813
67888
  const raw = asWorkspace(loadDefinitionFile(opts.file));
67814
67889
  const loaded = cliLoadAndValidate(raw);
67815
67890
  if (reportValidation(loaded.validationErrors)) return 1;
@@ -67828,10 +67903,7 @@ async function runMockSimulate(opts) {
67828
67903
  failed: failed.length,
67829
67904
  results
67830
67905
  };
67831
- const json = JSON.stringify(summary, null, 2);
67832
- if (opts.output) writeFileSync2(resolve4(opts.output), json, "utf8");
67833
- else console.log(json);
67834
- if (opts.junit) {
67906
+ const buildJunit = () => {
67835
67907
  const cases = results.map((r) => {
67836
67908
  const name2 = r.sampleId;
67837
67909
  if (isFailedSimulation(r)) {
@@ -67839,12 +67911,18 @@ async function runMockSimulate(opts) {
67839
67911
  }
67840
67912
  return `<testcase classname="api-mock" name="${escapeXml2(name2)}"/>`;
67841
67913
  }).join("\n");
67842
- const xml = `<?xml version="1.0" encoding="UTF-8"?>
67914
+ return `<?xml version="1.0" encoding="UTF-8"?>
67843
67915
  <testsuite name="api-mock-simulate" tests="${results.length}" failures="${failed.length}">
67844
67916
  ${cases}
67845
67917
  </testsuite>
67846
67918
  `;
67847
- 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");
67848
67926
  }
67849
67927
  console.error(`Simulated ${results.length} sample(s); ${failed.length} failure(s).`);
67850
67928
  return failed.length > 0 ? 1 : 0;
@@ -68268,7 +68346,7 @@ async function runMockVerifyAllRoutes(opts) {
68268
68346
  // cli/mockCommandRegistration.ts
68269
68347
  function registerMockCommands(program2) {
68270
68348
  const mock = program2.command("mock").description("API Mock Studio headless commands");
68271
- 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) => {
68272
68350
  const code = await runMockSimulate({ file, serverId: opts.server, output: opts.output, junit: opts.junit });
68273
68351
  process.exit(code);
68274
68352
  });
@@ -68344,15 +68422,18 @@ var __dirname = dirname2(fileURLToPath(import.meta.url));
68344
68422
  var pkg = JSON.parse(readFileSync7(resolve5(__dirname, "..", "package.json"), "utf-8"));
68345
68423
  var program = new Command();
68346
68424
  program.name("redfireforge").description("RedfireForge CLI \u2014 run API performance tests from YAML/JSON files").version(pkg.version);
68347
- 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) => {
68348
68426
  try {
68427
+ const outputTarget = resolveOutputTarget(opts.output);
68428
+ const stdoutFormat = stdoutFormatOf(outputTarget);
68429
+ const quiet = Boolean(opts.quiet) || stdoutFormat !== null;
68349
68430
  const absPath = resolve5(filePath);
68350
68431
  const file = loadTestFile(absPath);
68351
68432
  let externalDataSource;
68352
68433
  if (opts.data) {
68353
68434
  const dataPath = resolve5(opts.data);
68354
68435
  externalDataSource = loadDataFile(dataPath);
68355
- if (!opts.quiet) {
68436
+ if (!quiet) {
68356
68437
  console.log(` Data: ${basename2(dataPath)} (${externalDataSource.rows.length} rows)`);
68357
68438
  }
68358
68439
  }
@@ -68362,7 +68443,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68362
68443
  throw new Error(`No test found matching --scenario "${opts.scenario}"`);
68363
68444
  }
68364
68445
  }
68365
- if (!opts.quiet) {
68446
+ if (!quiet) {
68366
68447
  console.log(`
68367
68448
  Loading: ${basename2(absPath)}`);
68368
68449
  console.log(` Tests: ${file.tests.length}`);
@@ -68378,7 +68459,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68378
68459
  if (scTags.length === 0) return false;
68379
68460
  return tagMode === "any" ? filterTags.some((t) => scTags.includes(t)) : filterTags.every((t) => scTags.includes(t));
68380
68461
  });
68381
- if (!opts.quiet) {
68462
+ if (!quiet) {
68382
68463
  console.log(` Scenario tags: ${filterTags.join(", ")} (mode: ${tagMode}, ${scenarios.length}/${before} scenarios matched)`);
68383
68464
  }
68384
68465
  if (scenarios.length === 0) {
@@ -68392,7 +68473,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68392
68473
  const before = scenarios.length;
68393
68474
  const result = filterScenariosByRowTags(scenarios, filterTags, tagMode);
68394
68475
  scenarios = result.scenarios;
68395
- if (!opts.quiet) {
68476
+ if (!quiet) {
68396
68477
  console.log(` Tags: ${filterTags.join(", ")} (mode: ${tagMode}, ${result.matchingRowCount} matching rows, ${scenarios.length}/${before} scenarios retained)`);
68397
68478
  if (result.droppedScenarioNames.length > 0) {
68398
68479
  console.log(` Dropped: ${result.droppedScenarioNames.join(", ")} (no rows matched the tag filter)`);
@@ -68415,7 +68496,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68415
68496
  maxErrors: opts.maxErrors,
68416
68497
  maxErrorRate: opts.maxErrorRate
68417
68498
  });
68418
- if (!opts.quiet) {
68499
+ if (!quiet) {
68419
68500
  console.log(` Mode: ${config.executionMode} (C:${config.concurrency} I:${config.iterations})`);
68420
68501
  const paramTests = scenarios.filter((s2) => s2.dataSource && s2.dataSource.rows.length > 0);
68421
68502
  if (paramTests.length > 0) {
@@ -68426,12 +68507,12 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68426
68507
  }
68427
68508
  const abortController = new AbortController();
68428
68509
  process.on("SIGINT", () => {
68429
- if (!opts.quiet) console.log("\n Aborting...");
68510
+ if (!quiet) console.log("\n Aborting...");
68430
68511
  abortController.abort();
68431
68512
  });
68432
68513
  let lastPrinted = 0;
68433
68514
  const onProgress = (completed, total, _results, meta2) => {
68434
- if (opts.quiet) return;
68515
+ if (quiet) return;
68435
68516
  const now = Date.now();
68436
68517
  if (now - lastPrinted < 500 && completed < total) return;
68437
68518
  lastPrinted = now;
@@ -68448,41 +68529,47 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68448
68529
  const { results } = await runTest(config, scenarios, onProgress, abortController.signal);
68449
68530
  const elapsed = performance.now() - t0;
68450
68531
  const summary = computeMetrics(results, elapsed);
68451
- if (!opts.quiet) {
68532
+ if (!quiet) {
68452
68533
  process.stdout.write("\r" + " ".repeat(60) + "\r");
68453
68534
  }
68454
- printConsoleSummary(summary, config, results);
68535
+ if (!stdoutFormat) {
68536
+ printConsoleSummary(summary, config, results);
68537
+ }
68455
68538
  const suiteName = file.name || basename2(absPath, ".yaml").replace(/\.yml$|\.json$/, "");
68456
68539
  const meta = { name: file.name, env: opts.env || file.env, file: basename2(absPath) };
68457
68540
  const slaTargets = opts.slaConfig ? loadSlaTargetFile(resolve5(opts.slaConfig)) : void 0;
68458
68541
  if (slaTargets) {
68459
68542
  config.slaTargets = slaTargets;
68460
68543
  }
68461
- 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") {
68462
68549
  const report = buildJsonReport(results, summary, config, meta);
68463
- writeFileSync3(resolve5(opts.output), JSON.stringify(report, null, 2));
68464
- console.log(` JSON report: ${opts.output}`);
68550
+ writeFileSync3(resolve5(outputTarget.path), JSON.stringify(report, null, 2));
68551
+ console.log(` JSON report: ${outputTarget.path}`);
68465
68552
  }
68466
68553
  if (opts.junit) {
68467
68554
  const xml = buildJunitXml(results, summary, suiteName);
68468
68555
  writeFileSync3(resolve5(opts.junit), xml);
68469
- console.log(` JUnit XML: ${opts.junit}`);
68556
+ if (!quiet) console.log(` JUnit XML: ${opts.junit}`);
68470
68557
  }
68471
68558
  if (opts.markdown) {
68472
68559
  const md = buildMarkdownReport(summary, config, meta, results);
68473
68560
  writeFileSync3(resolve5(opts.markdown), md);
68474
- console.log(` Markdown: ${opts.markdown}`);
68561
+ if (!quiet) console.log(` Markdown: ${opts.markdown}`);
68475
68562
  }
68476
68563
  if (opts.dataRowsSummary) {
68477
68564
  const rowSummary = buildDataRowSummary(results);
68478
68565
  writeFileSync3(resolve5(opts.dataRowsSummary), JSON.stringify(rowSummary, null, 2));
68479
- console.log(` Data Rows: ${opts.dataRowsSummary}`);
68566
+ if (!quiet) console.log(` Data Rows: ${opts.dataRowsSummary}`);
68480
68567
  }
68481
68568
  let hasSlaFail = false;
68482
68569
  if (slaTargets) {
68483
68570
  const checks = evaluateCliSla(summary, results, slaTargets);
68484
68571
  hasSlaFail = !!(opts.failOnSla && overallSlaStatus(checks) === "fail");
68485
- printSlaReport(checks, opts.quiet && !hasSlaFail);
68572
+ printSlaReport(checks, stdoutFormat !== null || quiet && !hasSlaFail);
68486
68573
  }
68487
68574
  let hasRegression = false;
68488
68575
  if (opts.compareBaseline) {
@@ -68491,12 +68578,12 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68491
68578
  let baselineEntry = null;
68492
68579
  if (sentinel === LATEST_BASELINE_SENTINEL) {
68493
68580
  baselineEntry = findLatestBaseline(absPath, baselinesDir);
68494
- if (!baselineEntry && !opts.quiet) {
68581
+ if (!baselineEntry && !quiet) {
68495
68582
  console.warn(` \u26A0 No baselines found for ${basename2(absPath)} \u2014 skipping regression check`);
68496
68583
  }
68497
68584
  } else {
68498
68585
  baselineEntry = findBaselineById(sentinel, baselinesDir);
68499
- if (!baselineEntry && !opts.quiet) {
68586
+ if (!baselineEntry && !quiet) {
68500
68587
  console.warn(` \u26A0 Baseline not found: "${sentinel}" \u2014 skipping regression check`);
68501
68588
  }
68502
68589
  }
@@ -68522,13 +68609,13 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68522
68609
  };
68523
68610
  const comparison = compareRuns(baselineRun, currentRun, DEFAULT_THRESHOLDS);
68524
68611
  printComparisonSummary(comparison, {
68525
- quiet: opts.quiet,
68612
+ quiet,
68526
68613
  baselineLabel: baselineEntry.label
68527
68614
  });
68528
68615
  if (opts.comparisonReport) {
68529
68616
  const md = buildComparisonMarkdown(comparison, baselineEntry.label);
68530
68617
  writeFileSync3(resolve5(opts.comparisonReport), md);
68531
- if (!opts.quiet) {
68618
+ if (!quiet) {
68532
68619
  console.log(` Comparison: ${opts.comparisonReport}`);
68533
68620
  }
68534
68621
  }
@@ -68548,7 +68635,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68548
68635
  summary
68549
68636
  };
68550
68637
  addCliBaseline(entry, baselinesDir);
68551
- if (!opts.quiet) {
68638
+ if (!quiet) {
68552
68639
  console.log(` Baseline saved${entry.label ? ` (${entry.label})` : ""}: ${entry.runId}`);
68553
68640
  }
68554
68641
  }
@@ -68559,7 +68646,7 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68559
68646
  process.exit(testFail ? 3 : 2);
68560
68647
  }
68561
68648
  if (testFail) {
68562
- if (overThreshold) {
68649
+ if (overThreshold && !stdoutFormat) {
68563
68650
  console.log(` Error rate ${summary.errorRate}% exceeds threshold ${opts.failThreshold}%`);
68564
68651
  }
68565
68652
  process.exit(1);
@@ -68571,14 +68658,17 @@ program.command("run").description("Execute a test file").argument("<file>", "Pa
68571
68658
  process.exit(1);
68572
68659
  }
68573
68660
  });
68574
- 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) => {
68575
68662
  try {
68663
+ const outputTarget = resolveOutputTarget(opts.output);
68664
+ const stdoutFormat = stdoutFormatOf(outputTarget);
68665
+ const quiet = Boolean(opts.quiet) || stdoutFormat !== null;
68576
68666
  const absPath = resolve5(filePath);
68577
68667
  if (!existsSync5(absPath)) {
68578
68668
  throw new Error(`Workflow file not found: ${absPath}`);
68579
68669
  }
68580
68670
  const workflow = loadWorkflowFile(absPath);
68581
- if (!opts.quiet) {
68671
+ if (!quiet) {
68582
68672
  console.log(`
68583
68673
  Loading: ${basename2(absPath)}`);
68584
68674
  console.log(` Workflow: ${workflow.name}`);
@@ -68597,7 +68687,7 @@ program.command("workflow").description("Execute a workflow file as a performanc
68597
68687
  variables[name2] = value;
68598
68688
  }
68599
68689
  }
68600
- if (!opts.quiet && Object.keys(variables).length > 0) {
68690
+ if (!quiet && Object.keys(variables).length > 0) {
68601
68691
  console.log(` Variables: ${Object.keys(variables).length}`);
68602
68692
  for (const [k, v] of Object.entries(variables)) {
68603
68693
  const display = v.length > 40 ? v.slice(0, 37) + "..." : v;
@@ -68606,13 +68696,13 @@ program.command("workflow").description("Execute a workflow file as a performanc
68606
68696
  }
68607
68697
  const iterations = opts.iterations ?? 10;
68608
68698
  const concurrency = opts.concurrency ?? 1;
68609
- if (!opts.quiet) {
68699
+ if (!quiet) {
68610
68700
  console.log(` Mode: workflow (I:${iterations} C:${concurrency})`);
68611
68701
  console.log("");
68612
68702
  }
68613
68703
  const abortController = new AbortController();
68614
68704
  process.on("SIGINT", () => {
68615
- if (!opts.quiet) console.log("\n Aborting...");
68705
+ if (!quiet) console.log("\n Aborting...");
68616
68706
  abortController.abort();
68617
68707
  });
68618
68708
  const breaker = new CircuitBreaker({
@@ -68622,7 +68712,7 @@ program.command("workflow").description("Execute a workflow file as a performanc
68622
68712
  });
68623
68713
  let lastPrinted = 0;
68624
68714
  const onProgress = (completed, total, _results, _meta) => {
68625
- if (opts.quiet) return;
68715
+ if (quiet) return;
68626
68716
  const now = Date.now();
68627
68717
  if (now - lastPrinted < 500 && completed < total) return;
68628
68718
  lastPrinted = now;
@@ -68630,7 +68720,7 @@ program.command("workflow").description("Execute a workflow file as a performanc
68630
68720
  process.stdout.write(`\r Progress: ${completed}/${total} iterations (${pct}%)`);
68631
68721
  };
68632
68722
  const baseUrl = opts.baseUrl?.trim();
68633
- if (!opts.quiet && baseUrl) {
68723
+ if (!quiet && baseUrl) {
68634
68724
  console.log(` Base URL: ${baseUrl}`);
68635
68725
  }
68636
68726
  const t0 = performance.now();
@@ -68655,12 +68745,18 @@ program.command("workflow").description("Execute a workflow file as a performanc
68655
68745
  });
68656
68746
  const elapsed = performance.now() - t0;
68657
68747
  const summary = computeMetrics(results, elapsed);
68658
- if (!opts.quiet) {
68748
+ if (!quiet) {
68659
68749
  process.stdout.write("\r" + " ".repeat(60) + "\r");
68660
68750
  }
68661
- printWorkflowConsoleSummary(summary, workflow, iterations, concurrency, results);
68751
+ if (!stdoutFormat) {
68752
+ printWorkflowConsoleSummary(summary, workflow, iterations, concurrency, results);
68753
+ }
68662
68754
  const meta = { name: workflow.name, file: basename2(absPath) };
68663
- 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") {
68664
68760
  const report = buildJsonReport(results, summary, {
68665
68761
  concurrency,
68666
68762
  iterations,
@@ -68675,29 +68771,31 @@ program.command("workflow").description("Execute a workflow file as a performanc
68675
68771
  maxErrors: opts.maxErrors ?? 10,
68676
68772
  maxErrorRate: opts.maxErrorRate ?? 50
68677
68773
  }, meta);
68678
- writeFileSync3(resolve5(opts.output), JSON.stringify(report, null, 2));
68679
- console.log(` JSON report: ${opts.output}`);
68774
+ writeFileSync3(resolve5(outputTarget.path), JSON.stringify(report, null, 2));
68775
+ console.log(` JSON report: ${outputTarget.path}`);
68680
68776
  }
68681
68777
  if (opts.junit) {
68682
68778
  const xml = buildWorkflowJunitXml(results, summary, workflow.name, iterations);
68683
68779
  writeFileSync3(resolve5(opts.junit), xml);
68684
- console.log(` JUnit XML: ${opts.junit}`);
68780
+ if (!quiet) console.log(` JUnit XML: ${opts.junit}`);
68685
68781
  }
68686
68782
  if (opts.markdown) {
68687
68783
  const md = buildWorkflowMarkdownReport(summary, workflow, iterations, concurrency, results);
68688
68784
  writeFileSync3(resolve5(opts.markdown), md);
68689
- console.log(` Markdown: ${opts.markdown}`);
68785
+ if (!quiet) console.log(` Markdown: ${opts.markdown}`);
68690
68786
  }
68691
68787
  if (opts.traceOutput) {
68692
68788
  writeFileSync3(resolve5(opts.traceOutput), JSON.stringify(trace, null, 2));
68693
- console.log(` Trace: ${opts.traceOutput}`);
68789
+ if (!quiet) console.log(` Trace: ${opts.traceOutput}`);
68694
68790
  }
68695
68791
  const passed = summary.failedRequests === 0 && summary.failedValidations === 0;
68696
68792
  if (opts.failOnError && !passed) {
68697
68793
  process.exit(1);
68698
68794
  }
68699
68795
  if (opts.failThreshold != null && summary.errorRate > opts.failThreshold) {
68700
- 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
+ }
68701
68799
  process.exit(1);
68702
68800
  }
68703
68801
  process.exit(0);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "redfireforge-cli",
3
- "version": "0.8.1",
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",