@rcompat/test 0.11.2 → 0.11.3

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/README.md CHANGED
@@ -5,8 +5,9 @@ Testing library for JavaScript runtimes.
5
5
  ## What is @rcompat/test?
6
6
 
7
7
  A cross-runtime testing library with a fluent assertion API. Provides deep
8
- equality checks, type assertions, and exception testing. Designed to work
9
- with the `proby` test runner. Works consistently across Node, Deno, and Bun.
8
+ equality checks, type assertions, exception testing, and fetch interception.
9
+ Designed to work with the `proby` test runner. Works consistently across
10
+ Node, Deno, and Bun.
10
11
 
11
12
  ## Installation
12
13
 
@@ -158,11 +159,6 @@ test.case("async operations", async assert => {
158
159
  const result = await Promise.resolve(42);
159
160
  assert(result).equals(42);
160
161
  });
161
-
162
- test.case("fetch data", async assert => {
163
- const response = await fetch("https://api.example.com/data");
164
- assert(response.ok).true();
165
- });
166
162
  ```
167
163
 
168
164
  ### Cleanup with ended
@@ -177,11 +173,93 @@ test.case("database test", async assert => {
177
173
  });
178
174
 
179
175
  test.ended(async () => {
180
- // cleanup after all tests in this file
176
+ // runs after all tests in this file
181
177
  await closeDatabase();
182
178
  });
183
179
  ```
184
180
 
181
+ ### Intercepting fetch
182
+
183
+ Use `test.intercept` to block outbound fetch calls to a specific origin and
184
+ replace them with fake responses. Calls to other origins pass through
185
+ untouched. The intercept records every request so you can assert on what was
186
+ called and how.
187
+
188
+ ```js
189
+ import test from "@rcompat/test";
190
+
191
+ await using telegram = test.intercept("https://api.telegram.org", setup => {
192
+ setup.post("/sendMessage", () => ({
193
+ ok: true,
194
+ result: { message_id: 42 },
195
+ }));
196
+ });
197
+
198
+ test.case("notifies user via telegram on signup", async assert => {
199
+ await fetch("http://localhost:6161/signup", {
200
+ method: "POST",
201
+ body: JSON.stringify({ email: "foo@bar.com" }),
202
+ });
203
+
204
+ // assert the path was hit the right number of times
205
+ assert(telegram.calls("/sendMessage")).equals(1);
206
+
207
+ // assert on the actual request that came in
208
+ assert(telegram.requests("/sendMessage")[0].method).equals("POST");
209
+ });
210
+ ```
211
+
212
+ `await using` restores the original fetch automatically when the file scope
213
+ exits. For long-lived intercepts that need manual control, use `restore()`
214
+ with `test.ended`:
215
+
216
+ ```js
217
+ const telegram = test.intercept("https://api.telegram.org", setup => {
218
+ setup.post("/sendMessage", () => ({ ok: true, result: { message_id: 42 } }));
219
+ });
220
+
221
+ test.case("first case", async assert => {
222
+ // ...
223
+ });
224
+
225
+ test.case("second case", async assert => {
226
+ // ...
227
+ });
228
+
229
+ test.ended(() => telegram.restore());
230
+ ```
231
+
232
+ Hitting a path on an intercepted origin that has no registered handler throws
233
+ immediately, catching accidental unhandled calls early.
234
+
235
+ ### Extending the asserter
236
+
237
+ Use `test.extend` to attach custom assertion methods to the asserter. Useful
238
+ for domain-specific assertions shared across many test cases.
239
+
240
+ ```js
241
+ import test from "@rcompat/test";
242
+
243
+ // create an extended test with custom assertions
244
+ const myTest = test.extend((assert, subject) => ({
245
+ even() {
246
+ const passed = subject % 2 === 0;
247
+ // use the base asserter to report the result
248
+ assert(passed).true();
249
+ return this;
250
+ },
251
+ }));
252
+
253
+ myTest.case("even numbers", assert => {
254
+ assert(2).even();
255
+ assert(4).even();
256
+ });
257
+ ```
258
+
259
+ The factory receives the base `assert` function and the current `subject`
260
+ (the value passed to `assert()`). Return an object whose methods will be
261
+ mixed into every `Assert` instance for that test.
262
+
185
263
  ## API Reference
186
264
 
187
265
  ### `test.case`
@@ -205,6 +283,60 @@ test.ended(callback: () => void | Promise<void>): void;
205
283
 
206
284
  Register a cleanup callback to run after all tests in the file.
207
285
 
