Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>]`, 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
Expand Down
22 changes: 19 additions & 3 deletions bin/twogo.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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);
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -61,6 +62,8 @@ export {
suite,
run,
reset,
toJUnit,
toJSON,
expect,
Expectation,
validate,
Expand Down
21 changes: 21 additions & 0 deletions src/reporters.d.ts
Original file line number Diff line number Diff line change
@@ -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;
64 changes: 64 additions & 0 deletions src/reporters.js
Original file line number Diff line number Diff line change
@@ -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) =>
({ "<": "&lt;", ">": "&gt;", "&": "&amp;", '"': "&quot;", "'": "&apos;" }[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('<?xml version="1.0" encoding="UTF-8"?>');
lines.push(`<testsuites tests="${tests.length}" failures="${totalFailures}" time="${totalTime}">`);
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(` <testsuite name="${escapeXml(suite)}" tests="${list.length}" failures="${failures}" time="${time}">`);
for (const t of list) {
const open = ` <testcase name="${escapeXml(t.name)}" classname="${escapeXml(suite)}" time="${seconds(t.durationMs)}">`;
if (t.status === "failed") {
lines.push(open);
lines.push(` <failure message="${escapeXml(t.error || "assertion failed")}"></failure>`);
lines.push(" </testcase>");
} else {
lines.push(open + "</testcase>");
}
}
lines.push(" </testsuite>");
}
lines.push("</testsuites>");
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"
);
}
9 changes: 9 additions & 0 deletions src/runner.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
Expand All @@ -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 });
}
}

Expand All @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions test/unit/reporters.test.mjs
Original file line number Diff line number Diff line change
@@ -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 <oops>" },
],
};

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, /<testsuites tests="3" failures="1"/);
assert.match(xml, /<testsuite name="users" tests="2" failures="0"/);
assert.match(xml, /<testsuite name="auth" tests="1" failures="1"/);
assert.match(xml, /<testcase name="lists users" classname="users"[^>]*><\/testcase>/);
assert.match(xml, /<failure message="expected 401 but got 200 &lt;oops&gt;">/);
});

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, /<testsuites tests="0" failures="0"/);
assert.equal(JSON.parse(toJSON({ passed: 0, failed: 0, tests: [] })).total, 0);
});
Loading