@ultimat3/testing 1.0.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/src/harness.ts ADDED
@@ -0,0 +1,114 @@
1
+ // Boot an app in-process for a test: its own cloned database, a frozen clock, a seeded RNG and a
2
+ // sealed network, torn down afterwards. In-process because a spawned server turns every assertion
3
+ // into a race and every failure into a log-scraping exercise.
4
+
5
+ import { afterAll, beforeAll, describe, test } from 'bun:test';
6
+ import { installDeterminism, restoreDeterminism } from './determinism';
7
+ import { allowHost, resetNetwork, sealNetwork, unsealNetwork } from './sealed-network';
8
+ import type { TemplateDbConfig, WorkerDatabase } from './template-db';
9
+ import { acquireWorkerDatabase } from './template-db';
10
+ import type { TestType } from './test-types';
11
+ import { testName } from './test-types';
12
+
13
+ export interface BootedApp {
14
+ /** The app's request handler — the same one `ROLE=web` serves in production. */
15
+ fetch(request: Request): Response | Promise<Response>;
16
+ close?(): Promise<void>;
17
+ }
18
+
19
+ export interface AppOptions {
20
+ /** Boot the app against the database URL the harness just cloned. */
21
+ boot(context: { readonly databaseUrl: string }): Promise<BootedApp>;
22
+ readonly db?: TemplateDbConfig;
23
+ readonly seed?: (databaseUrl: string) => Promise<void>;
24
+ /** Hosts the test may reach for real. Everything else fails with X_TEST_NETWORK_SEALED. */
25
+ readonly allowHosts?: readonly string[];
26
+ readonly seedValue?: number;
27
+ readonly now?: string;
28
+ }
29
+
30
+ export interface AppHandle {
31
+ readonly db: WorkerDatabase;
32
+ /** Call the app by path: `app.request('/api/health')`. */
33
+ request(path: string, init?: RequestInit): Promise<Response>;
34
+ json<T>(path: string, init?: RequestInit): Promise<T>;
35
+ }
36
+
37
+ const BASE = 'http://app.test';
38
+
39
+ async function boot(
40
+ options: AppOptions,
41
+ ): Promise<{ handle: AppHandle; close: () => Promise<void> }> {
42
+ installDeterminism({
43
+ ...(options.seedValue === undefined ? {} : { seed: options.seedValue }),
44
+ ...(options.now === undefined ? {} : { now: options.now }),
45
+ });
46
+ sealNetwork();
47
+ for (const host of options.allowHosts ?? []) allowHost(host);
48
+
49
+ const db = await acquireWorkerDatabase(options.db ?? {});
50
+ if (options.seed !== undefined) await options.seed(db.url);
51
+ const app = await options.boot({ databaseUrl: db.url });
52
+
53
+ const handle: AppHandle = {
54
+ db,
55
+ request: async (path, init) => app.fetch(new Request(new URL(path, BASE), init)),
56
+ json: async <T>(path: string, init?: RequestInit): Promise<T> => {
57
+ const response = await app.fetch(new Request(new URL(path, BASE), init));
58
+ return (await response.json()) as T;
59
+ },
60
+ };
61
+
62
+ return {
63
+ handle,
64
+ close: async () => {
65
+ await app.close?.();
66
+ await db.drop();
67
+ resetNetwork();
68
+ unsealNetwork();
69
+ restoreDeterminism();
70
+ },
71
+ };
72
+ }
73
+
74
+ /**
75
+ * A describe block with the app booted once for every test inside it. The accessor is a function
76
+ * because the app does not exist until beforeAll has run.
77
+ */
78
+ export function describeApp(
79
+ name: string,
80
+ options: AppOptions,
81
+ body: (app: () => AppHandle) => void,
82
+ ): void {
83
+ describe(name, () => {
84
+ let booted: { handle: AppHandle; close: () => Promise<void> } | undefined;
85
+ beforeAll(async () => {
86
+ booted = await boot(options);
87
+ });
88
+ afterAll(async () => {
89
+ await booted?.close();
90
+ booted = undefined;
91
+ });
92
+ body(() => {
93
+ if (booted === undefined) throw new ReferenceError('app is not booted yet');
94
+ return booted.handle;
95
+ });
96
+ });
97
+ }
98
+
99
+ /** A single test with its own app, for the cases that need an isolated boot. */
100
+ export function testApp(
101
+ name: string,
102
+ options: AppOptions,
103
+ body: (app: AppHandle) => Promise<void>,
104
+ type: TestType = 'unit',
105
+ ): void {
106
+ test(testName(type, name), async () => {
107
+ const booted = await boot(options);
108
+ try {
109
+ await body(booted.handle);
110
+ } finally {
111
+ await booted.close();
112
+ }
113
+ });
114
+ }
package/src/index.ts ADDED
@@ -0,0 +1,133 @@
1
+ export type {
2
+ FixtureBody,
3
+ FixtureFactory,
4
+ FixtureMap,
5
+ FixtureRegistration,
6
+ Fixtures,
7
+ } from './fixtures';
8
+ export {
9
+ clearFixtures,
10
+ defineFixtures,
11
+ fixtureSnapshot,
12
+ fixtureTest,
13
+ registeredFixtures,
14
+ requestedFixtures,
15
+ } from './fixtures';
16
+ // Public API of @ultimat3/testing. Explicit re-exports: the preload has its own entry point so a
17
+ // bunfig can load side effects without importing the whole harness.
18
+
19
+ // Re-exported so an app test has one import line instead of two, and so `expect` carries
20
+ // this package's custom matchers (`toBeUltimateError`, `toEmitSteps`, …) already installed.
21
+ // Importing `expect` straight from `bun:test` in an app test gets the matchers only if some
22
+ // other module happened to load first — a load-order dependency, which is a flake.
23
+ // `test` is OURS (fixture-injecting); everything else passes through. Re-exported so an app
24
+ // test has one import line, and so `expect` carries this package's matchers already installed.
25
+ export { afterAll, afterEach, beforeAll, beforeEach, describe, expect } from 'bun:test';
26
+ export type { DeterminismOptions } from './determinism';
27
+ export {
28
+ advanceClock,
29
+ assertDeterministic,
30
+ DEFAULT_NOW,
31
+ DEFAULT_SEED,
32
+ frozenClock,
33
+ frozenNow,
34
+ installDeterminism,
35
+ isDeterminismInstalled,
36
+ restoreDeterminism,
37
+ seededRandom,
38
+ seededUuid,
39
+ setFrozenClock,
40
+ } from './determinism';
41
+ export type { TestingErrorCode } from './errors';
42
+ export {
43
+ FixtureUnavailableError,
44
+ NetworkOfflineError,
45
+ NetworkSealedError,
46
+ NondeterministicError,
47
+ TESTING_ERROR_CODES,
48
+ TESTING_ERROR_TITLES,
49
+ TestDatabaseUnavailableError,
50
+ } from './errors';
51
+ export type { EntityLike, EntityRegistry, Factory, FactoryOptions } from './factories';
52
+ export { defineFactory, factoriesFor } from './factories';
53
+ export type { TestClock, TestDuration } from './fixture-clock';
54
+ export { createTestClock } from './fixture-clock';
55
+ export type {
56
+ DriverFixtureName,
57
+ DriverFixtures,
58
+ LiveFeed,
59
+ LiveFeedPatch,
60
+ LiveTarget,
61
+ SignIn,
62
+ Subscribe,
63
+ TestBudget,
64
+ TestDeploy,
65
+ } from './fixture-drivers';
66
+ export { DRIVER_FIXTURE_NEEDS, driverFixtures, unavailableFixture } from './fixture-drivers';
67
+ export type { JobRunTrace, RunJobs, StepTally } from './fixture-jobs';
68
+ export { createRunJobs } from './fixture-jobs';
69
+ export type { MailRef, TestMail } from './fixture-mail';
70
+ export { createTestMail } from './fixture-mail';
71
+ export type { TestNetwork } from './fixture-network';
72
+ export { createTestNetwork } from './fixture-network';
73
+ export { fixtureTest as test } from './fixtures';
74
+ export {
75
+ ALL_FIXTURE_NAMES,
76
+ DRIVER_FIXTURE_NAMES,
77
+ FRAMEWORK_FIXTURE_NAMES,
78
+ registerFrameworkFixtures,
79
+ } from './framework-fixtures';
80
+ export type { AppHandle, AppOptions, BootedApp } from './harness';
81
+ export { describeApp, testApp } from './harness';
82
+ export type { MatcherResult } from './matchers';
83
+ export { matchersInstalled, recordSteps } from './matchers';
84
+ export type { MockRoute, NetworkState } from './sealed-network';
85
+ // `setNetworkState` is deliberately not here: it is the offline gate's one writer, and a test that
86
+ // called it directly would bypass the `network` fixture's disposal and leave the whole process
87
+ // offline for every file after it. The fixture is the way to go offline — there is no second one.
88
+ export {
89
+ allowHost,
90
+ isNetworkSealed,
91
+ mockFetch,
92
+ mockJson,
93
+ networkState,
94
+ requestedUrls,
95
+ resetNetwork,
96
+ sealNetwork,
97
+ unsealNetwork,
98
+ } from './sealed-network';
99
+ export type { SqlRunner, TemplateDbConfig, WorkerDatabase } from './template-db';
100
+ export {
101
+ acquireWorkerDatabase,
102
+ cloneSql,
103
+ createTemplateSql,
104
+ DEFAULT_TEMPLATE,
105
+ databaseNameFor,
106
+ dropSql,
107
+ lockSql,
108
+ unlockSql,
109
+ urlFor,
110
+ workerId,
111
+ } from './template-db';
112
+ export type {
113
+ E2eBody,
114
+ E2eFixtures,
115
+ EvalCase,
116
+ EvalOptions,
117
+ LocatorLike,
118
+ OpenApiLike,
119
+ PageLike,
120
+ TestType,
121
+ } from './test-types';
122
+ export {
123
+ contractTest,
124
+ e2eTest,
125
+ evalTest,
126
+ jobTest,
127
+ liveTest,
128
+ SEPARATOR,
129
+ TEST_TYPES,
130
+ testName,
131
+ unitTest,
132
+ useE2eDriver,
133
+ } from './test-types';
@@ -0,0 +1,182 @@
1
+ // Custom expectations. Each one exists because the assertion it replaces was being written by
2
+ // hand, differently, in every test — and a hand-written version of "did this policy deny?" is how
3
+ // a test ends up asserting on the wrong branch.
4
+
5
+ import { expect } from 'bun:test';
6
+ import { TestJobExpectedError, TestSchemaExpectedError } from './errors';
7
+ import type { OpenApiLike } from './test-types';
8
+
9
+ export interface MatcherResult {
10
+ readonly pass: boolean;
11
+ message(): string;
12
+ }
13
+
14
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
15
+ typeof value === 'object' && value !== null;
16
+
17
+ const codeOf = (value: unknown): string | undefined => {
18
+ if (!isRecord(value)) return undefined;
19
+ const code = value['code'];
20
+ return typeof code === 'string' ? code : undefined;
21
+ };
22
+
23
+ /** Standard Schema is the validation contract, so every blessed schema exposes `~standard`. */
24
+ interface StandardSchema {
25
+ readonly '~standard': {
26
+ validate(value: unknown): { issues?: unknown } | Promise<{ issues?: unknown }>;
27
+ };
28
+ }
29
+
30
+ const isStandardSchema = (value: unknown): value is StandardSchema =>
31
+ isRecord(value) && isRecord(value['~standard']);
32
+
33
+ async function hasIssues(schema: unknown, input: unknown): Promise<boolean> {
34
+ if (!isStandardSchema(schema)) {
35
+ throw new TestSchemaExpectedError();
36
+ }
37
+ const result = await schema['~standard'].validate(input);
38
+ const issues = result.issues;
39
+ return Array.isArray(issues) ? issues.length > 0 : issues !== undefined;
40
+ }
41
+
42
+ interface PolicyLike {
43
+ evaluate(context: Readonly<Record<string, unknown>>): boolean | Promise<boolean>;
44
+ }
45
+
46
+ /** A `Policy` from @ultimat3/policy. It decides through `run()`; there is no `evaluate()` on it. */
47
+ interface RunnablePolicy {
48
+ run(args: Readonly<Record<string, unknown>>): { readonly allowed: boolean };
49
+ }
50
+
51
+ const isPolicy = (value: unknown): value is PolicyLike =>
52
+ isRecord(value) && typeof value['evaluate'] === 'function';
53
+
54
+ const isRunnablePolicy = (value: unknown): value is RunnablePolicy =>
55
+ isRecord(value) && typeof value['run'] === 'function';
56
+
57
+ /**
58
+ * `undefined` means "not a policy at all", which is a different failure from a denial.
59
+ * `row` is defaulted the way `evaluate()` in @ultimat3/policy defaults it, so a test about a
60
+ * rule that decides on input alone does not have to write `row: null` for the predicate to see
61
+ * the field — and a row-level test still passes its own `row` and wins, because it comes second.
62
+ */
63
+ async function decide(
64
+ policy: unknown,
65
+ context: Readonly<Record<string, unknown>>,
66
+ ): Promise<boolean | undefined> {
67
+ if (isRunnablePolicy(policy)) return policy.run({ row: null, ...context }).allowed;
68
+ if (isPolicy(policy)) return policy.evaluate(context);
69
+ return undefined;
70
+ }
71
+
72
+ interface JobLike {
73
+ run(context: {
74
+ input: unknown;
75
+ step: StepRecorder;
76
+ ctx: Record<string, unknown>;
77
+ }): Promise<unknown>;
78
+ }
79
+
80
+ interface StepRecorder {
81
+ run<T>(name: string, body: () => T | Promise<T>): Promise<T>;
82
+ sleep(duration: string): Promise<void>;
83
+ }
84
+
85
+ const isJob = (value: unknown): value is JobLike =>
86
+ isRecord(value) && typeof value['run'] === 'function';
87
+
88
+ /** Runs the job with a recording step API, so the assertion is on the sequence, not the effects. */
89
+ export async function recordSteps(job: unknown, input: unknown = {}): Promise<readonly string[]> {
90
+ if (!isJob(job)) throw new TestJobExpectedError();
91
+ const names: string[] = [];
92
+ const step: StepRecorder = {
93
+ run: async (name, body) => {
94
+ names.push(name);
95
+ return body();
96
+ },
97
+ sleep: async () => undefined,
98
+ };
99
+ await job.run({ input, step, ctx: {} });
100
+ return names;
101
+ }
102
+
103
+ const result = (pass: boolean, message: string): MatcherResult => ({
104
+ pass,
105
+ message: () => message,
106
+ });
107
+
108
+ expect.extend({
109
+ toBeUltimateError(received: unknown, code?: string) {
110
+ const actual = codeOf(received);
111
+ if (actual === undefined) {
112
+ return result(false, `expected an UltimateError, received ${typeof received}`);
113
+ }
114
+ if (code === undefined) return result(true, `expected not to be an UltimateError`);
115
+ return result(actual === code, `expected error code ${code}, received ${actual}`);
116
+ },
117
+
118
+ async toDenyPolicy(received: unknown, context: Readonly<Record<string, unknown>>) {
119
+ const allowed = await decide(received, context);
120
+ if (allowed === undefined) {
121
+ return result(
122
+ false,
123
+ 'expected a policy — an object with run() (@ultimat3/policy) or evaluate()',
124
+ );
125
+ }
126
+ return result(!allowed, `expected the policy to deny ${JSON.stringify(context)}`);
127
+ },
128
+
129
+ async toEmitSteps(received: unknown, expected: readonly string[]) {
130
+ const names = await recordSteps(received);
131
+ return result(
132
+ JSON.stringify(names) === JSON.stringify(expected),
133
+ `expected steps ${expected.join(' -> ')}, ran ${names.join(' -> ')}`,
134
+ );
135
+ },
136
+
137
+ toMatchOpenApi(received: unknown, committed: OpenApiLike) {
138
+ const current = received as OpenApiLike;
139
+ const before = committed.operations.map((operation) => operation.operationId).sort();
140
+ const after = current.operations.map((operation) => operation.operationId).sort();
141
+ const removed = before.filter((id) => !after.includes(id));
142
+ return result(
143
+ removed.length === 0,
144
+ `contract removed operation(s): ${removed.join(', ')} — bump the package version or restore them`,
145
+ );
146
+ },
147
+
148
+ toBeWithinBudget(received: unknown, limit: number) {
149
+ if (typeof received !== 'number') {
150
+ return result(
151
+ false,
152
+ `expected a number to compare against the budget, got ${typeof received}`,
153
+ );
154
+ }
155
+ return result(received <= limit, `expected ${received} to be within the budget of ${limit}`);
156
+ },
157
+
158
+ async toRejectInput(received: unknown, input: unknown) {
159
+ const rejected = await hasIssues(received, input);
160
+ return result(rejected, `expected the schema to reject ${JSON.stringify(input)}`);
161
+ },
162
+
163
+ async toAcceptInput(received: unknown, input: unknown) {
164
+ const rejected = await hasIssues(received, input);
165
+ return result(!rejected, `expected the schema to accept ${JSON.stringify(input)}`);
166
+ },
167
+ });
168
+
169
+ declare module 'bun:test' {
170
+ interface Matchers<T> {
171
+ toBeUltimateError(code?: string): T;
172
+ toDenyPolicy(context: Readonly<Record<string, unknown>>): Promise<T>;
173
+ toEmitSteps(steps: readonly string[]): Promise<T>;
174
+ toMatchOpenApi(committed: OpenApiLike): T;
175
+ toBeWithinBudget(limit: number): T;
176
+ toRejectInput(input: unknown): Promise<T>;
177
+ toAcceptInput(input: unknown): Promise<T>;
178
+ }
179
+ }
180
+
181
+ /** Imported for its side effect by the preload; exported so a test can be explicit about it. */
182
+ export const matchersInstalled = true;
package/src/preload.ts ADDED
@@ -0,0 +1,25 @@
1
+ // The bunfig preload for apps: frozen clock, seeded RNG, sealed network, custom matchers, and
2
+ // the framework's fixture bag. Loaded once per test process, before any test file — an app never
3
+ // has to remember to call it.
4
+ //
5
+ // [test]
6
+ // preload = ["@ultimat3/testing/preload"]
7
+
8
+ import { installDeterminism } from './determinism';
9
+ import { registerFrameworkFixtures } from './framework-fixtures';
10
+ import './matchers';
11
+ import { sealNetwork } from './sealed-network';
12
+
13
+ const seed = Number.parseInt(Bun.env['ULTIMATE_TEST_SEED'] ?? '', 10);
14
+ const now = Bun.env['ULTIMATE_TEST_NOW'];
15
+
16
+ installDeterminism({
17
+ ...(Number.isFinite(seed) ? { seed } : {}),
18
+ ...(now === undefined ? {} : { now }),
19
+ });
20
+
21
+ registerFrameworkFixtures();
22
+
23
+ // Opt-out exists for one case: a test that deliberately exercises a real integration in a job the
24
+ // team runs on purpose. It is an env var, not an API, so it cannot be set from inside a test file.
25
+ if (Bun.env['ULTIMATE_TEST_ALLOW_NET'] !== '1') sealNetwork();
@@ -0,0 +1,150 @@
1
+ // Sealed network. Any egress a test did not explicitly mock or allow fails the test with the URL
2
+ // and the line that fixes it. A test that quietly reaches the internet is a test that fails in CI
3
+ // for reasons nobody can reproduce — so the default is "nothing gets out".
4
+
5
+ import { isSelfOrigin } from '@ultimat3/core';
6
+ import { NetworkOfflineError, NetworkRaceError, NetworkSealedError } from './errors';
7
+
8
+ export type FetchLike = typeof globalThis.fetch;
9
+
10
+ /**
11
+ * `offline` is the cable pulled; `dropped` is the same for a request, but tells a transport its
12
+ * connection was cut rather than closed — so it reconnects and resumes instead of resubscribing.
13
+ */
14
+ export type NetworkState = 'online' | 'offline' | 'dropped';
15
+
16
+ export interface MockRoute {
17
+ /** Exact URL, or a prefix ending in `*`, or a RegExp. */
18
+ readonly match: string | RegExp;
19
+ readonly handler: (request: Request) => Response | Promise<Response>;
20
+ }
21
+
22
+ interface SealState {
23
+ readonly allowed: Set<string>;
24
+ readonly mocks: MockRoute[];
25
+ readonly seen: string[];
26
+ original: FetchLike | undefined;
27
+ network: NetworkState;
28
+ }
29
+
30
+ const state: SealState = {
31
+ allowed: new Set(),
32
+ mocks: [],
33
+ seen: [],
34
+ original: undefined,
35
+ network: 'online',
36
+ };
37
+
38
+ const matches = (route: MockRoute, url: string): boolean => {
39
+ if (route.match instanceof RegExp) return route.match.test(url);
40
+ if (route.match.endsWith('*')) return url.startsWith(route.match.slice(0, -1));
41
+ return route.match === url;
42
+ };
43
+
44
+ const urlOf = (input: RequestInfo | URL): string => {
45
+ if (typeof input === 'string') return input;
46
+ if (input instanceof URL) return input.toString();
47
+ return input.url;
48
+ };
49
+
50
+ const methodOf = (input: RequestInfo | URL, init: RequestInit | undefined): string => {
51
+ if (init?.method !== undefined) return init.method.toUpperCase();
52
+ if (typeof input === 'object' && 'method' in input) return input.method.toUpperCase();
53
+ return 'GET';
54
+ };
55
+
56
+ /** Replace global fetch. Idempotent: sealing twice keeps the one original around. */
57
+ export function sealNetwork(): void {
58
+ if (state.original !== undefined) return;
59
+ state.original = globalThis.fetch;
60
+ globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
61
+ const url = urlOf(input);
62
+ state.seen.push(url);
63
+ // Ahead of the mocks on purpose: a test that mocked Stripe and then went offline is asserting
64
+ // the offline path, and a mock that still answered would be the one thing that path never sees.
65
+ if (state.network !== 'online') {
66
+ throw new NetworkOfflineError({
67
+ url,
68
+ method: methodOf(input, init),
69
+ mode: state.network,
70
+ });
71
+ }
72
+ const mock = state.mocks.find((route) => matches(route, url));
73
+ if (mock !== undefined) {
74
+ return mock.handler(input instanceof Request ? input : new Request(url, init));
75
+ }
76
+ // A server this process booted is not egress — the port is one the kernel just handed us, so
77
+ // there is nothing to allowlist ahead of time. Without this, a socket test's only option is to
78
+ // unseal the network wholesale, which then hides the real egress it was meant to catch.
79
+ const host = safeHost(url);
80
+ if (isSelfOrigin(url) || (host !== undefined && state.allowed.has(host))) {
81
+ const original = state.original;
82
+ if (original === undefined) throw new NetworkRaceError();
83
+ return original(input, init);
84
+ }
85
+ throw new NetworkSealedError({
86
+ url,
87
+ method: methodOf(input, init),
88
+ allowed: [...state.allowed],
89
+ });
90
+ }) as FetchLike;
91
+ }
92
+
93
+ export function unsealNetwork(): void {
94
+ if (state.original === undefined) return;
95
+ globalThis.fetch = state.original;
96
+ state.original = undefined;
97
+ }
98
+
99
+ /** Whether the patch is installed. The `network` fixture seals before going offline, so that
100
+ * `offline()` has teeth in a process that deliberately unsealed (`ULTIMATE_TEST_ALLOW_NET=1`). */
101
+ export const isNetworkSealed = (): boolean => state.original !== undefined;
102
+
103
+ /** The one writer of the offline gate — `createTestNetwork()`. Never call it from a test body. */
104
+ export function setNetworkState(next: NetworkState): void {
105
+ state.network = next;
106
+ }
107
+
108
+ export const networkState = (): NetworkState => state.network;
109
+
110
+ function safeHost(url: string): string | undefined {
111
+ try {
112
+ return new URL(url).host;
113
+ } catch {
114
+ return undefined;
115
+ }
116
+ }
117
+
118
+ /** Let one host through for real. Deliberately per-host, never per-test-file or global. */
119
+ export function allowHost(host: string): void {
120
+ state.allowed.add(host);
121
+ }
122
+
123
+ export function mockFetch(
124
+ match: string | RegExp,
125
+ handler: (request: Request) => Response | Promise<Response>,
126
+ ): void {
127
+ state.mocks.unshift({ match, handler });
128
+ }
129
+
130
+ /** Convenience for the common case: a JSON body and a status. */
131
+ export function mockJson(match: string | RegExp, body: unknown, status = 200): void {
132
+ mockFetch(
133
+ match,
134
+ () =>
135
+ new Response(JSON.stringify(body), {
136
+ status,
137
+ headers: { 'content-type': 'application/json' },
138
+ }),
139
+ );
140
+ }
141
+
142
+ /** Every URL a test attempted, in order — the fastest way to see what a failure actually called. */
143
+ export const requestedUrls = (): readonly string[] => [...state.seen];
144
+
145
+ export function resetNetwork(): void {
146
+ state.allowed.clear();
147
+ state.mocks.length = 0;
148
+ state.seen.length = 0;
149
+ state.network = 'online';
150
+ }