@rcompat/test 0.11.2 → 0.12.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/README.md +309 -26
- package/lib/private/Suite.d.ts +2 -1
- package/lib/private/Suite.js +9 -2
- package/lib/private/Test.d.ts +2 -1
- package/lib/private/Test.js +6 -1
- package/lib/private/fixtures/math.d.ts +2 -0
- package/lib/private/fixtures/math.js +5 -0
- package/lib/private/import.d.ts +2 -0
- package/lib/private/import.js +4 -0
- package/lib/private/index.d.ts +17 -0
- package/lib/private/index.js +9 -0
- package/lib/private/intercept.d.ts +17 -0
- package/lib/private/intercept.js +47 -0
- package/lib/private/mock.d.ts +14 -0
- package/lib/private/mock.js +87 -0
- package/lib/private/repository.d.ts +3 -0
- package/lib/private/repository.js +15 -1
- package/package.json +13 -10
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,
|
|
9
|
-
with the `proby` test runner. Works
|
|
8
|
+
equality checks, type assertions, exception testing, fetch interception, and
|
|
9
|
+
module mocking. Designed to work with the `proby` test runner. Works
|
|
10
|
+
consistently across Node, Deno, and Bun.
|
|
10
11
|
|
|
11
12
|
## Installation
|
|
12
13
|
|
|
@@ -114,12 +115,12 @@ test.case("throws", assert => {
|
|
|
114
115
|
// check that function throws
|
|
115
116
|
assert(() => {
|
|
116
117
|
throw new Error("oops");
|
|
117
|
-
}).throws();
|
|
118
|
+
}).throws("oops");
|
|
118
119
|
|
|
119
|
-
// Check specific error
|
|
120
|
+
// Check specific error type
|
|
120
121
|
assert(() => {
|
|
121
|
-
throw new
|
|
122
|
-
}).throws(
|
|
122
|
+
throw new TypeError("invalid input");
|
|
123
|
+
}).throws(TypeError);
|
|
123
124
|
});
|
|
124
125
|
|
|
125
126
|
test.case("tries (does not throw)", assert => {
|
|
@@ -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,193 @@ test.case("database test", async assert => {
|
|
|
177
173
|
});
|
|
178
174
|
|
|
179
175
|
test.ended(async () => {
|
|
180
|
-
//
|
|
176
|
+
// runs after all tests in this file
|
|
181
177
|
await closeDatabase();
|
|
182
178
|
});
|
|
183
179
|
```
|
|
184
180
|
|
|
181
|
+
### Grouping tests
|
|
182
|
+
|
|
183
|
+
Use `test.group` to cluster related test cases together. Groups can be run
|
|
184
|
+
individually via proby.
|
|
185
|
+
|
|
186
|
+
```js
|
|
187
|
+
import test from "@rcompat/test";
|
|
188
|
+
|
|
189
|
+
test.group("addition", () => {
|
|
190
|
+
test.case("integers", assert => {
|
|
191
|
+
assert(1 + 1).equals(2);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test.case("floats", assert => {
|
|
195
|
+
assert(0.1 + 0.2).equals(0.3);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test.group("subtraction", () => {
|
|
200
|
+
test.case("integers", assert => {
|
|
201
|
+
assert(3 - 1).equals(2);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Run a specific group:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
npx proby math.spec.ts addition
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
### Mocking modules
|
|
213
|
+
|
|
214
|
+
Use `test.mock` to replace a module's exports and `test.import` to import the
|
|
215
|
+
mocked module.
|
|
216
|
+
|
|
217
|
+
```js
|
|
218
|
+
import test from "@rcompat/test";
|
|
219
|
+
|
|
220
|
+
using math = test.mock("./math.ts", () => ({
|
|
221
|
+
add: (a, b) => 99,
|
|
222
|
+
}));
|
|
223
|
+
|
|
224
|
+
const { add } = await test.import("./math.ts");
|
|
225
|
+
|
|
226
|
+
test.case("returns mocked value", assert => {
|
|
227
|
+
assert(add(1, 2)).equals(99);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test.case("tracks calls", assert => {
|
|
231
|
+
add(1, 2);
|
|
232
|
+
assert(math.add.calls.length).equals(1);
|
|
233
|
+
assert(math.add.calls[0]).equals([1, 2]);
|
|
234
|
+
assert(math.add.called).true();
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
Tracked mock functions record each call as a tuple of arguments in `calls`.
|
|
239
|
+
Call tracking resets between test cases.
|
|
240
|
+
|
|
241
|
+
### Static mocks with proby
|
|
242
|
+
|
|
243
|
+
When running tests with `proby`, you can preload mocks before a spec file is
|
|
244
|
+
evaluated by adding a sibling mock file.
|
|
245
|
+
|
|
246
|
+
- `foo.spec.ts` pairs with `foo.mock.ts`
|
|
247
|
+
- `foo.spec.js` pairs with `foo.mock.js`
|
|
248
|
+
|
|
249
|
+
`proby` loads the mock file before the spec file, so the spec's static imports
|
|
250
|
+
see the mocked module immediately.
|
|
251
|
+
|
|
252
|
+
```ts
|
|
253
|
+
// math.ts
|
|
254
|
+
export function add(a: number, b: number) {
|
|
255
|
+
return a + b;
|
|
256
|
+
}
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
// math.mock.ts
|
|
261
|
+
import test from "@rcompat/test";
|
|
262
|
+
|
|
263
|
+
test.mock("./math.ts", () => ({
|
|
264
|
+
add: (a: number, b: number) => 99,
|
|
265
|
+
}));
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
// math.spec.ts
|
|
270
|
+
import test from "@rcompat/test";
|
|
271
|
+
import { add } from "./math.ts";
|
|
272
|
+
|
|
273
|
+
test.case("static mock is loaded before the spec", assert => {
|
|
274
|
+
assert(add(1, 2)).equals(99);
|
|
275
|
+
});
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
Static mocks are file-scoped when run through `proby`; they do not leak into
|
|
279
|
+
later spec files.
|
|
280
|
+
|
|
281
|
+
### Intercepting fetch
|
|
282
|
+
|
|
283
|
+
Use `test.intercept` to block outbound fetch calls to a specific origin and
|
|
284
|
+
replace them with fake responses. Calls to other origins pass through
|
|
285
|
+
untouched. The intercept records every request so you can assert on what was
|
|
286
|
+
called and how.
|
|
287
|
+
|
|
288
|
+
```js
|
|
289
|
+
import test from "@rcompat/test";
|
|
290
|
+
|
|
291
|
+
await using telegram = test.intercept("https://api.telegram.org", setup => {
|
|
292
|
+
setup.post("/sendMessage", () => ({
|
|
293
|
+
ok: true,
|
|
294
|
+
result: { message_id: 42 },
|
|
295
|
+
}));
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test.case("notifies user via telegram on signup", async assert => {
|
|
299
|
+
await fetch("http://localhost:6161/signup", {
|
|
300
|
+
method: "POST",
|
|
301
|
+
body: JSON.stringify({ email: "foo@bar.com" }),
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// assert the path was hit the right number of times
|
|
305
|
+
assert(telegram.calls("/sendMessage")).equals(1);
|
|
306
|
+
|
|
307
|
+
// assert on the actual request that came in
|
|
308
|
+
assert(telegram.requests("/sendMessage")[0].method).equals("POST");
|
|
309
|
+
});
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`await using` restores the original fetch automatically when the file scope
|
|
313
|
+
exits. For long-lived intercepts that need manual control, use `restore()`
|
|
314
|
+
with `test.ended`:
|
|
315
|
+
|
|
316
|
+
```js
|
|
317
|
+
const telegram = test.intercept("https://api.telegram.org", setup => {
|
|
318
|
+
setup.post("/sendMessage", () => ({ ok: true, result: { message_id: 42 } }));
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test.case("first case", async assert => {
|
|
322
|
+
// ...
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test.case("second case", async assert => {
|
|
326
|
+
// ...
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test.ended(() => telegram.restore());
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Hitting a path on an intercepted origin that has no registered handler throws
|
|
333
|
+
immediately, catching accidental unhandled calls early.
|
|
334
|
+
|
|
335
|
+
### Extending the asserter
|
|
336
|
+
|
|
337
|
+
Use `test.extend` to attach custom assertion methods to the asserter. Useful
|
|
338
|
+
for domain-specific assertions shared across many test cases.
|
|
339
|
+
|
|
340
|
+
```js
|
|
341
|
+
import test from "@rcompat/test";
|
|
342
|
+
|
|
343
|
+
// create an extended test with custom assertions
|
|
344
|
+
const myTest = test.extend((assert, subject) => ({
|
|
345
|
+
even() {
|
|
346
|
+
const passed = subject % 2 === 0;
|
|
347
|
+
// use the base asserter to report the result
|
|
348
|
+
assert(passed).true();
|
|
349
|
+
return this;
|
|
350
|
+
},
|
|
351
|
+
}));
|
|
352
|
+
|
|
353
|
+
myTest.case("even numbers", assert => {
|
|
354
|
+
assert(2).even();
|
|
355
|
+
assert(4).even();
|
|
356
|
+
});
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
The factory receives the base `assert` function and the current `subject`
|
|
360
|
+
(the value passed to `assert()`). Return an object whose methods will be
|
|
361
|
+
mixed into every `Assert` instance for that test.
|
|
362
|
+
|
|
185
363
|
## API Reference
|
|
186
364
|
|
|
187
365
|
### `test.case`
|
|
@@ -205,6 +383,99 @@ test.ended(callback: () => void | Promise<void>): void;
|
|
|
205
383
|
|
|
206
384
|
Register a cleanup callback to run after all tests in the file.
|
|
207
385
|
|
|
386
|
+
### `test.group`
|
|
387
|
+
|
|
388
|
+
```ts
|
|
389
|
+
test.group(name: string, fn: () => void): void;
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
Group test cases under a named scope. Groups can be targeted individually
|
|
393
|
+
when running proby.
|
|
394
|
+
|
|
395
|
+
| Parameter | Type | Description |
|
|
396
|
+
| --------- | ---------- | ----------------------------------- |
|
|
397
|
+
| `name` | `string` | Group name, used by proby to filter |
|
|
398
|
+
| `fn` | `function` | Function containing `test.case` calls |
|
|
399
|
+
|
|
400
|
+
### `test.mock`
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
test.mock<T extends object>(
|
|
404
|
+
specifier: string,
|
|
405
|
+
factory: (original: unknown) => T,
|
|
406
|
+
): MockHandle<T>;
|
|
407
|
+
```
|
|
408
|
+
|
|
409
|
+
Register a module mock and return a handle to the tracked mocked exports.
|
|
410
|
+
Function exports are wrapped so you can inspect `calls` and `called`.
|
|
411
|
+
|
|
412
|
+
| Parameter | Type | Description |
|
|
413
|
+
| ----------- | ---------- | -------------------------------------------- |
|
|
414
|
+
| `specifier` | `string` | Module specifier to mock |
|
|
415
|
+
| `factory` | `function` | Returns the mocked exports for that module |
|
|
416
|
+
|
|
417
|
+
### `test.import`
|
|
418
|
+
|
|
419
|
+
```ts
|
|
420
|
+
test.import(specifier: string): Promise<unknown>;
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
Import a module after mocks have been registered.
|
|
424
|
+
|
|
425
|
+
### `test.intercept`
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
test.intercept(
|
|
429
|
+
base_url: string,
|
|
430
|
+
setup: (setup: Setup) => void
|
|
431
|
+
): Intercept;
|
|
432
|
+
```
|
|
433
|
+
|
|
434
|
+
Intercept outbound fetch calls to `base_url`. Returns an `Intercept` object
|
|
435
|
+
for asserting on recorded requests.
|
|
436
|
+
|
|
437
|
+
| Parameter | Type | Description |
|
|
438
|
+
| ---------- | ---------- | --------------------------------------------------------- |
|
|
439
|
+
| `base_url` | `string` | Origin to intercept, e.g. `"https://api.example.com"` |
|
|
440
|
+
| `setup` | `function` | Register route handlers on the setup object |
|
|
441
|
+
|
|
442
|
+
### `test.extend`
|
|
443
|
+
|
|
444
|
+
```ts
|
|
445
|
+
test.extend<Subject, Extensions>(
|
|
446
|
+
factory: (assert: Asserter, subject: Subject) => Extensions
|
|
447
|
+
): ExtendedTest<Extensions>;
|
|
448
|
+
```
|
|
449
|
+
|
|
450
|
+
Create a new test object with custom assertion methods mixed into the
|
|
451
|
+
asserter.
|
|
452
|
+
|
|
453
|
+
| Parameter | Type | Description |
|
|
454
|
+
| --------- | ---------- | ---------------------------------------------------------- |
|
|
455
|
+
| `factory` | `function` | Returns extra methods to attach to each `Assert` instance |
|
|
456
|
+
|
|
457
|
+
#### `Setup`
|
|
458
|
+
|
|
459
|
+
| Method | Description |
|
|
460
|
+
| ---------------------- | ------------------------- |
|
|
461
|
+
| `get(path, handler)` | Register a GET handler |
|
|
462
|
+
| `post(path, handler)` | Register a POST handler |
|
|
463
|
+
| `put(path, handler)` | Register a PUT handler |
|
|
464
|
+
| `patch(path, handler)` | Register a PATCH handler |
|
|
465
|
+
| `delete(path, handler)`| Register a DELETE handler |
|
|
466
|
+
|
|
467
|
+
Each handler receives the incoming `Request` and returns a plain object,
|
|
468
|
+
which is serialized into a `Response` automatically.
|
|
469
|
+
|
|
470
|
+
#### `Intercept`
|
|
471
|
+
|
|
472
|
+
| Method | Description |
|
|
473
|
+
| ----------------------- | ----------------------------------------------- |
|
|
474
|
+
| `calls(path)` | Number of times `path` was hit |
|
|
475
|
+
| `requests(path)` | Array of `Request` objects recorded for `path` |
|
|
476
|
+
| `restore()` | Reinstate the original `globalThis.fetch` |
|
|
477
|
+
| `[Symbol.asyncDispose]` | Called automatically by `await using` |
|
|
478
|
+
|
|
208
479
|
### `Asserter`
|
|
209
480
|
|
|
210
481
|
```ts
|
|
@@ -219,15 +490,16 @@ The assert function passed to test cases.
|
|
|
219
490
|
| ----------------------- | ----------------------------------------- |
|
|
220
491
|
| `equals(expected)` | Deep equality check |
|
|
221
492
|
| `nequals(expected)` | Deep inequality check |
|
|
493
|
+
| `includes(expected)` | Inclusion check (string, array, object) |
|
|
222
494
|
| `true()` | Assert value is `true` |
|
|
223
495
|
| `false()` | Assert value is `false` |
|
|
224
496
|
| `null()` | Assert value is `null` |
|
|
225
497
|
| `undefined()` | Assert value is `undefined` |
|
|
226
498
|
| `defined()` | Assert value is not `undefined` |
|
|
227
499
|
| `instance(constructor)` | Assert value is instance of class |
|
|
228
|
-
| `throws(
|
|
500
|
+
| `throws(expected?)` | Assert function throws |
|
|
229
501
|
| `tries()` | Assert function does not throw |
|
|
230
|
-
| `not` | Negate the next
|
|
502
|
+
| `not` | Negate the next assertion |
|
|
231
503
|
| `type<T>()` | Compile-time type assertion |
|
|
232
504
|
| `nottype<T>()` | Compile-time negative type assertion |
|
|
233
505
|
| `pass()` | Manually pass the assertion |
|
|
@@ -273,7 +545,7 @@ import E from "@rcompat/test/E";
|
|
|
273
545
|
E(error: unknown): { message: string };
|
|
274
546
|
```
|
|
275
547
|
|
|
276
|
-
Extract error
|
|
548
|
+
Extract error data from unknown error types.
|
|
277
549
|
|
|
278
550
|
## Examples
|
|
279
551
|
|
|
@@ -301,17 +573,9 @@ import test from "@rcompat/test";
|
|
|
301
573
|
|
|
302
574
|
class Calculator {
|
|
303
575
|
#value = 0;
|
|
304
|
-
add(n) {
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
}
|
|
308
|
-
subtract(n) {
|
|
309
|
-
this.#value -= n;
|
|
310
|
-
return this;
|
|
311
|
-
}
|
|
312
|
-
get value() {
|
|
313
|
-
return this.#value;
|
|
314
|
-
}
|
|
576
|
+
add(n) { this.#value += n; return this; }
|
|
577
|
+
subtract(n) { this.#value -= n; return this; }
|
|
578
|
+
get value() { return this.#value; }
|
|
315
579
|
}
|
|
316
580
|
|
|
317
581
|
test.case("calculator operations", assert => {
|
|
@@ -345,6 +609,24 @@ test.case("divide works normally", assert => {
|
|
|
345
609
|
});
|
|
346
610
|
```
|
|
347
611
|
|
|
612
|
+
### Testing code that calls external APIs
|
|
613
|
+
|
|
614
|
+
```js
|
|
615
|
+
import test from "@rcompat/test";
|
|
616
|
+
|
|
617
|
+
await using openai = test.intercept("https://api.openai.com", setup => {
|
|
618
|
+
setup.post("/v1/chat/completions", () => ({
|
|
619
|
+
choices: [{ message: { content: "Hello!" } }],
|
|
620
|
+
}));
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test.case("generates a reply", async assert => {
|
|
624
|
+
const reply = await myService.generateReply("hi");
|
|
625
|
+
assert(reply).equals("Hello!");
|
|
626
|
+
assert(openai.calls("/v1/chat/completions")).equals(1);
|
|
627
|
+
});
|
|
628
|
+
```
|
|
629
|
+
|
|
348
630
|
## Cross-Runtime Compatibility
|
|
349
631
|
|
|
350
632
|
| Runtime | Supported |
|
|
@@ -362,3 +644,4 @@ MIT
|
|
|
362
644
|
## Contributing
|
|
363
645
|
|
|
364
646
|
See [CONTRIBUTING.md](../../CONTRIBUTING.md) in the repository root.
|
|
647
|
+
|
package/lib/private/Suite.d.ts
CHANGED
|
@@ -5,9 +5,10 @@ import type { FileRef } from "@rcompat/fs";
|
|
|
5
5
|
export default class Suite {
|
|
6
6
|
#private;
|
|
7
7
|
constructor(file: FileRef);
|
|
8
|
-
test(name: string, body: Body): void;
|
|
8
|
+
test(name: string, body: Body, group?: string): void;
|
|
9
9
|
ended(end: End): void;
|
|
10
10
|
get file(): FileRef;
|
|
11
|
+
between(fn: () => void): void;
|
|
11
12
|
run(): AsyncGenerator<Test, void, unknown>;
|
|
12
13
|
end(): Promise<void>;
|
|
13
14
|
}
|
package/lib/private/Suite.js
CHANGED
|
@@ -3,11 +3,12 @@ export default class Suite {
|
|
|
3
3
|
#file;
|
|
4
4
|
#tests = [];
|
|
5
5
|
#ends = [];
|
|
6
|
+
#between = [];
|
|
6
7
|
constructor(file) {
|
|
7
8
|
this.#file = file;
|
|
8
9
|
}
|
|
9
|
-
test(name, body) {
|
|
10
|
-
this.#tests.push(new Test(name, body));
|
|
10
|
+
test(name, body, group) {
|
|
11
|
+
this.#tests.push(new Test(name, body, group));
|
|
11
12
|
}
|
|
12
13
|
ended(end) {
|
|
13
14
|
this.#ends.push(end);
|
|
@@ -15,9 +16,15 @@ export default class Suite {
|
|
|
15
16
|
get file() {
|
|
16
17
|
return this.#file;
|
|
17
18
|
}
|
|
19
|
+
between(fn) {
|
|
20
|
+
this.#between.push(fn);
|
|
21
|
+
}
|
|
18
22
|
async *run() {
|
|
19
23
|
for (const test of this.#tests) {
|
|
20
24
|
yield await test.run();
|
|
25
|
+
for (const fn of this.#between) {
|
|
26
|
+
fn();
|
|
27
|
+
}
|
|
21
28
|
}
|
|
22
29
|
}
|
|
23
30
|
async end() {
|
package/lib/private/Test.d.ts
CHANGED
|
@@ -2,8 +2,9 @@ import type Body from "#Body";
|
|
|
2
2
|
import Result from "#Result";
|
|
3
3
|
export default class Test {
|
|
4
4
|
#private;
|
|
5
|
-
constructor(name: string, body: Body);
|
|
5
|
+
constructor(name: string, body: Body, group?: string);
|
|
6
6
|
get name(): string;
|
|
7
|
+
get group(): string | undefined;
|
|
7
8
|
get results(): Result<unknown>[];
|
|
8
9
|
report<T>(actual: T, expected: T, passed: boolean): void;
|
|
9
10
|
run(): Promise<this>;
|
package/lib/private/Test.js
CHANGED
|
@@ -4,13 +4,18 @@ export default class Test {
|
|
|
4
4
|
#name;
|
|
5
5
|
#body;
|
|
6
6
|
#results = [];
|
|
7
|
-
|
|
7
|
+
#group;
|
|
8
|
+
constructor(name, body, group) {
|
|
8
9
|
this.#name = name;
|
|
9
10
|
this.#body = body;
|
|
11
|
+
this.#group = group;
|
|
10
12
|
}
|
|
11
13
|
get name() {
|
|
12
14
|
return this.#name;
|
|
13
15
|
}
|
|
16
|
+
get group() {
|
|
17
|
+
return this.#group;
|
|
18
|
+
}
|
|
14
19
|
get results() {
|
|
15
20
|
return this.#results;
|
|
16
21
|
}
|
package/lib/private/index.d.ts
CHANGED
|
@@ -5,10 +5,27 @@ import type Env from "#Env";
|
|
|
5
5
|
import type Result from "#Result";
|
|
6
6
|
import type Test from "#Test";
|
|
7
7
|
import type { ExtendedTest, Factory } from "#extend";
|
|
8
|
+
import mock from "#mock";
|
|
9
|
+
import import_ from "#import";
|
|
8
10
|
declare const _default: {
|
|
11
|
+
intercept: (base_url: string, setup: (setup: {
|
|
12
|
+
get(path: string, handler: (request: Request) => unknown): void;
|
|
13
|
+
post(path: string, handler: (request: Request) => unknown): void;
|
|
14
|
+
put(path: string, handler: (request: Request) => unknown): void;
|
|
15
|
+
patch(path: string, handler: (request: Request) => unknown): void;
|
|
16
|
+
delete(path: string, handler: (request: Request) => unknown): void;
|
|
17
|
+
}) => void) => {
|
|
18
|
+
calls(path: string): number;
|
|
19
|
+
requests(path: string): Request[];
|
|
20
|
+
restore(): void;
|
|
21
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
22
|
+
};
|
|
9
23
|
extend<Subject, Extensions>(factory: Factory<Subject, Extensions>): ExtendedTest<Extensions>;
|
|
10
24
|
case(name: string, body: Body): void;
|
|
11
25
|
ended(end: End): void;
|
|
26
|
+
group(name: string, fn: () => void): void;
|
|
27
|
+
mock: typeof mock;
|
|
28
|
+
import: typeof import_;
|
|
12
29
|
};
|
|
13
30
|
export default _default;
|
|
14
31
|
export type { Asserter, Env, ExtendedTest, Result, Test };
|
package/lib/private/index.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import extend from "#extend";
|
|
2
2
|
import repository from "#repository";
|
|
3
|
+
import intercept from "#intercept";
|
|
4
|
+
import mock from "#mock";
|
|
5
|
+
import import_ from "#import";
|
|
3
6
|
const base = {
|
|
4
7
|
case(name, body) {
|
|
5
8
|
repository.put(name, body);
|
|
@@ -7,9 +10,15 @@ const base = {
|
|
|
7
10
|
ended(end) {
|
|
8
11
|
repository.ended(end);
|
|
9
12
|
},
|
|
13
|
+
group(name, fn) {
|
|
14
|
+
repository.group(name, fn);
|
|
15
|
+
},
|
|
16
|
+
mock,
|
|
17
|
+
import: import_,
|
|
10
18
|
};
|
|
11
19
|
export default {
|
|
12
20
|
...base,
|
|
21
|
+
intercept,
|
|
13
22
|
extend(factory) {
|
|
14
23
|
return extend(base, factory);
|
|
15
24
|
},
|
|
@@ -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
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
type AnyFn = (...args: any[]) => any;
|
|
2
|
+
type Tracked<F extends AnyFn> = F & {
|
|
3
|
+
calls: Parameters<F>[];
|
|
4
|
+
readonly called: boolean;
|
|
5
|
+
};
|
|
6
|
+
type MockHandle<T extends object> = {
|
|
7
|
+
[K in keyof T]: T[K] extends AnyFn ? Tracked<T[K]> : T[K];
|
|
8
|
+
} & {
|
|
9
|
+
restore(): void;
|
|
10
|
+
[Symbol.dispose](): void;
|
|
11
|
+
};
|
|
12
|
+
export default function mock<T extends object>(specifier: string, factory: (original: unknown) => T): MockHandle<T>;
|
|
13
|
+
export {};
|
|
14
|
+
//# sourceMappingURL=mock.d.ts.map
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import repository from "#repository";
|
|
2
|
+
import is from "@rcompat/is";
|
|
3
|
+
import module from "node:module";
|
|
4
|
+
let loader_registered = false;
|
|
5
|
+
const MARK = "std:test/mock";
|
|
6
|
+
const clean = (specifier) => specifier.split("?")[0];
|
|
7
|
+
const track = (fn) => {
|
|
8
|
+
const calls = [];
|
|
9
|
+
const tracked = ((...args) => {
|
|
10
|
+
calls.push(args);
|
|
11
|
+
return fn(...args);
|
|
12
|
+
});
|
|
13
|
+
tracked.calls = calls;
|
|
14
|
+
Object.defineProperty(tracked, "called", {
|
|
15
|
+
get: () => calls.length > 0,
|
|
16
|
+
});
|
|
17
|
+
return tracked;
|
|
18
|
+
};
|
|
19
|
+
const hooks = {
|
|
20
|
+
resolve(specifier, context, next) {
|
|
21
|
+
const id = clean(specifier);
|
|
22
|
+
if (!repository.mocks.has(id)) {
|
|
23
|
+
return next(specifier, context);
|
|
24
|
+
}
|
|
25
|
+
const resolved = next(id, context);
|
|
26
|
+
const url = new URL(resolved.url);
|
|
27
|
+
url.searchParams.set(MARK, id);
|
|
28
|
+
return {
|
|
29
|
+
...resolved,
|
|
30
|
+
shortCircuit: true,
|
|
31
|
+
url: url.href,
|
|
32
|
+
};
|
|
33
|
+
},
|
|
34
|
+
load(url, context, next) {
|
|
35
|
+
const parsed = new URL(url);
|
|
36
|
+
const id = parsed.searchParams.get(MARK);
|
|
37
|
+
if (id === null) {
|
|
38
|
+
return next(url, context);
|
|
39
|
+
}
|
|
40
|
+
const tracked = repository.mocks.get(id);
|
|
41
|
+
if (tracked === undefined)
|
|
42
|
+
throw new Error(`mock not found for ${id}`);
|
|
43
|
+
const exports = Object.keys(tracked);
|
|
44
|
+
const source = `
|
|
45
|
+
import repository from "@rcompat/test/repository";
|
|
46
|
+
const tracked = repository.mocks.get(${JSON.stringify(id)});
|
|
47
|
+
if (tracked === undefined) {
|
|
48
|
+
throw new Error(${JSON.stringify(`mock not found for ${id}`)});
|
|
49
|
+
}
|
|
50
|
+
${exports.map(k => `export const ${k} = tracked[${JSON.stringify(k)}];`).join("\n")}
|
|
51
|
+
`;
|
|
52
|
+
return {
|
|
53
|
+
format: "module",
|
|
54
|
+
shortCircuit: true,
|
|
55
|
+
source,
|
|
56
|
+
};
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
export default function mock(specifier, factory) {
|
|
60
|
+
if (!loader_registered) {
|
|
61
|
+
module.registerHooks(hooks);
|
|
62
|
+
loader_registered = true;
|
|
63
|
+
}
|
|
64
|
+
const id = clean(specifier);
|
|
65
|
+
const mocked = factory({});
|
|
66
|
+
const tracked = Object.fromEntries(Object.entries(mocked).map(([k, v]) => [
|
|
67
|
+
k,
|
|
68
|
+
is.function(v) ? track(v) : v,
|
|
69
|
+
]));
|
|
70
|
+
repository.mocks.set(id, tracked);
|
|
71
|
+
repository.between(() => {
|
|
72
|
+
for (const v of Object.values(tracked)) {
|
|
73
|
+
if (is.function(v) && "calls" in v) {
|
|
74
|
+
v.calls.length = 0;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return Object.assign(tracked, {
|
|
79
|
+
restore() {
|
|
80
|
+
repository.mocks.delete(id);
|
|
81
|
+
},
|
|
82
|
+
[Symbol.dispose]() {
|
|
83
|
+
this.restore();
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
//# sourceMappingURL=mock.js.map
|
|
@@ -4,7 +4,10 @@ import Suite from "#Suite";
|
|
|
4
4
|
import type { FileRef } from "@rcompat/fs";
|
|
5
5
|
declare class Repository {
|
|
6
6
|
#private;
|
|
7
|
+
get mocks(): Map<string, unknown>;
|
|
7
8
|
put(name: string, body: Body): void;
|
|
9
|
+
group(name: string, fn: () => void): void;
|
|
10
|
+
between(fn: () => void): void;
|
|
8
11
|
ended(end: End): void;
|
|
9
12
|
suite(file: FileRef): void;
|
|
10
13
|
reset(): void;
|
|
@@ -1,11 +1,24 @@
|
|
|
1
1
|
import Suite from "#Suite";
|
|
2
2
|
class Repository {
|
|
3
3
|
#suites = [];
|
|
4
|
+
#current_group;
|
|
5
|
+
#mocks = new Map();
|
|
4
6
|
get #suite() {
|
|
5
7
|
return this.#suites.at(-1);
|
|
6
8
|
}
|
|
9
|
+
get mocks() {
|
|
10
|
+
return this.#mocks;
|
|
11
|
+
}
|
|
7
12
|
put(name, body) {
|
|
8
|
-
this.#suite.test(name, body);
|
|
13
|
+
this.#suite.test(name, body, this.#current_group);
|
|
14
|
+
}
|
|
15
|
+
group(name, fn) {
|
|
16
|
+
this.#current_group = name;
|
|
17
|
+
fn();
|
|
18
|
+
this.#current_group = undefined;
|
|
19
|
+
}
|
|
20
|
+
between(fn) {
|
|
21
|
+
this.#suite.between(fn);
|
|
9
22
|
}
|
|
10
23
|
ended(end) {
|
|
11
24
|
this.#suite.ended(end);
|
|
@@ -15,6 +28,7 @@ class Repository {
|
|
|
15
28
|
}
|
|
16
29
|
reset() {
|
|
17
30
|
this.#suites = [];
|
|
31
|
+
this.#mocks = new Map();
|
|
18
32
|
}
|
|
19
33
|
*next() {
|
|
20
34
|
for (const suite of this.#suites) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rcompat/test",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Standard library testing",
|
|
5
5
|
"bugs": "https://github.com/rcompat/rcompat/issues",
|
|
6
6
|
"license": "MIT",
|
|
@@ -14,42 +14,45 @@
|
|
|
14
14
|
"url": "https://github.com/rcompat/rcompat",
|
|
15
15
|
"directory": "packages/test"
|
|
16
16
|
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@rcompat/is": "^0.6.0"
|
|
19
|
+
},
|
|
17
20
|
"devDependencies": {
|
|
18
|
-
"@rcompat/fs": "^0.
|
|
19
|
-
"@rcompat/type": "^0.
|
|
21
|
+
"@rcompat/fs": "^0.28.0",
|
|
22
|
+
"@rcompat/type": "^0.11.0"
|
|
20
23
|
},
|
|
21
24
|
"type": "module",
|
|
22
25
|
"imports": {
|
|
23
26
|
"#mask/*": {
|
|
24
|
-
"
|
|
27
|
+
"source": "./src/private/mask/*.ts",
|
|
25
28
|
"default": "./lib/private/mask/*.js"
|
|
26
29
|
},
|
|
27
30
|
"#types/*": {
|
|
28
|
-
"
|
|
31
|
+
"source": "./src/private/types/*.ts",
|
|
29
32
|
"default": "./lib/private/types/*.js"
|
|
30
33
|
},
|
|
31
34
|
"#*": {
|
|
32
|
-
"
|
|
35
|
+
"source": "./src/private/*.ts",
|
|
33
36
|
"default": "./lib/private/*.js"
|
|
34
37
|
}
|
|
35
38
|
},
|
|
36
39
|
"exports": {
|
|
37
40
|
"./mask/*": {
|
|
38
|
-
"
|
|
41
|
+
"source": "./src/public/mask/*.ts",
|
|
39
42
|
"default": "./lib/public/mask/*.js"
|
|
40
43
|
},
|
|
41
44
|
"./*": {
|
|
42
|
-
"
|
|
45
|
+
"source": "./src/public/*.ts",
|
|
43
46
|
"default": "./lib/public/*.js"
|
|
44
47
|
},
|
|
45
48
|
".": {
|
|
46
|
-
"
|
|
49
|
+
"source": "./src/public/index.ts",
|
|
47
50
|
"default": "./lib/public/index.js"
|
|
48
51
|
}
|
|
49
52
|
},
|
|
50
53
|
"scripts": {
|
|
51
54
|
"build": "npm run clean && tsc",
|
|
52
|
-
"test": "
|
|
55
|
+
"test": "npx proby",
|
|
53
56
|
"clean": "rm -rf ./lib"
|
|
54
57
|
}
|
|
55
58
|
}
|