286
+ ### `test.intercept`
287
+
288
+ ```ts
289
+ test.intercept(
290
+ base_url: string,
291
+ setup: (setup: Setup) => void
292
+ ): Intercept;
293
+ ```
294
+
295
+ Intercept outbound fetch calls to `base_url`. Returns an `Intercept` object
296
+ for asserting on recorded requests.
297
+
298
+ | Parameter | Type | Description |
299
+ | ---------- | ---------- | ------------------------------------------------ |
300
+ | `base_url` | `string` | Origin to intercept, e.g. `"https://api.example.com"` |
301
+ | `setup` | `function` | Register route handlers on the setup object |
302
+
303
+ #### `Setup`
304
+
305
+ | Method | Description |
306
+ | ------------------------------- | ---------------------------- |
307
+ | `get(path, handler)` | Register a GET handler |
308
+ | `post(path, handler)` | Register a POST handler |
309
+ | `put(path, handler)` | Register a PUT handler |
310
+ | `patch(path, handler)` | Register a PATCH handler |
311
+ | `delete(path, handler)` | Register a DELETE handler |
312
+
313
+ Each handler receives the incoming `Request` and returns a plain object,
314
+ which is serialized into a `Response` automatically.
315
+
316
+ #### `Intercept`
317
+
318
+ | Method | Description |
319
+ | ------------------- | ---------------------------------------------------- |
320
+ | `calls(path)` | Number of times `path` was hit |
321
+ | `requests(path)` | Array of `Request` objects recorded for `path` |
322
+ | `restore()` | Reinstate the original `globalThis.fetch` |
323
+ | `[Symbol.asyncDispose]` | Called automatically by `await using` |
324
+
325
+ ### `test.extend`
326
+
327
+ ```ts
328
+ test.extend<Subject, Extensions>(
329
+ factory: (assert: Asserter, subject: Subject) => Extensions
330
+ ): ExtendedTest<Extensions>;
331
+ ```
332
+
333
+ Create a new test object with custom assertion methods mixed into the
334
+ asserter.
335
+
336
+ | Parameter | Type | Description |
337
+ | --------- | ---------- | -------------------------------------------------------- |
338
+ | `factory` | `function` | Returns extra methods to attach to each `Assert` instance |
339
+
208
340
  ### `Asserter`
209
341
 
210
342
  ```ts
@@ -219,15 +351,16 @@ The assert function passed to test cases.
219
351
  | ----------------------- | ----------------------------------------- |
220
352
  | `equals(expected)` | Deep equality check |
221
353
  | `nequals(expected)` | Deep inequality check |
354
+ | `includes(expected)` | Inclusion check (string, array, object) |
222
355
  | `true()` | Assert value is `true` |
223
356
  | `false()` | Assert value is `false` |
224
357
  | `null()` | Assert value is `null` |
225
358
  | `undefined()` | Assert value is `undefined` |
226
359
  | `defined()` | Assert value is not `undefined` |
227
360
  | `instance(constructor)` | Assert value is instance of class |
228
- | `throws(message?)` | Assert function throws (optional message) |
361
+ | `throws(expected?)` | Assert function throws |
229
362
  | `tries()` | Assert function does not throw |
230
- | `not` | Negate the next asertion |
363
+ | `not` | Negate the next assertion |
231
364
  | `type<T>()` | Compile-time type assertion |
232
365
  | `nottype<T>()` | Compile-time negative type assertion |
233
366
  | `pass()` | Manually pass the assertion |
@@ -301,17 +434,9 @@ import test from "@rcompat/test";
301
434
 
302
435
  class Calculator {
303
436
  #value = 0;
304
- add(n) {
305
- this.#value += n;
306
- return this;
307
- }
308
- subtract(n) {
309
- this.#value -= n;
310
- return this;
311
- }
312
- get value() {
313
- return this.#value;
314
- }
437
+ add(n) { this.#value += n; return this; }
438
+ subtract(n) { this.#value -= n; return this; }
439
+ get value() { return this.#value; }
315
440
  }
316
441
 
317
442
  test.case("calculator operations", assert => {
@@ -345,6 +470,24 @@ test.case("divide works normally", assert => {
345
470
  });
346
471
  ```
347
472
 
473
+ ### Testing code that calls external APIs
474
+
475
+ ```js
476
+ import test from "@rcompat/test";
477
+
478
+ await using openai = test.intercept("https://api.openai.com", setup => {
479
+ setup.post("/v1/chat/completions", () => ({
480
+ choices: [{ message: { content: "Hello!" } }],
481
+ }));
482
+ });
483
+
484
+ test.case("generates a reply", async assert => {
485
+ const reply = await myService.generateReply("hi");
486
+ assert(reply).equals("Hello!");
487
+ assert(openai.calls("/v1/chat/completions")).equals(1);
488
+ });
489
+ ```
490
+
348
491
  ## Cross-Runtime Compatibility
