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
29 changes: 17 additions & 12 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,31 @@
All notable changes to this project are documented here. This project follows
[Semantic Versioning](https://semver.org/).

## [Unreleased]

### Changed

- The extended HTTP assertions (`expectClientError`, `expectJsonSchema`,
`expectJsonContains`, `expectCookie`, `expectSorted`, and the rest) are now
chainable on the request builder too, not only on a resolved response. So
`api.get("/x").expectStatus(200).expectJsonSchema(schema)` works. `expectValue`
stays response-only since it returns an `expect()`.
## [1.1.0]

### Added

- **GraphQL helper**: `client.graphql(query, variables, { path })` POSTs
`{ query, variables }` and returns the usual chainable builder.
- **Request retry/backoff**: `.retry({ attempts, delay, factor, on })` on the
builder retries on a thrown error or when `on(response)` is truthy.
- **Cookie jar**: `go({ cookies: true })` (or pass a `Map`) captures Set-Cookie
and replays Cookie on later requests from that client.
- **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
import any runner, so it works with node:test, Jest, Vitest, and Mocha.
`and` plus `scenario(steps)` and `feature(...)`. Works with node:test, Jest,
Vitest, and Mocha.

### Changed

- The extended HTTP assertions (`expectClientError`, `expectJsonSchema`,
`expectJsonContains`, `expectCookie`, `expectSorted`, and the rest) are now
chainable on the request builder too, not only on a resolved response. So
`api.get("/x").expectStatus(200).expectJsonSchema(schema)` works. `expectValue`
stays response-only since it returns an `expect()`.

## [1.0.0] - 2026-06-02

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "two-go",
"version": "1.0.0",
"version": "1.1.0",
"description": "Zero-dependency fluent service/API testing library: chainable HTTP requests, Jest-style + HTTP assertions, and a lodash-inspired utility belt. Works standalone or inside node:test, Jest, Vitest, and Mocha.",
"type": "module",
"main": "src/index.js",
Expand Down
17 changes: 17 additions & 0 deletions src/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ export interface GoClientOptions {
baseURL?: string;
headers?: Record<string, string>;
timeout?: number;
/** Enable a cookie jar (true) or supply your own Map of name to value. */
cookies?: boolean | Map<string, string>;
}

export interface RetryOptions {
attempts?: number;
delay?: number;
factor?: number;
on?: (response: GoResponse) => boolean;
}

export type HttpMethod =
Expand Down Expand Up @@ -32,6 +41,13 @@ export declare class GoClient {
options(path: string): RequestBuilder;

send(req: RequestBuilder): Promise<GoResponse>;

/** POST a GraphQL query (added by graphql.js). */
graphql(
query: string,
variables?: Record<string, unknown>,
options?: { path?: string }
): RequestBuilder;
}

export declare class RequestBuilder implements PromiseLike<GoResponse> {
Expand All @@ -50,6 +66,7 @@ export declare class RequestBuilder implements PromiseLike<GoResponse> {
form(obj: Record<string, string> | URLSearchParams): this;
text(str: string): this;
timeout(ms: number): this;
retry(options?: RetryOptions): this;

// --- queued assertions (chainable) ---
expectStatus(status: number): this;
Expand Down
70 changes: 68 additions & 2 deletions src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@
import { GoResponse } from "./response.js";

export class GoClient {
constructor({ baseURL = "", headers = {}, timeout = 30000 } = {}) {
constructor({ baseURL = "", headers = {}, timeout = 30000, cookies = false } = {}) {
// Strip a single trailing slash from the base URL.
this.baseURL = String(baseURL).replace(/\/+$/, "");
this.timeout = timeout;

// Optional cookie jar: captures Set-Cookie and replays Cookie on later
// requests from this client. Pass cookies: true, or your own Map.
this._jar = cookies instanceof Map ? cookies : cookies ? new Map() : null;

// Lowercase default header keys for consistent merging.
this.headers = {};
for (const [key, value] of Object.entries(headers)) {
Expand All @@ -32,6 +36,11 @@ export class GoClient {
// Merge default headers with per-request headers (all lowercase keys).
const headers = { ...this.headers, ...req._headers };

// Attach stored cookies unless the request already set its own.
if (this._jar && this._jar.size > 0 && headers.cookie === undefined) {
headers.cookie = [...this._jar].map(([k, v]) => `${k}=${v}`).join("; ");
}

const controller = new AbortController();
const timeout = req._timeout ?? this.timeout;
const timer = setTimeout(() => controller.abort(), timeout);
Expand Down Expand Up @@ -62,6 +71,22 @@ export class GoClient {
resHeaders[key.toLowerCase()] = value;
}

// Capture Set-Cookie into the jar (use getSetCookie so multiple cookies
// are not lost to header joining).
if (this._jar) {
const setCookies =
typeof res.headers.getSetCookie === "function"
? res.headers.getSetCookie()
: resHeaders["set-cookie"]
? [resHeaders["set-cookie"]]
: [];
for (const cookie of setCookies) {
const pair = String(cookie).split(";")[0];
const eq = pair.indexOf("=");
if (eq > 0) this._jar.set(pair.slice(0, eq).trim(), pair.slice(eq + 1).trim());
}
}

const text = await res.text();

// Parse JSON when the content type indicates JSON; fall back to text.
Expand Down Expand Up @@ -116,6 +141,39 @@ function ensureLeadingSlash(path) {
return path.startsWith("/") ? path : "/" + path;
}

function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}

// Send a builder with retry/backoff. Retries on a thrown error, or when the
// retry's on(response) predicate returns truthy, until attempts run out.
async function sendWithRetry(builder) {
const r = builder._retry || {};
const attempts = r.attempts != null ? r.attempts : 3;
const factor = r.factor != null ? r.factor : 2;
let delay = r.delay != null ? r.delay : 0;
let lastError;

for (let i = 0; i < attempts; i += 1) {
const isLast = i === attempts - 1;
try {
const res = await builder.client.send(builder);
if (!isLast && typeof r.on === "function" && r.on(res)) {
await sleep(delay);
delay *= factor;
continue;
}
return res;
} catch (err) {
lastError = err;
if (isLast) throw err;
await sleep(delay);
delay *= factor;
}
}
throw lastError;
}

// The set of assertion method names that can be queued on a RequestBuilder.
// Each one maps to a same-named method on GoResponse.
const QUEUEABLE = [
Expand All @@ -139,6 +197,7 @@ export class RequestBuilder {
this._query = {};
this._body = undefined;
this._timeout = undefined;
this._retry = undefined;

// Queued assertions, replayed in order against the GoResponse.
this._assertions = [];
Expand Down Expand Up @@ -193,11 +252,18 @@ export class RequestBuilder {
return this;
}

// Retry the send on a thrown error (network/timeout) or when on(response) is
// truthy. options: { attempts = 3, delay = 0, factor = 2, on }.
retry(options = {}) {
this._retry = options;
return this;
}

// --- run + thenable ---

// Send the request, then replay every queued assertion in order.
async run() {
const response = await this.client.send(this);
const response = this._retry ? await sendWithRetry(this) : await this.client.send(this);
for (const { name, args } of this._assertions) {
response[name](...args);
}
Expand Down
16 changes: 16 additions & 0 deletions src/graphql.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Adds a graphql() helper to GoClient. It POSTs { query, variables } as JSON
// and returns the usual thenable RequestBuilder, so you can chain assertions.
//
// await go(endpoint).graphql("{ user { id } }").expectStatus(200)
// .expectJson("data.user.id", 1);
import { GoClient } from "./client.js";

Object.defineProperty(GoClient.prototype, "graphql", {
value: function graphql(query, variables, options = {}) {
const path = options.path || "";
return this.post(path).json({ query, variables: variables || {} });
},
writable: true,
enumerable: false,
configurable: true,
});
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { chain } from "./utils/chain.js";
// Side-effect import: augments GoResponse.prototype with the extra HTTP
// assertions (expectClientError, expectJsonSchema, expectValue, ...).
import "./http-assertions.js";
// Side-effect import: adds GoClient.prototype.graphql.
import "./graphql.js";

// Differentiating features (API-testing specific, beyond a utility belt).
import { soft, softly } from "./soft.js";
Expand Down
72 changes: 72 additions & 0 deletions test/unit/graphql-retry-cookies.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// Tests for the cookie jar, request retry, and the graphql() helper.
import { test } from "node:test";
import assert from "node:assert/strict";
import http from "node:http";
import { go } from "../../src/index.js";

let server;
let base;
let flakyHits = 0;

test.before(async () => {
server = http.createServer((req, res) => {
if (req.method === "POST" && req.url === "/login") {
res.writeHead(200, { "content-type": "application/json", "set-cookie": "sid=abc123" });
return res.end(JSON.stringify({ ok: true }));
}
if (req.method === "GET" && req.url === "/me") {
res.writeHead(200, { "content-type": "application/json" });
return res.end(JSON.stringify({ cookie: req.headers.cookie || null }));
}
if (req.method === "GET" && req.url === "/flaky") {
flakyHits += 1;
const status = flakyHits < 3 ? 503 : 200;
res.writeHead(status, { "content-type": "application/json" });
return res.end(JSON.stringify({ attempt: flakyHits }));
}
if (req.method === "POST" && req.url === "/graphql") {
let raw = "";
req.on("data", (c) => { raw += c; });
req.on("end", () => {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({ echo: JSON.parse(raw || "{}") }));
});
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not found" }));
});
await new Promise((r) => server.listen(0, r));
base = `http://localhost:${server.address().port}`;
});

test.after(() => server.close());

test("cookie jar replays a Set-Cookie on later requests", async () => {
const api = go({ baseURL: base, cookies: true });
await api.post("/login").expectStatus(200);
await api.get("/me").expectStatus(200).expectJson("cookie", "sid=abc123");
});

test("without the jar, cookies are not carried over", async () => {
const api = go(base); // no cookie jar
await api.post("/login").expectStatus(200);
await api.get("/me").expectJson("cookie", null);
});

test("retry recovers from transient 5xx responses", async () => {
flakyHits = 0;
const res = await go(base)
.get("/flaky")
.retry({ attempts: 3, delay: 1, on: (r) => r.status >= 500 })
.expectStatus(200);
res.expectJson("attempt", 3);
});

test("graphql posts query and variables as JSON", async () => {
const res = await go(base)
.graphql("{ user { id } }", { id: 1 }, { path: "/graphql" })
.expectStatus(200);
res.expectJson("echo.query", "{ user { id } }");
res.expectJson("echo.variables.id", 1);
});
Loading