slicetest 0.1.0

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/dist/http.js ADDED
@@ -0,0 +1,158 @@
1
+ /** Per-scenario state shared by a client and every client derived from it with `with()`. */
2
+ class Session {
3
+ cookies = new Map();
4
+ history = [];
5
+ }
6
+ const HISTORY = 20;
7
+ /** HTTP client bound to the app under test. Keeps cookies for the duration of a scenario. */
8
+ export class HttpClient {
9
+ baseUrl;
10
+ defaults;
11
+ #session;
12
+ constructor(baseUrl, defaults = {}, session = new Session()) {
13
+ this.baseUrl = baseUrl;
14
+ this.defaults = defaults;
15
+ this.#session = session;
16
+ }
17
+ /** A client that adds `opts` (e.g. an auth header) to every request, sharing this one's cookies. */
18
+ with(opts) {
19
+ return new HttpClient(this.baseUrl, merge(this.defaults, opts), this.#session);
20
+ }
21
+ /** Wrap fields to be sent as `application/x-www-form-urlencoded`. */
22
+ form(fields) {
23
+ return new URLSearchParams(Object.entries(fields).map(([k, v]) => [k, String(v)]));
24
+ }
25
+ get(path, opts) {
26
+ return this.request("GET", path, undefined, opts);
27
+ }
28
+ head(path, opts) {
29
+ return this.request("HEAD", path, undefined, opts);
30
+ }
31
+ delete(path, opts) {
32
+ return this.request("DELETE", path, undefined, opts);
33
+ }
34
+ post(path, body, opts) {
35
+ return this.request("POST", path, body, opts);
36
+ }
37
+ put(path, body, opts) {
38
+ return this.request("PUT", path, body, opts);
39
+ }
40
+ patch(path, body, opts) {
41
+ return this.request("PATCH", path, body, opts);
42
+ }
43
+ /** Strings, URLSearchParams, FormData, Blob and byte arrays are sent as-is; anything else is sent as JSON. */
44
+ async request(method, path, body, options = {}) {
45
+ const opts = merge(this.defaults, options);
46
+ const url = new URL(path, this.baseUrl);
47
+ if (url.origin !== new URL(this.baseUrl).origin) {
48
+ throw new Error(`slicetest: http only talks to the app under test; "${path}" resolves to ${url.origin}`);
49
+ }
50
+ for (const [k, v] of Object.entries(opts.query ?? {}))
51
+ if (v !== undefined)
52
+ url.searchParams.set(k, String(v));
53
+ const headers = new Headers(opts.headers);
54
+ if (this.#session.cookies.size > 0 && !headers.has("cookie")) {
55
+ headers.set("cookie", [...this.#session.cookies].map(([k, v]) => `${k}=${v}`).join("; "));
56
+ }
57
+ let payload;
58
+ if (body === undefined ||
59
+ typeof body === "string" ||
60
+ body instanceof URLSearchParams ||
61
+ body instanceof FormData ||
62
+ body instanceof Blob ||
63
+ body instanceof Uint8Array) {
64
+ payload = body;
65
+ }
66
+ else {
67
+ payload = JSON.stringify(body);
68
+ if (!headers.has("content-type"))
69
+ headers.set("content-type", "application/json");
70
+ }
71
+ const started = performance.now();
72
+ let res;
73
+ try {
74
+ res = await fetch(url, { method, headers, body: payload, redirect: opts.follow ? "follow" : "manual" });
75
+ }
76
+ catch (e) {
77
+ const cause = e.cause?.code;
78
+ this.#record({ method, url: url.pathname + url.search, status: 0, headers: new Headers(), text: `${e}${cause ? ` (${cause})` : ""}`, json: undefined, durationMs: Math.round(performance.now() - started) });
79
+ throw e;
80
+ }
81
+ for (const cookie of res.headers.getSetCookie())
82
+ this.#storeCookie(cookie);
83
+ const text = await res.text();
84
+ let json;
85
+ try {
86
+ json = text ? JSON.parse(text) : undefined;
87
+ }
88
+ catch { }
89
+ const out = {
90
+ method,
91
+ url: url.pathname + url.search,
92
+ status: res.status,
93
+ headers: res.headers,
94
+ text,
95
+ json,
96
+ durationMs: Math.round(performance.now() - started),
97
+ };
98
+ this.#record(out);
99
+ return out;
100
+ }
101
+ #record(res) {
102
+ this.#session.history.push(res);
103
+ if (this.#session.history.length > HISTORY)
104
+ this.#session.history.shift();
105
+ }
106
+ /** Cookies the app has set during this scenario. Mutations are sent with later requests. */
107
+ get cookies() {
108
+ return this.#session.cookies;
109
+ }
110
+ clearCookies() {
111
+ this.#session.cookies.clear();
112
+ }
113
+ /** Requests made during the current scenario, oldest first (last 20). */
114
+ get history() {
115
+ return this.#session.history;
116
+ }
117
+ /** Start a new scenario: forget cookies and history. */
118
+ reset() {
119
+ this.#session.cookies.clear();
120
+ this.#session.history = [];
121
+ }
122
+ #storeCookie(header) {
123
+ const [pair, ...attrs] = header.split(";");
124
+ const eq = pair.indexOf("=");
125
+ if (eq <= 0)
126
+ return;
127
+ const name = pair.slice(0, eq).trim();
128
+ const expired = attrs.some((a) => {
129
+ const [k, v = ""] = a.split("=").map((s) => s.trim());
130
+ if (k.toLowerCase() === "max-age")
131
+ return Number(v) <= 0;
132
+ if (k.toLowerCase() === "expires")
133
+ return Date.parse(v) <= Date.now();
134
+ return false;
135
+ });
136
+ if (expired)
137
+ this.#session.cookies.delete(name);
138
+ else
139
+ this.#session.cookies.set(name, pair.slice(eq + 1).trim());
140
+ }
141
+ }
142
+ function merge(a, b) {
143
+ return {
144
+ ...a,
145
+ ...b,
146
+ headers: { ...a.headers, ...b.headers },
147
+ query: { ...a.query, ...b.query },
148
+ };
149
+ }
150
+ /** One line per request, for failure output. */
151
+ export function formatHistory(history) {
152
+ return history
153
+ .map((r) => {
154
+ const snippet = r.text.length > 120 ? `${r.text.slice(0, 120)}…` : r.text;
155
+ return ` ${r.method} ${r.url} → ${r.status || "failed"} (${r.durationMs}ms)${snippet ? ` ${snippet.replace(/\s+/g, " ")}` : ""}`;
156
+ })
157
+ .join("\n");
158
+ }
@@ -0,0 +1,7 @@
1
+ export { scenario } from "./scenario.js";
2
+ export type { ScenarioContext } from "./runtime.js";
3
+ export type { Db, Row, Where, RowsOptions } from "./db.js";
4
+ export type { Stub, RecordedCall, StubResponse, Responder, MatchOptions, RouteBuilder } from "./stub.js";
5
+ export type { HttpClient, HttpResponse, RequestOptions } from "./http.js";
6
+ export type { App } from "./app.js";
7
+ export type { SlicetestOptions } from "./config.js";
package/dist/index.js ADDED
@@ -0,0 +1 @@
1
+ export { scenario } from "./scenario.js";
@@ -0,0 +1,17 @@
1
+ import { type Where } from "./db.js";
2
+ import { type MatchOptions } from "./stub.js";
3
+ interface SlicetestMatchers<R = unknown> {
4
+ /** The stub received at least one call matching `method path` (and `match`, if given). */
5
+ toHaveReceived(method: string, path: string | RegExp, match?: MatchOptions): R;
6
+ /** The stub received exactly `n` calls matching `method path`. */
7
+ toHaveReceivedTimes(n: number, method?: string, path?: string | RegExp, match?: MatchOptions): R;
8
+ /** The response has this status; the failure message shows the response body. */
9
+ toHaveStatus(status: number): R;
10
+ /** Async: the table has at least one row matching `where` (`count` for an exact number). */
11
+ toHaveRow(table: string, where?: Where, count?: number): Promise<void>;
12
+ }
13
+ declare module "vitest" {
14
+ interface Matchers<R extends void | Promise<void> = void | Promise<void>, T = unknown> extends SlicetestMatchers<R> {
15
+ }
16
+ }
17
+ export {};
@@ -0,0 +1,69 @@
1
+ import { expect } from "vitest";
2
+ import { Db } from "./db.js";
3
+ import { Stub } from "./stub.js";
4
+ const MAX_SHOWN = 5;
5
+ function describeCalls(calls) {
6
+ if (calls.length === 0)
7
+ return " (no calls)";
8
+ const shown = calls.slice(-MAX_SHOWN).map((c) => {
9
+ const q = c.query.size ? `?${c.query}` : "";
10
+ const body = c.body ? ` ${c.body.length > 200 ? `${c.body.slice(0, 200)}…` : c.body}` : "";
11
+ return ` ${c.method} ${c.path}${q}${body}`;
12
+ });
13
+ const more = calls.length > MAX_SHOWN ? [` …and ${calls.length - MAX_SHOWN} earlier`] : [];
14
+ return [...more, ...shown].join("\n");
15
+ }
16
+ function assertStub(received) {
17
+ if (!(received instanceof Stub))
18
+ throw new TypeError("slicetest: expected a stub, e.g. expect(stub(\"slack\"))");
19
+ }
20
+ expect.extend({
21
+ toHaveReceived(received, method, path, match) {
22
+ assertStub(received);
23
+ const pass = received.calls(method, path, match).length > 0;
24
+ const cond = match && Object.keys(match).length ? ` with ${this.utils.stringify(match)}` : "";
25
+ return {
26
+ pass,
27
+ message: () => `expected stub "${received.name}" ${pass ? "not " : ""}to have received ${method} ${path}${cond}\n` +
28
+ `Calls received:\n${describeCalls(received.calls())}`,
29
+ };
30
+ },
31
+ toHaveReceivedTimes(received, n, method, path, match) {
32
+ assertStub(received);
33
+ const actual = received.calls(method, path, match).length;
34
+ const what = method ? `${method} ${path ?? "*"}` : "calls";
35
+ return {
36
+ pass: actual === n,
37
+ message: () => `expected stub "${received.name}" to have received ${what} ${this.isNot ? "not " : ""}${n} time(s), got ${actual}\n` +
38
+ `Calls received:\n${describeCalls(received.calls())}`,
39
+ actual,
40
+ expected: n,
41
+ };
42
+ },
43
+ toHaveStatus(received, status) {
44
+ const pass = received?.status === status;
45
+ return {
46
+ pass,
47
+ message: () => {
48
+ const body = received.text.length > 1000 ? `${received.text.slice(0, 1000)}…` : received.text;
49
+ return (`expected ${received.method} ${received.url} ${this.isNot ? "not " : ""}to respond ${status}, got ${received.status}\n` +
50
+ `Response body:\n ${body || "(empty)"}`);
51
+ },
52
+ actual: received?.status,
53
+ expected: status,
54
+ };
55
+ },
56
+ async toHaveRow(received, table, where = {}, count) {
57
+ if (!(received instanceof Db))
58
+ throw new TypeError("slicetest: expected the db handle, e.g. expect(db)");
59
+ const n = await received.count(table, where);
60
+ const pass = count === undefined ? n > 0 : n === count;
61
+ const sample = pass ? [] : await received.rows(table, {}, { limit: MAX_SHOWN });
62
+ const want = count === undefined ? "a row" : `${count} row(s)`;
63
+ return {
64
+ pass,
65
+ message: () => `expected ${table} ${this.isNot ? "not " : ""}to have ${want} matching ${this.utils.stringify(where)}, found ${n}\n` +
66
+ (sample.length ? `First rows in ${table}:\n${sample.map((r) => ` ${JSON.stringify(r)}`).join("\n")}` : `${table} is empty`),
67
+ };
68
+ },
69
+ });
@@ -0,0 +1,11 @@
1
+ import type { ResolvedOptions } from "./config.js";
2
+ declare module "vitest" {
3
+ interface ProvidedContext {
4
+ slicetestOptions: ResolvedOptions;
5
+ slicetestDb: {
6
+ adminUrl: string;
7
+ template: string;
8
+ prefix: string;
9
+ };
10
+ }
11
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,34 @@
1
+ import { App } from "./app.js";
2
+ import type { ResolvedOptions } from "./config.js";
3
+ import { Db } from "./db.js";
4
+ import { HttpClient } from "./http.js";
5
+ import { Stub } from "./stub.js";
6
+ export interface ScenarioContext {
7
+ http: HttpClient;
8
+ db: Db;
9
+ stub: (name: string) => Stub;
10
+ app: App;
11
+ }
12
+ /** Everything one test file needs: its own database, stub servers and app process. */
13
+ export declare class Runtime {
14
+ #private;
15
+ app: App;
16
+ readonly db: Db;
17
+ readonly stubs: Map<string, Stub>;
18
+ private readonly opts;
19
+ private readonly vars;
20
+ private constructor();
21
+ get http(): HttpClient;
22
+ static start(opts: ResolvedOptions, shared: {
23
+ adminUrl: string;
24
+ template: string;
25
+ prefix: string;
26
+ }): Promise<Runtime>;
27
+ context(): ScenarioContext;
28
+ beforeScenario(): Promise<void>;
29
+ /** Failures that the scenario body can't see on its own. */
30
+ afterScenario(): Promise<void>;
31
+ /** What happened during the current scenario, printed when it fails. */
32
+ diagnostics(): string;
33
+ stop(): Promise<void>;
34
+ }
@@ -0,0 +1,145 @@
1
+ import path from "node:path";
2
+ import pg from "pg";
3
+ import { App } from "./app.js";
4
+ import { Db, withDatabase } from "./db.js";
5
+ import { formatHistory, HttpClient } from "./http.js";
6
+ import { Stub } from "./stub.js";
7
+ /** Everything one test file needs: its own database, stub servers and app process. */
8
+ export class Runtime {
9
+ app;
10
+ db;
11
+ stubs;
12
+ opts;
13
+ vars;
14
+ #logMark = 0;
15
+ #http;
16
+ constructor(app, db, stubs, opts, vars) {
17
+ this.app = app;
18
+ this.db = db;
19
+ this.stubs = stubs;
20
+ this.opts = opts;
21
+ this.vars = vars;
22
+ this.#http = new HttpClient(app.url, opts.http);
23
+ }
24
+ get http() {
25
+ return this.#http;
26
+ }
27
+ static async start(opts, shared) {
28
+ const url = await ensureWorkerDatabase(shared.adminUrl, shared.template, shared.prefix);
29
+ const stubs = new Map();
30
+ let db;
31
+ try {
32
+ for (const name of opts.stubs)
33
+ stubs.set(name, await Stub.start(name));
34
+ db = await Db.connect(url, {
35
+ schemas: opts.db.schemas,
36
+ keep: opts.db.keep,
37
+ seedFile: opts.db.seed && path.resolve(opts.root, opts.db.seed),
38
+ });
39
+ const vars = { "db.url": url };
40
+ for (const [name, stub] of stubs)
41
+ vars[`stub.${name}`] = stub.url;
42
+ const app = await App.start(opts.app, opts.root, vars);
43
+ return new Runtime(app, db, stubs, opts, vars);
44
+ }
45
+ catch (e) {
46
+ await db?.close();
47
+ await Promise.all([...stubs.values()].map((s) => s.close()));
48
+ throw e;
49
+ }
50
+ }
51
+ context() {
52
+ return {
53
+ http: this.http,
54
+ db: this.db,
55
+ app: this.app,
56
+ stub: (name) => {
57
+ const stub = this.stubs.get(name);
58
+ if (!stub) {
59
+ throw new Error(`slicetest: unknown stub "${name}". Declared stubs: ${[...this.stubs.keys()].join(", ") || "(none)"}`);
60
+ }
61
+ return stub;
62
+ },
63
+ };
64
+ }
65
+ async beforeScenario() {
66
+ // A crash already failed the scenario that caused it; give the next one a fresh app.
67
+ if (this.app.exited) {
68
+ await this.app.stop();
69
+ this.app = await App.start(this.opts.app, this.opts.root, this.vars);
70
+ this.#http = new HttpClient(this.app.url, this.opts.http);
71
+ }
72
+ await this.db.reset();
73
+ for (const stub of this.stubs.values())
74
+ stub.reset();
75
+ this.http.reset();
76
+ this.#logMark = this.app.mark();
77
+ }
78
+ /** Failures that the scenario body can't see on its own. */
79
+ async afterScenario() {
80
+ await this.app.settle();
81
+ this.#assertAlive();
82
+ const unmatched = this.#unmatched();
83
+ if (unmatched.length > 0) {
84
+ throw new Error(`slicetest: the app called stubbed services with no matching route:\n${unmatched.join("\n")}`);
85
+ }
86
+ }
87
+ #unmatched() {
88
+ return [...this.stubs.values()].flatMap((s) => {
89
+ const calls = s.unmatched();
90
+ if (calls.length === 0)
91
+ return [];
92
+ const routes = s.describeRoutes();
93
+ return [
94
+ ...calls.map((c) => ` ${s.name}: ${c.method} ${c.path}${c.query.size ? `?${c.query}` : ""}`),
95
+ ` registered on ${s.name}: ${routes.length ? routes.join(", ") : "(none)"}`,
96
+ ];
97
+ });
98
+ }
99
+ /** What happened during the current scenario, printed when it fails. */
100
+ diagnostics() {
101
+ const sections = [];
102
+ const exit = this.app.exited;
103
+ if (exit)
104
+ sections.push(`the app exited (code ${exit.code}, signal ${exit.signal}); it will be restarted for the next scenario`);
105
+ const unmatched = this.#unmatched();
106
+ if (unmatched.length > 0)
107
+ sections.push(`stub calls with no matching route:\n${unmatched.join("\n")}`);
108
+ if (this.http.history.length > 0)
109
+ sections.push(`requests to the app:\n${formatHistory(this.http.history)}`);
110
+ const logs = this.app.logs(this.#logMark);
111
+ sections.push(logs ? `app output during this scenario:\n${logs}` : "app output during this scenario: (none)");
112
+ return sections.join("\n\n");
113
+ }
114
+ async stop() {
115
+ const results = await Promise.allSettled([
116
+ this.app.stop(),
117
+ this.db.close(),
118
+ ...[...this.stubs.values()].map((s) => s.close()),
119
+ ]);
120
+ const failed = results.find((r) => r.status === "rejected");
121
+ if (failed)
122
+ throw failed.reason;
123
+ }
124
+ #assertAlive() {
125
+ const exit = this.app.exited;
126
+ if (exit) {
127
+ throw new Error(`slicetest: app process exited (code ${exit.code}, signal ${exit.signal})\n${this.app.logs()}`);
128
+ }
129
+ }
130
+ }
131
+ /** One database per vitest worker, cloned from the migrated template and reused across its test files. */
132
+ async function ensureWorkerDatabase(adminUrl, template, prefix) {
133
+ const name = `${prefix}_w${process.env.VITEST_POOL_ID ?? process.pid}`;
134
+ const admin = new pg.Client({ connectionString: adminUrl });
135
+ await admin.connect();
136
+ try {
137
+ const { rowCount } = await admin.query("SELECT 1 FROM pg_database WHERE datname = $1", [name]);
138
+ if (!rowCount)
139
+ await admin.query(`CREATE DATABASE "${name}" TEMPLATE "${template}"`);
140
+ }
141
+ finally {
142
+ await admin.end();
143
+ }
144
+ return withDatabase(adminUrl, name);
145
+ }
@@ -0,0 +1,15 @@
1
+ import type { Runtime, ScenarioContext } from "./runtime.js";
2
+ export declare function setRuntime(runtime: Runtime | undefined): void;
3
+ type Body = (ctx: ScenarioContext) => Promise<void> | void;
4
+ /**
5
+ * A test that runs against the real app. The database is reset to the
6
+ * migrated schema (plus seed) and stubs are cleared before each scenario.
7
+ */
8
+ export declare const scenario: ((name: string, body: Body, timeout?: number) => void) & {
9
+ only: (name: string, body: Body, timeout?: number) => void;
10
+ skip: (name: string, body: Body, timeout?: number) => void;
11
+ todo: (name: string) => void;
12
+ /** Same scenario for each row: `scenario.each(rows)("name %s", async (row, ctx) => ...)`. */
13
+ each<T>(rows: readonly T[]): (name: string, body: (row: T, ctx: ScenarioContext) => Promise<void> | void, timeout?: number) => void;
14
+ };
15
+ export {};
@@ -0,0 +1,59 @@
1
+ import { test } from "vitest";
2
+ // Kept on globalThis so it survives the setup file and the test file loading
3
+ // separate copies of this module (e.g. one from dist, one through an alias).
4
+ const KEY = Symbol.for("slicetest.runtime");
5
+ const slot = globalThis;
6
+ export function setRuntime(runtime) {
7
+ slot[KEY] = runtime;
8
+ }
9
+ function define(register) {
10
+ return (name, body, timeout) => register(name, async ({ onTestFailed, task }) => {
11
+ const runtime = slot[KEY];
12
+ if (!runtime) {
13
+ throw new Error("slicetest: runtime not started. Add the slicetest() plugin to your vitest config.");
14
+ }
15
+ if (task.concurrent) {
16
+ throw new Error("slicetest: scenarios share one app and database per file, so they can't run concurrently. Remove .concurrent / sequence.concurrent.");
17
+ }
18
+ onTestFailed(() => {
19
+ console.error(`--- slicetest ---\n${runtime.diagnostics()}\n-----------------`);
20
+ });
21
+ await runtime.beforeScenario();
22
+ await body(runtime.context());
23
+ await runtime.afterScenario();
24
+ }, timeout);
25
+ }
26
+ /**
27
+ * A test that runs against the real app. The database is reset to the
28
+ * migrated schema (plus seed) and stubs are cleared before each scenario.
29
+ */
30
+ export const scenario = Object.assign(define(test), {
31
+ only: define(test.only),
32
+ skip: define(test.skip),
33
+ todo: (name) => test.todo(name),
34
+ /** Same scenario for each row: `scenario.each(rows)("name %s", async (row, ctx) => ...)`. */
35
+ each(rows) {
36
+ return (name, body, timeout) => {
37
+ rows.forEach((row, i) => {
38
+ const title = format(name, row, i);
39
+ define(test)(title, (ctx) => body(row, ctx), timeout);
40
+ });
41
+ };
42
+ },
43
+ });
44
+ /** printf-style `%s`/`%i`/`%j`/`%#`, plus `$field` for object rows. */
45
+ function format(name, row, index) {
46
+ const args = Array.isArray(row) ? [...row] : [row];
47
+ let out = name.replace(/%[sdij#%]/g, (tok) => {
48
+ if (tok === "%%")
49
+ return "%";
50
+ if (tok === "%#")
51
+ return String(index);
52
+ const v = args.shift();
53
+ return tok === "%j" ? JSON.stringify(v) : typeof v === "object" ? JSON.stringify(v) : String(v);
54
+ });
55
+ if (row && typeof row === "object" && !Array.isArray(row)) {
56
+ out = out.replace(/\$(\w+)/g, (m, k) => (k in row ? String(row[k]) : m));
57
+ }
58
+ return out;
59
+ }
@@ -0,0 +1,2 @@
1
+ import "./provided.js";
2
+ import "./matchers.js";
@@ -0,0 +1,14 @@
1
+ import { afterAll, beforeAll, inject } from "vitest";
2
+ import { setRuntime } from "./scenario.js";
3
+ import { Runtime } from "./runtime.js";
4
+ import "./provided.js";
5
+ import "./matchers.js";
6
+ let runtime;
7
+ beforeAll(async () => {
8
+ runtime = await Runtime.start(inject("slicetestOptions"), inject("slicetestDb"));
9
+ setRuntime(runtime);
10
+ });
11
+ afterAll(async () => {
12
+ setRuntime(undefined);
13
+ await runtime?.stop();
14
+ });
package/dist/stub.d.ts ADDED
@@ -0,0 +1,75 @@
1
+ import http from "node:http";
2
+ export interface RecordedCall {
3
+ method: string;
4
+ path: string;
5
+ query: URLSearchParams;
6
+ headers: http.IncomingHttpHeaders;
7
+ body: string;
8
+ /** Parsed JSON body, or undefined when the body isn't JSON. */
9
+ json: any;
10
+ /** Values captured by `:name` segments of the matching route's path. */
11
+ params: Record<string, string>;
12
+ /** Whether a registered route answered this call. */
13
+ matched: boolean;
14
+ }
15
+ export interface StubResponse {
16
+ status?: number;
17
+ headers?: Record<string, string>;
18
+ /** Objects are sent as JSON. */
19
+ body?: unknown;
20
+ }
21
+ export type Responder = StubResponse | ((call: RecordedCall) => StubResponse | Promise<StubResponse>);
22
+ /**
23
+ * Extra conditions a call must meet for a route to answer it. Plain values are
24
+ * compared exactly (`json` as a subset); RegExps test strings; functions and
25
+ * `expect.*` asymmetric matchers receive the actual value.
26
+ */
27
+ export interface MatchOptions {
28
+ query?: Record<string, Matcher>;
29
+ headers?: Record<string, Matcher>;
30
+ json?: unknown;
31
+ body?: Matcher;
32
+ }
33
+ type Matcher = string | number | boolean | RegExp | ((value: any) => boolean) | {
34
+ asymmetricMatch(value: unknown): boolean;
35
+ };
36
+ /** Builder returned by `stub.on()`. Finish it with `reply()` or `networkError()`. */
37
+ export interface RouteBuilder {
38
+ /** Answer only the next `n` matching calls; later calls fall through to other routes. */
39
+ times(n: number): RouteBuilder;
40
+ once(): RouteBuilder;
41
+ /** Wait before answering, e.g. to exercise the app's timeouts. */
42
+ delay(ms: number): RouteBuilder;
43
+ reply(status: number, body?: unknown, headers?: Record<string, string>): Stub;
44
+ reply(response: Responder): Stub;
45
+ /** Answer each matching call with the next response in the list; the last one repeats. */
46
+ replySequence(responses: StubResponse[]): Stub;
47
+ /** Drop the connection without answering. */
48
+ networkError(): Stub;
49
+ }
50
+ /**
51
+ * A fake outbound service. The app is pointed at `url`; tests register routes
52
+ * with `on()` and inspect what the app sent with `calls()`.
53
+ */
54
+ export declare class Stub {
55
+ #private;
56
+ readonly name: string;
57
+ url: string;
58
+ private constructor();
59
+ static start(name: string): Promise<Stub>;
60
+ /**
61
+ * Answer `method path`. `path` may contain `:name` segments (captured into
62
+ * `call.params`) or be a RegExp; `method` may be `*`. Later routes win.
63
+ */
64
+ on(method: string, path: string | RegExp, match?: MatchOptions): RouteBuilder;
65
+ /** Calls received so far, optionally filtered by method, path and conditions (same syntax as `on()`). */
66
+ calls(method?: string, path?: string | RegExp, match?: MatchOptions): RecordedCall[];
67
+ unmatched(): RecordedCall[];
68
+ /** Human-readable list of registered routes, for diagnostics. */
69
+ describeRoutes(): string[];
70
+ reset(): void;
71
+ close(): Promise<void>;
72
+ }
73
+ /** `expected` is contained in `actual`: objects compare key by key, arrays element-wise. */
74
+ export declare function subset(expected: unknown, actual: unknown): boolean;
75
+ export {};