349
492
 
350
493
  | Runtime | Supported |
@@ -362,3 +505,4 @@ MIT
362
505
  ## Contributing
363
506
 
364
507
  See [CONTRIBUTING.md](../../CONTRIBUTING.md) in the repository root.
508
+
@@ -6,6 +6,18 @@ import type Result from "#Result";
6
6
  import type Test from "#Test";
7
7
  import type { ExtendedTest, Factory } from "#extend";
8
8
  declare const _default: {
9
+ intercept: (base_url: string, setup: (setup: {
10
+ get(path: string, handler: (request: Request) => unknown): void;
11
+ post(path: string, handler: (request: Request) => unknown): void;
12
+ put(path: string, handler: (request: Request) => unknown): void;
13
+ patch(path: string, handler: (request: Request) => unknown): void;
14
+ delete(path: string, handler: (request: Request) => unknown): void;
15
+ }) => void) => {
16
+ calls(path: string): number;
17
+ requests(path: string): Request[];
18
+ restore(): void;
19
+ [Symbol.asyncDispose](): Promise<void>;
20
+ };
9
21
  extend<Subject, Extensions>(factory: Factory<Subject, Extensions>): ExtendedTest<Extensions>;
10
22
  case(name: string, body: Body): void;
11
23
  ended(end: End): void;
@@ -1,5 +1,6 @@
1
1
  import extend from "#extend";
2
2
  import repository from "#repository";
3
+ import intercept from "#intercept";
3
4
  const base = {
4
5
  case(name, body) {
5
6
  repository.put(name, body);
@@ -10,6 +11,7 @@ const base = {
10
11
  };
11
12
  export default {
12
13
  ...base,
14
+ intercept,
13
15
  extend(factory) {
14
16
  return extend(base, factory);
15
17
  },
@@ -0,0 +1,17 @@
1
+ type Handler = (request: Request) => unknown;
2
+ type Setup = {
3
+ get(path: string, handler: Handler): void;
4
+ post(path: string, handler: Handler): void;
5
+ put(path: string, handler: Handler): void;
6
+ patch(path: string, handler: Handler): void;
7
+ delete(path: string, handler: Handler): void;
8
+ };
9
+ type Intercept = {
10
+ calls(path: string): number;
11
+ requests(path: string): Request[];
12
+ restore(): void;
13
+ [Symbol.asyncDispose](): Promise<void>;
14
+ };
15
+ declare const _default: (base_url: string, setup: (setup: Setup) => void) => Intercept;
16
+ export default _default;
17
+ //# sourceMappingURL=intercept.d.ts.map
@@ -0,0 +1,47 @@
1
+ export default (base_url, setup) => {
2
+ const routes = [];
3
+ const log = [];
4
+ const original = globalThis.fetch;
5
+ const register = (method) => (path, handler) => {
6
+ routes.push({ method, path, handler });
7
+ };
8
+ setup({
9
+ get: register("GET"),
10
+ post: register("POST"),
11
+ put: register("PUT"),
12
+ patch: register("PATCH"),
13
+ delete: register("DELETE"),
14
+ });
15
+ globalThis.fetch = (async (input, init) => {
16
+ const request = new Request(input, init);
17
+ const url = new URL(request.url);
18
+ const origin = url.origin;
19
+ const path = url.pathname;
20
+ if (origin !== base_url) {
21
+ return original(request);
22
+ }
23
+ const route = routes.find(r => r.method === request.method && r.path === path);
24
+ if (route === undefined) {
25
+ throw new Error(`no intercept registered for ${request.method} ${url.href}`);
26
+ }
27
+ log.push(request);
28
+ const body = route.handler(request);
29
+ return Response.json(body);
30
+ });
31
+ const restore = () => {
32
+ globalThis.fetch = original;
33
+ };
34
+ return {
35
+ calls(path) {
36
+ return log.filter(r => new URL(r.url).pathname === path).length;
37
+ },
38
+ requests(path) {
39
+ return log.filter(r => new URL(r.url).pathname === path);
40
+ },
41
+ restore,
42
+ async [Symbol.asyncDispose]() {
43
+ restore();
44
+ },
45
+ };
46
+ };
47
+ //# sourceMappingURL=intercept.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rcompat/test",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "description": "Standard library testing",
5
5
  "bugs": "https://github.com/rcompat/rcompat/issues",
6
6
  "license": "MIT",