From dc34980c36d1f4513f13e0738fc99efc7e85ac62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tu=C4=9Fkan=20Boz?= Date: Wed, 3 Jun 2026 13:30:08 +0300 Subject: [PATCH] feat: JUnit and JSON reporters for the runner run() now returns a tests array (suite, name, status, durationMs, error). Add toJUnit/toJSON (two-go/reporters) and a CLI flag --reporter junit|json [--out file]. Types, unit tests, CHANGELOG. --- CHANGELOG.md | 4 +++ bin/twogo.js | 22 +++++++++++-- package.json | 3 +- src/index.js | 3 ++ src/reporters.d.ts | 21 ++++++++++++ src/reporters.js | 64 ++++++++++++++++++++++++++++++++++++ src/runner.d.ts | 9 +++++ src/runner.js | 8 +++-- test/unit/reporters.test.mjs | 39 ++++++++++++++++++++++ 9 files changed, 167 insertions(+), 6 deletions(-) create mode 100644 src/reporters.d.ts create mode 100644 src/reporters.js create mode 100644 test/unit/reporters.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c0808a..46e8dc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,10 @@ All notable changes to this project are documented here. This project follows ### Added +- **Reporters** (`two-go/reporters`): `toJUnit(result)` and `toJSON(result)` + turn a `run()` result into CI-friendly output. The CLI gained + `--reporter junit|json [--out ]`, and `run()` now returns a `tests` + array with per-test status, duration, and error. - **BDD layer** (`two-go/bdd`): runner-agnostic `given` / `when` / `then` / `and` plus `scenario(steps)` and `feature(...)`. `scenario` returns an async function for your runner's `test()`, with steps sharing a `world`. Does not diff --git a/bin/twogo.js b/bin/twogo.js index 4f6a358..de9c056 100644 --- a/bin/twogo.js +++ b/bin/twogo.js @@ -16,6 +16,7 @@ import { run } from "../src/runner.js"; import { fromPostman } from "../src/importers/postman.js"; import { fromOpenapi } from "../src/importers/openapi.js"; import { aiGenerateTests } from "../src/ai/generate.js"; +import { toJUnit, toJSON } from "../src/reporters.js"; // Read the value following a flag in argv, or undefined. function flag(name) { @@ -124,7 +125,8 @@ if (process.argv[2] === "ai" && process.argv[3] === "gen") { process.exit(0); } -const target = process.argv[2] || "test"; +// The directory is the first non-flag argument; default to "test". +const target = process.argv[2] && !process.argv[2].startsWith("-") ? process.argv[2] : "test"; const root = resolve(process.cwd(), target); if (!existsSync(root)) { @@ -168,5 +170,19 @@ for (const file of files) { await import(pathToFileURL(file).href); } -const { failed } = await run(); -process.exit(failed > 0 ? 1 : 0); +const result = await run(); + +// Optional machine-readable report: --reporter junit|json [--out file]. +const reporter = flag("--reporter"); +if (reporter === "junit" || reporter === "json") { + const report = reporter === "junit" ? toJUnit(result) : toJSON(result); + const out = flag("--out"); + if (out) { + writeFileSync(out, report, "utf8"); + console.log(`two-go: wrote ${out}`); + } else { + process.stdout.write(report); + } +} + +process.exit(result.failed > 0 ? 1 : 0); diff --git a/package.json b/package.json index d299592..c1b7b66 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "./importers": { "types": "./src/importers/index.d.ts", "default": "./src/importers/index.js" }, "./ai": { "types": "./src/ai/index.d.ts", "default": "./src/ai/index.js" }, "./mcp": { "types": "./src/mcp/server.d.ts", "default": "./src/mcp/server.js" }, - "./bdd": { "types": "./src/bdd.d.ts", "default": "./src/bdd.js" } + "./bdd": { "types": "./src/bdd.d.ts", "default": "./src/bdd.js" }, + "./reporters": { "types": "./src/reporters.d.ts", "default": "./src/reporters.js" } }, "bin": { "two-go": "bin/twogo.js", diff --git a/src/index.js b/src/index.js index 0e98d2a..427b29f 100644 --- a/src/index.js +++ b/src/index.js @@ -4,6 +4,7 @@ import { GoClient, RequestBuilder } from "./client.js"; import { GoResponse } from "./response.js"; import { AssertionError, resolvePath, matches, deepEqual } from "./assertions.js"; import { suite, run, reset } from "./runner.js"; +import { toJUnit, toJSON } from "./reporters.js"; import { expect, Expectation } from "./expect.js"; import { validate, isValid } from "./schema.js"; import { chain } from "./utils/chain.js"; @@ -61,6 +62,8 @@ export { suite, run, reset, + toJUnit, + toJSON, expect, Expectation, validate, diff --git a/src/reporters.d.ts b/src/reporters.d.ts new file mode 100644 index 0000000..2ddc9c1 --- /dev/null +++ b/src/reporters.d.ts @@ -0,0 +1,21 @@ +// Type declarations for the built-in runner reporters. + +export interface TestResult { + suite: string; + name: string; + status: "passed" | "failed"; + durationMs: number; + error: string | null; +} + +export interface RunResult { + passed: number; + failed: number; + tests: TestResult[]; +} + +/** Render a run() result as JUnit XML. */ +export declare function toJUnit(result: RunResult): string; + +/** Render a run() result as JSON. */ +export declare function toJSON(result: RunResult): string; diff --git a/src/reporters.js b/src/reporters.js new file mode 100644 index 0000000..5dfaafc --- /dev/null +++ b/src/reporters.js @@ -0,0 +1,64 @@ +// Reporters for the built-in runner. Pass the object returned by run() +// (which carries a `tests` array) and get a CI-friendly string back. + +function escapeXml(value) { + return String(value).replace(/[<>&"']/g, (c) => + ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" }[c]) + ); +} + +function seconds(ms) { + return ((ms || 0) / 1000).toFixed(3); +} + +// Produce JUnit XML from a run() result. Tests are grouped by suite. +export function toJUnit(result) { + const tests = (result && result.tests) || []; + const bySuite = new Map(); + for (const t of tests) { + if (!bySuite.has(t.suite)) bySuite.set(t.suite, []); + bySuite.get(t.suite).push(t); + } + + const totalFailures = tests.filter((t) => t.status === "failed").length; + const totalTime = seconds(tests.reduce((a, t) => a + (t.durationMs || 0), 0)); + + const lines = []; + lines.push(''); + lines.push(``); + for (const [suite, list] of bySuite) { + const failures = list.filter((t) => t.status === "failed").length; + const time = seconds(list.reduce((a, t) => a + (t.durationMs || 0), 0)); + lines.push(` `); + for (const t of list) { + const open = ` `; + if (t.status === "failed") { + lines.push(open); + lines.push(` `); + lines.push(" "); + } else { + lines.push(open + ""); + } + } + lines.push(" "); + } + lines.push(""); + return lines.join("\n") + "\n"; +} + +// Produce a JSON report from a run() result. +export function toJSON(result) { + const tests = (result && result.tests) || []; + return ( + JSON.stringify( + { + passed: result ? result.passed : 0, + failed: result ? result.failed : 0, + total: tests.length, + tests, + }, + null, + 2 + ) + "\n" + ); +} diff --git a/src/runner.d.ts b/src/runner.d.ts index 73864cb..08c4995 100644 --- a/src/runner.d.ts +++ b/src/runner.d.ts @@ -13,9 +13,18 @@ export interface SuiteEntry { afterHooks: Array<() => unknown>; } +export interface TestResult { + suite: string; + name: string; + status: "passed" | "failed"; + durationMs: number; + error: string | null; +} + export interface RunResult { passed: number; failed: number; + tests: TestResult[]; } export declare function suite(name: string, fn: (api: SuiteApi) => void): SuiteEntry; diff --git a/src/runner.js b/src/runner.js index 167e468..bd299d7 100644 --- a/src/runner.js +++ b/src/runner.js @@ -36,10 +36,12 @@ const green = (s) => (useColor ? `\x1b[32m${s}\x1b[0m` : s); const red = (s) => (useColor ? `\x1b[31m${s}\x1b[0m` : s); const dim = (s) => (useColor ? `\x1b[2m${s}\x1b[0m` : s); -// Run all registered suites. Returns { passed, failed }. +// Run all registered suites. Returns { passed, failed, tests } where tests is +// a flat list of { suite, name, status, durationMs, error } for reporters. export async function run() { let passed = 0; let failed = 0; + const tests = []; for (const s of registry) { console.log(`\n${s.name}`); @@ -55,12 +57,14 @@ export async function run() { const ms = Math.round(performance.now() - start); console.log(` ${green("✓")} ${t.name} ${dim(`(${ms}ms)`)}`); passed++; + tests.push({ suite: s.name, name: t.name, status: "passed", durationMs: ms, error: null }); } catch (err) { const ms = Math.round(performance.now() - start); console.log(` ${red("✗")} ${t.name} ${dim(`(${ms}ms)`)}`); const message = err && err.message ? err.message : String(err); console.log(` ${red(message)}`); failed++; + tests.push({ suite: s.name, name: t.name, status: "failed", durationMs: ms, error: message }); } } @@ -75,7 +79,7 @@ export async function run() { process.exitCode = 1; } - return { passed, failed }; + return { passed, failed, tests }; } // Clear the registry. Useful for tests that drive the runner themselves. diff --git a/test/unit/reporters.test.mjs b/test/unit/reporters.test.mjs new file mode 100644 index 0000000..6c98ae6 --- /dev/null +++ b/test/unit/reporters.test.mjs @@ -0,0 +1,39 @@ +// Unit tests for the JUnit and JSON reporters. +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { toJUnit, toJSON } from "../../src/reporters.js"; + +const sample = { + passed: 2, + failed: 1, + tests: [ + { suite: "users", name: "lists users", status: "passed", durationMs: 12, error: null }, + { suite: "users", name: "creates a user", status: "passed", durationMs: 8, error: null }, + { suite: "auth", name: "rejects bad password", status: "failed", durationMs: 5, error: "expected 401 but got 200 " }, + ], +}; + +test("toJUnit produces well-formed grouped XML with failures and escaping", () => { + const xml = toJUnit(sample); + assert.match(xml, /^<\?xml version="1.0" encoding="UTF-8"\?>/); + assert.match(xml, /]*><\/testcase>/); + assert.match(xml, //); +}); + +test("toJSON produces a structured report", () => { + const report = JSON.parse(toJSON(sample)); + assert.equal(report.passed, 2); + assert.equal(report.failed, 1); + assert.equal(report.total, 3); + assert.equal(report.tests.length, 3); + assert.equal(report.tests[2].status, "failed"); +}); + +test("reporters handle an empty result", () => { + const xml = toJUnit({ passed: 0, failed: 0, tests: [] }); + assert.match(xml, /