@zudojs/testing 1.0.0 → 1.1.1
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 +20 -1
- package/dist/assertions/deepEqual/deepEqual.collections.d.ts +24 -0
- package/dist/assertions/deepEqual/deepEqual.collections.js +81 -0
- package/dist/assertions/{deepEqual.core.js → deepEqual/deepEqual.core.js} +13 -27
- package/dist/assertions/deepEqual/deepEqual.identity.d.ts +33 -0
- package/dist/assertions/deepEqual/deepEqual.identity.js +101 -0
- package/dist/assertions/deepEqual/index.d.ts +13 -0
- package/dist/assertions/deepEqual/index.js +12 -0
- package/dist/assertions/errorAssertions.core.js +17 -3
- package/dist/assertions/eventAssertions.core.js +1 -1
- package/dist/assertions/httpAssertions.core.js +2 -2
- package/dist/assertions/index.d.ts +2 -3
- package/dist/assertions/index.js +1 -2
- package/dist/cleanupManager/cleanupManager.core.js +19 -5
- package/dist/mocking/mockFn.core.d.ts +7 -1
- package/dist/mocking/mockFn.core.js +18 -1
- package/dist/spyLogger/spyLogger.context.d.ts +9 -1
- package/dist/spyLogger/spyLogger.context.js +11 -8
- package/dist/spyLogger/spyLogger.recording.js +15 -4
- package/dist/testClock/testClock.core.js +5 -0
- package/dist/testSerialization/serializationAssertions.core.js +7 -2
- package/package.json +19 -16
- /package/dist/assertions/{deepEqual.core.d.ts → deepEqual/deepEqual.core.d.ts} +0 -0
- /package/dist/assertions/{deepEqual.describe.d.ts → deepEqual/deepEqual.describe.d.ts} +0 -0
- /package/dist/assertions/{deepEqual.describe.js → deepEqual/deepEqual.describe.js} +0 -0
package/README.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
Test helpers, fixtures, mocks, and assertions for Zudojs applications.
|
|
4
4
|
|
|
5
|
+
<!-- zudo-docs:start -->
|
|
6
|
+
|
|
7
|
+
**Documentation:** [zudojs.oyinlola.site/docs/packages-testing](https://zudojs.oyinlola.site/docs/packages-testing) · **For AI agents:** [Markdown version](https://zudojs.oyinlola.site/docs/packages-testing.md), [llms.txt](https://zudojs.oyinlola.site/llms.txt)
|
|
8
|
+
|
|
9
|
+
<!-- zudo-docs:end -->
|
|
10
|
+
|
|
5
11
|
## Installation
|
|
6
12
|
|
|
7
13
|
```bash
|
|
@@ -61,13 +67,26 @@ assertResponseBody(response, { id: "u_1", roles: new Set(["admin"]) });
|
|
|
61
67
|
- Assertions compare structurally, not by `JSON.stringify`. `Map`, `Set`,
|
|
62
68
|
`Date`, `BigInt`, `undefined` values and key order are all handled, and a
|
|
63
69
|
circular value reports a mismatch instead of throwing a `TypeError`.
|
|
70
|
+
- Types are compared too: two objects must share a prototype (a class
|
|
71
|
+
instance never equals a plain object or an instance of another class;
|
|
72
|
+
`{}` and `Object.create(null)` count as the same), Errors must match on
|
|
73
|
+
`name`, `message` and `cause`, boxed primitives on their value, and typed
|
|
74
|
+
arrays on their constructor. Distinct Promises, WeakMaps and WeakSets are
|
|
75
|
+
never equal. A serializer that turns an Error into `{}` fails
|
|
76
|
+
`assertSerializesCorrectly`.
|
|
64
77
|
- `createStub()` answers `then` with `undefined`, so awaiting a stub — or
|
|
65
78
|
returning one from an async factory — resolves rather than hanging.
|
|
66
79
|
- `cleanup.dispose()` rejects with an `AggregateError` when any cleanup fails,
|
|
67
80
|
after running them all.
|
|
68
81
|
- `mockResolvedValue` and `mockRejectedValue` return promises; `results` stays
|
|
69
|
-
aligned index-for-index with `calls
|
|
82
|
+
aligned index-for-index with `calls`, even when the implementation throws
|
|
83
|
+
(the slot holds `undefined` and the thrown value lands in `errors`).
|
|
70
84
|
- Spies forward their receiver, so a method reading `this` still works.
|
|
85
|
+
- `assertThrows` is for synchronous code. Handing it an async function throws
|
|
86
|
+
"use assertRejects" instead of a misleading "did not throw", and the
|
|
87
|
+
rejection is handled rather than leaked.
|
|
88
|
+
- `findByMetadata` and the structural assertions compare `Set` members and
|
|
89
|
+
`Map` keys by value, so `new Set([{ id: 1 }])` matches `new Set([{ id: 1 }])`.
|
|
71
90
|
|
|
72
91
|
## Use Cases
|
|
73
92
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/testing — Map and Set comparison for the structural walk.
|
|
3
|
+
*
|
|
4
|
+
* `Map.has`/`Set.has` are identity-based, so collections holding equal but
|
|
5
|
+
* distinct objects would compare unequal. Each expected entry is matched
|
|
6
|
+
* against a still-unmatched actual entry: by identity when that entry is
|
|
7
|
+
* still unmatched, structurally otherwise. The identity shortcut only applies
|
|
8
|
+
* to entries not yet consumed; taking it for a consumed entry made
|
|
9
|
+
* `indexOf` return -1 and `splice(-1, 1)` drop an unrelated entry.
|
|
10
|
+
*
|
|
11
|
+
* @module assertions/deepEqual.collections
|
|
12
|
+
*/
|
|
13
|
+
import type { Difference } from "./deepEqual.describe.js";
|
|
14
|
+
/** The recursive walker, passed in to avoid a module cycle. */
|
|
15
|
+
export type DiffWalker = (actual: unknown, expected: unknown, path: string, seen: Map<object, object>) => Difference | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Compares two Maps. A key matched structurally prefers the actual key whose
|
|
18
|
+
* value also matches, and falls back to a key-only match so the value
|
|
19
|
+
* difference is what gets reported.
|
|
20
|
+
*/
|
|
21
|
+
export declare function diffMap(actual: Map<unknown, unknown>, expected: Map<unknown, unknown>, path: string, seen: Map<object, object>, walk: DiffWalker): Difference | undefined;
|
|
22
|
+
/** Compares two Sets, matching each expected item to one unmatched actual item. */
|
|
23
|
+
export declare function diffSet(actual: Set<unknown>, expected: Set<unknown>, path: string, seen: Map<object, object>, walk: DiffWalker): Difference | undefined;
|
|
24
|
+
//# sourceMappingURL=deepEqual.collections.d.ts.map
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/testing — Map and Set comparison for the structural walk.
|
|
3
|
+
*
|
|
4
|
+
* `Map.has`/`Set.has` are identity-based, so collections holding equal but
|
|
5
|
+
* distinct objects would compare unequal. Each expected entry is matched
|
|
6
|
+
* against a still-unmatched actual entry: by identity when that entry is
|
|
7
|
+
* still unmatched, structurally otherwise. The identity shortcut only applies
|
|
8
|
+
* to entries not yet consumed; taking it for a consumed entry made
|
|
9
|
+
* `indexOf` return -1 and `splice(-1, 1)` drop an unrelated entry.
|
|
10
|
+
*
|
|
11
|
+
* @module assertions/deepEqual.collections
|
|
12
|
+
*/
|
|
13
|
+
import { describeValue } from "./deepEqual.describe.js";
|
|
14
|
+
const NO_MATCH = Symbol("deepEqual.noMatch");
|
|
15
|
+
/**
|
|
16
|
+
* Finds a candidate structurally equal to `expected`. Each probe walks a
|
|
17
|
+
* copy of `seen`: the cycle guard records a pair as "assumed equal" before
|
|
18
|
+
* comparing it, and a probe that fails must not leave that assumption
|
|
19
|
+
* behind for the next candidate.
|
|
20
|
+
*/
|
|
21
|
+
function findStructuralMatch(walk, candidates, expected, seen, accept = () => true) {
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
if (walk(candidate, expected, "", new Map(seen)) === undefined &&
|
|
24
|
+
accept(candidate)) {
|
|
25
|
+
return candidate;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return NO_MATCH;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Compares two Maps. A key matched structurally prefers the actual key whose
|
|
32
|
+
* value also matches, and falls back to a key-only match so the value
|
|
33
|
+
* difference is what gets reported.
|
|
34
|
+
*/
|
|
35
|
+
export function diffMap(actual, expected, path, seen, walk) {
|
|
36
|
+
if (actual.size !== expected.size) {
|
|
37
|
+
return {
|
|
38
|
+
path,
|
|
39
|
+
reason: `expected ${expected.size} entries, received ${actual.size}`,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const unmatched = [...actual.keys()];
|
|
43
|
+
for (const [key, value] of expected) {
|
|
44
|
+
let actualKey = key;
|
|
45
|
+
if (!unmatched.includes(key)) {
|
|
46
|
+
actualKey = findStructuralMatch(walk, unmatched, key, seen, (candidate) => walk(actual.get(candidate), value, "", new Map(seen)) === undefined);
|
|
47
|
+
if (actualKey === NO_MATCH) {
|
|
48
|
+
actualKey = findStructuralMatch(walk, unmatched, key, seen);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (actualKey === NO_MATCH) {
|
|
52
|
+
return { path, reason: `missing key ${describeValue(key)}` };
|
|
53
|
+
}
|
|
54
|
+
unmatched.splice(unmatched.indexOf(actualKey), 1);
|
|
55
|
+
const found = walk(actual.get(actualKey), value, `${path}[${describeValue(key)}]`, seen);
|
|
56
|
+
if (found)
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
59
|
+
return undefined;
|
|
60
|
+
}
|
|
61
|
+
/** Compares two Sets, matching each expected item to one unmatched actual item. */
|
|
62
|
+
export function diffSet(actual, expected, path, seen, walk) {
|
|
63
|
+
if (actual.size !== expected.size) {
|
|
64
|
+
return {
|
|
65
|
+
path,
|
|
66
|
+
reason: `expected ${expected.size} items, received ${actual.size}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
const unmatched = [...actual];
|
|
70
|
+
for (const entry of expected) {
|
|
71
|
+
const match = unmatched.includes(entry)
|
|
72
|
+
? entry
|
|
73
|
+
: findStructuralMatch(walk, unmatched, entry, seen);
|
|
74
|
+
if (match === NO_MATCH) {
|
|
75
|
+
return { path, reason: `missing item ${describeValue(entry)}` };
|
|
76
|
+
}
|
|
77
|
+
unmatched.splice(unmatched.indexOf(match), 1);
|
|
78
|
+
}
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
//# sourceMappingURL=deepEqual.collections.js.map
|
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
* @module assertions/deepEqual
|
|
12
12
|
*/
|
|
13
13
|
import { describeValue } from "./deepEqual.describe.js";
|
|
14
|
+
import { diffMap, diffSet } from "./deepEqual.collections.js";
|
|
15
|
+
import { compareIdentity, errorCauses } from "./deepEqual.identity.js";
|
|
14
16
|
/** The constructor-level kind of a value, used to reject cross-type matches. */
|
|
15
17
|
function kindOf(value) {
|
|
16
18
|
if (value === null)
|
|
@@ -60,12 +62,21 @@ function diff(actual, expected, path, seen) {
|
|
|
60
62
|
reason: `expected ${describeValue(expected)}, received ${describeValue(actual)}`,
|
|
61
63
|
};
|
|
62
64
|
}
|
|
65
|
+
const mismatch = compareIdentity(actual, expected);
|
|
66
|
+
if (mismatch)
|
|
67
|
+
return { path, reason: mismatch };
|
|
63
68
|
// Guard against cycles: a pair already being compared is assumed equal
|
|
64
69
|
// until proven otherwise elsewhere in the walk.
|
|
65
70
|
const previous = seen.get(actual);
|
|
66
71
|
if (previous === expected)
|
|
67
72
|
return undefined;
|
|
68
73
|
seen.set(actual, expected);
|
|
74
|
+
const causes = errorCauses(actual, expected);
|
|
75
|
+
if (causes) {
|
|
76
|
+
const found = diff(causes.actual, causes.expected, `${path}.cause`, seen);
|
|
77
|
+
if (found)
|
|
78
|
+
return found;
|
|
79
|
+
}
|
|
69
80
|
if (Array.isArray(actual) && Array.isArray(expected)) {
|
|
70
81
|
if (actual.length !== expected.length) {
|
|
71
82
|
return {
|
|
@@ -81,35 +92,10 @@ function diff(actual, expected, path, seen) {
|
|
|
81
92
|
return undefined;
|
|
82
93
|
}
|
|
83
94
|
if (actual instanceof Map && expected instanceof Map) {
|
|
84
|
-
|
|
85
|
-
return {
|
|
86
|
-
path,
|
|
87
|
-
reason: `expected ${expected.size} entries, received ${actual.size}`,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
for (const [key, value] of expected) {
|
|
91
|
-
if (!actual.has(key)) {
|
|
92
|
-
return { path, reason: `missing key ${describeValue(key)}` };
|
|
93
|
-
}
|
|
94
|
-
const found = diff(actual.get(key), value, `${path}[${describeValue(key)}]`, seen);
|
|
95
|
-
if (found)
|
|
96
|
-
return found;
|
|
97
|
-
}
|
|
98
|
-
return undefined;
|
|
95
|
+
return diffMap(actual, expected, path, seen, diff);
|
|
99
96
|
}
|
|
100
97
|
if (actual instanceof Set && expected instanceof Set) {
|
|
101
|
-
|
|
102
|
-
return {
|
|
103
|
-
path,
|
|
104
|
-
reason: `expected ${expected.size} items, received ${actual.size}`,
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
for (const entry of expected) {
|
|
108
|
-
if (!actual.has(entry)) {
|
|
109
|
-
return { path, reason: `missing item ${describeValue(entry)}` };
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
return undefined;
|
|
98
|
+
return diffSet(actual, expected, path, seen, diff);
|
|
113
99
|
}
|
|
114
100
|
if (ArrayBuffer.isView(actual) && ArrayBuffer.isView(expected)) {
|
|
115
101
|
const a = new Uint8Array(actual.buffer, actual.byteOffset, actual.byteLength);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/testing — Identity checks that run before the structural walk.
|
|
3
|
+
*
|
|
4
|
+
* The structural walk compares own enumerable keys. That alone treats every
|
|
5
|
+
* Error as `{}` (`message`, `name` and `stack` are not enumerable), a class
|
|
6
|
+
* instance as a plain object, `new Number(1)` as `new Number(2)`, and a
|
|
7
|
+
* `Uint8Array` as any typed array with the same bytes. These checks reject
|
|
8
|
+
* those pairs first, so an assertion built on `deepEqual` can fail when the
|
|
9
|
+
* value lost its type or its payload.
|
|
10
|
+
*
|
|
11
|
+
* @module assertions/deepEqual.identity
|
|
12
|
+
*/
|
|
13
|
+
/** A mismatch reason, or undefined when the pair passes this check. */
|
|
14
|
+
export type IdentityMismatch = string | undefined;
|
|
15
|
+
/**
|
|
16
|
+
* Compares the parts of two objects that are invisible to a key walk:
|
|
17
|
+
* prototype, boxed value, Error name/message/cause, typed-array constructor,
|
|
18
|
+
* and the unobservable contents of Promises and weak collections.
|
|
19
|
+
*
|
|
20
|
+
* @param actual - The observed object.
|
|
21
|
+
* @param expected - The object it should equal.
|
|
22
|
+
* @returns A mismatch reason, or undefined when the pair may be walked.
|
|
23
|
+
*/
|
|
24
|
+
export declare function compareIdentity(actual: object, expected: object): IdentityMismatch;
|
|
25
|
+
/**
|
|
26
|
+
* The Error `cause`, when either side has one. `cause` is an own
|
|
27
|
+
* non-enumerable property, so the key walk never reaches it.
|
|
28
|
+
*/
|
|
29
|
+
export declare function errorCauses(actual: object, expected: object): {
|
|
30
|
+
readonly actual: unknown;
|
|
31
|
+
readonly expected: unknown;
|
|
32
|
+
} | undefined;
|
|
33
|
+
//# sourceMappingURL=deepEqual.identity.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/testing — Identity checks that run before the structural walk.
|
|
3
|
+
*
|
|
4
|
+
* The structural walk compares own enumerable keys. That alone treats every
|
|
5
|
+
* Error as `{}` (`message`, `name` and `stack` are not enumerable), a class
|
|
6
|
+
* instance as a plain object, `new Number(1)` as `new Number(2)`, and a
|
|
7
|
+
* `Uint8Array` as any typed array with the same bytes. These checks reject
|
|
8
|
+
* those pairs first, so an assertion built on `deepEqual` can fail when the
|
|
9
|
+
* value lost its type or its payload.
|
|
10
|
+
*
|
|
11
|
+
* @module assertions/deepEqual.identity
|
|
12
|
+
*/
|
|
13
|
+
import { describeValue } from "./deepEqual.describe.js";
|
|
14
|
+
/** Whether a prototype marks a plain record (`{}` or `Object.create(null)`). */
|
|
15
|
+
function isPlainPrototype(proto) {
|
|
16
|
+
return proto === null || proto === Object.prototype;
|
|
17
|
+
}
|
|
18
|
+
/** A readable name for the prototype of a value. */
|
|
19
|
+
function prototypeName(value) {
|
|
20
|
+
const proto = Object.getPrototypeOf(value);
|
|
21
|
+
if (proto === null)
|
|
22
|
+
return "null-prototype object";
|
|
23
|
+
const ctor = proto.constructor;
|
|
24
|
+
return ctor?.name ? ctor.name : "anonymous prototype";
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Boxed primitives (`new Number(1)`, `Object(1n)`) carry their value in an
|
|
28
|
+
* internal slot, not in an own key. Returns the unboxed value, or a sentinel
|
|
29
|
+
* when `value` is not boxed.
|
|
30
|
+
*/
|
|
31
|
+
function unbox(value) {
|
|
32
|
+
const tag = Object.prototype.toString.call(value);
|
|
33
|
+
switch (tag) {
|
|
34
|
+
case "[object Number]":
|
|
35
|
+
case "[object String]":
|
|
36
|
+
case "[object Boolean]":
|
|
37
|
+
case "[object BigInt]":
|
|
38
|
+
case "[object Symbol]":
|
|
39
|
+
return {
|
|
40
|
+
boxed: true,
|
|
41
|
+
value: value.valueOf(),
|
|
42
|
+
};
|
|
43
|
+
default:
|
|
44
|
+
return { boxed: false };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Values whose contents cannot be observed, so only identity can make them equal. */
|
|
48
|
+
function isOpaque(value) {
|
|
49
|
+
return (value instanceof Promise ||
|
|
50
|
+
value instanceof WeakMap ||
|
|
51
|
+
value instanceof WeakSet ||
|
|
52
|
+
(typeof WeakRef !== "undefined" && value instanceof WeakRef));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Compares the parts of two objects that are invisible to a key walk:
|
|
56
|
+
* prototype, boxed value, Error name/message/cause, typed-array constructor,
|
|
57
|
+
* and the unobservable contents of Promises and weak collections.
|
|
58
|
+
*
|
|
59
|
+
* @param actual - The observed object.
|
|
60
|
+
* @param expected - The object it should equal.
|
|
61
|
+
* @returns A mismatch reason, or undefined when the pair may be walked.
|
|
62
|
+
*/
|
|
63
|
+
export function compareIdentity(actual, expected) {
|
|
64
|
+
const actualProto = Object.getPrototypeOf(actual);
|
|
65
|
+
const expectedProto = Object.getPrototypeOf(expected);
|
|
66
|
+
const bothPlain = isPlainPrototype(actualProto) && isPlainPrototype(expectedProto);
|
|
67
|
+
if (!bothPlain && actualProto !== expectedProto) {
|
|
68
|
+
return `expected instance of ${prototypeName(expected)}, received ${prototypeName(actual)}`;
|
|
69
|
+
}
|
|
70
|
+
if (isOpaque(expected)) {
|
|
71
|
+
return `expected the same ${prototypeName(expected)} instance, received a different one`;
|
|
72
|
+
}
|
|
73
|
+
const actualBox = unbox(actual);
|
|
74
|
+
const expectedBox = unbox(expected);
|
|
75
|
+
if (actualBox.boxed || expectedBox.boxed) {
|
|
76
|
+
if (!Object.is(actualBox.value, expectedBox.value)) {
|
|
77
|
+
return `expected boxed ${describeValue(expectedBox.value)}, received boxed ${describeValue(actualBox.value)}`;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (actual instanceof Error && expected instanceof Error) {
|
|
81
|
+
if (actual.name !== expected.name) {
|
|
82
|
+
return `expected error name ${describeValue(expected.name)}, received ${describeValue(actual.name)}`;
|
|
83
|
+
}
|
|
84
|
+
if (actual.message !== expected.message) {
|
|
85
|
+
return `expected error message ${describeValue(expected.message)}, received ${describeValue(actual.message)}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return undefined;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The Error `cause`, when either side has one. `cause` is an own
|
|
92
|
+
* non-enumerable property, so the key walk never reaches it.
|
|
93
|
+
*/
|
|
94
|
+
export function errorCauses(actual, expected) {
|
|
95
|
+
if (!(actual instanceof Error) || !(expected instanceof Error))
|
|
96
|
+
return undefined;
|
|
97
|
+
if (!("cause" in actual) && !("cause" in expected))
|
|
98
|
+
return undefined;
|
|
99
|
+
return { actual: actual.cause, expected: expected.cause };
|
|
100
|
+
}
|
|
101
|
+
//# sourceMappingURL=deepEqual.identity.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/testing — Structural equality barrel.
|
|
3
|
+
*
|
|
4
|
+
* `deepEqual`/`findDifference` compare values structurally, including Maps,
|
|
5
|
+
* Sets, typed arrays, Errors, boxed primitives and class instances (by
|
|
6
|
+
* prototype). `describeValue` renders values for assertion messages.
|
|
7
|
+
*
|
|
8
|
+
* @module assertions/deepEqual
|
|
9
|
+
*/
|
|
10
|
+
export { deepEqual, findDifference } from "./deepEqual.core.js";
|
|
11
|
+
export { describeValue } from "./deepEqual.describe.js";
|
|
12
|
+
export type { Difference } from "./deepEqual.describe.js";
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @zudojs/testing — Structural equality barrel.
|
|
3
|
+
*
|
|
4
|
+
* `deepEqual`/`findDifference` compare values structurally, including Maps,
|
|
5
|
+
* Sets, typed arrays, Errors, boxed primitives and class instances (by
|
|
6
|
+
* prototype). `describeValue` renders values for assertion messages.
|
|
7
|
+
*
|
|
8
|
+
* @module assertions/deepEqual
|
|
9
|
+
*/
|
|
10
|
+
export { deepEqual, findDifference } from "./deepEqual.core.js";
|
|
11
|
+
export { describeValue } from "./deepEqual.describe.js";
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Assert errors, error types, and error messages.
|
|
5
5
|
*/
|
|
6
|
-
import { findDifference } from "./deepEqual.core.js";
|
|
7
|
-
import { describeValue } from "./deepEqual.describe.js";
|
|
6
|
+
import { findDifference } from "./deepEqual/deepEqual.core.js";
|
|
7
|
+
import { describeValue } from "./deepEqual/deepEqual.describe.js";
|
|
8
8
|
/**
|
|
9
9
|
* Asserts that a function throws an error.
|
|
10
10
|
*
|
|
@@ -13,8 +13,9 @@ import { describeValue } from "./deepEqual.describe.js";
|
|
|
13
13
|
* @returns The thrown error.
|
|
14
14
|
*/
|
|
15
15
|
export function assertThrows(fn, expectedMessage) {
|
|
16
|
+
let returned;
|
|
16
17
|
try {
|
|
17
|
-
fn();
|
|
18
|
+
returned = fn();
|
|
18
19
|
}
|
|
19
20
|
catch (error) {
|
|
20
21
|
if (expectedMessage !== undefined) {
|
|
@@ -25,8 +26,21 @@ export function assertThrows(fn, expectedMessage) {
|
|
|
25
26
|
}
|
|
26
27
|
return error instanceof Error ? error : new Error(String(error));
|
|
27
28
|
}
|
|
29
|
+
if (isThenable(returned)) {
|
|
30
|
+
// An async function never throws synchronously; its failure is a
|
|
31
|
+
// rejection. Reporting "did not throw" was misleading, and the
|
|
32
|
+
// rejected promise nobody awaited surfaced as an unhandled rejection
|
|
33
|
+
// blamed on whichever test happened to be running.
|
|
34
|
+
void returned.then(undefined, () => undefined);
|
|
35
|
+
throw new Error("assertThrows received a function that returned a promise; use assertRejects for async functions.");
|
|
36
|
+
}
|
|
28
37
|
throw new Error("Expected function to throw, but it did not.");
|
|
29
38
|
}
|
|
39
|
+
function isThenable(value) {
|
|
40
|
+
return (value !== null &&
|
|
41
|
+
(typeof value === "object" || typeof value === "function") &&
|
|
42
|
+
typeof value.then === "function");
|
|
43
|
+
}
|
|
30
44
|
/**
|
|
31
45
|
* Asserts that an async function rejects.
|
|
32
46
|
*
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Assert HTTP responses, status codes, headers, and bodies.
|
|
5
5
|
*/
|
|
6
|
-
import { findDifference } from "./deepEqual.core.js";
|
|
7
|
-
import { describeValue } from "./deepEqual.describe.js";
|
|
6
|
+
import { findDifference } from "./deepEqual/deepEqual.core.js";
|
|
7
|
+
import { describeValue } from "./deepEqual/deepEqual.describe.js";
|
|
8
8
|
/**
|
|
9
9
|
* Asserts that a response has a specific status code.
|
|
10
10
|
*
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
export { assertBadRequest, assertCreated, assertNoContent, assertNotFound, assertOK, assertResponseBody, assertResponseBodyContains, assertResponseHeader, assertResponseStatus, assertServerError, } from "./httpAssertions.core.js";
|
|
5
5
|
export { assertEventNotPublished, assertEventPayload, assertEventPublished, assertEventType, assertMessageDispatched, assertMessageNotDispatched, assertRecordedEventType, } from "./eventAssertions.core.js";
|
|
6
6
|
export { assertErrorCode, assertErrorMetadata, assertErrorType, assertRejects, assertThrows, } from "./errorAssertions.core.js";
|
|
7
|
-
export { deepEqual, findDifference } from "./deepEqual.
|
|
8
|
-
export {
|
|
9
|
-
export type { Difference } from "./deepEqual.describe.js";
|
|
7
|
+
export { deepEqual, describeValue, findDifference } from "./deepEqual/index.js";
|
|
8
|
+
export type { Difference } from "./deepEqual/index.js";
|
|
10
9
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/assertions/index.js
CHANGED
|
@@ -4,6 +4,5 @@
|
|
|
4
4
|
export { assertBadRequest, assertCreated, assertNoContent, assertNotFound, assertOK, assertResponseBody, assertResponseBodyContains, assertResponseHeader, assertResponseStatus, assertServerError, } from "./httpAssertions.core.js";
|
|
5
5
|
export { assertEventNotPublished, assertEventPayload, assertEventPublished, assertEventType, assertMessageDispatched, assertMessageNotDispatched, assertRecordedEventType, } from "./eventAssertions.core.js";
|
|
6
6
|
export { assertErrorCode, assertErrorMetadata, assertErrorType, assertRejects, assertThrows, } from "./errorAssertions.core.js";
|
|
7
|
-
export { deepEqual, findDifference } from "./deepEqual.
|
|
8
|
-
export { describeValue } from "./deepEqual.describe.js";
|
|
7
|
+
export { deepEqual, describeValue, findDifference } from "./deepEqual/index.js";
|
|
9
8
|
//# sourceMappingURL=index.js.map
|
|
@@ -15,6 +15,7 @@ export function createCleanupManager(options = {}) {
|
|
|
15
15
|
const entries = [];
|
|
16
16
|
let nextId = 0;
|
|
17
17
|
let disposed = false;
|
|
18
|
+
let inFlight;
|
|
18
19
|
const register = (fn, label = `cleanup-${nextId}`) => {
|
|
19
20
|
if (disposed) {
|
|
20
21
|
throw new Error("Cannot register cleanup after manager has been disposed.");
|
|
@@ -25,11 +26,7 @@ export function createCleanupManager(options = {}) {
|
|
|
25
26
|
fn,
|
|
26
27
|
});
|
|
27
28
|
};
|
|
28
|
-
const
|
|
29
|
-
if (disposed) {
|
|
30
|
-
return;
|
|
31
|
-
}
|
|
32
|
-
disposed = true;
|
|
29
|
+
const runCleanups = async () => {
|
|
33
30
|
const errors = [];
|
|
34
31
|
const reversed = [...entries].reverse();
|
|
35
32
|
for (const entry of reversed) {
|
|
@@ -49,6 +46,23 @@ export function createCleanupManager(options = {}) {
|
|
|
49
46
|
.join(", ")}`);
|
|
50
47
|
}
|
|
51
48
|
};
|
|
49
|
+
/**
|
|
50
|
+
* Runs the cleanups once. A second call made while the first is still
|
|
51
|
+
* running shares its promise, so nobody is told "disposed" before the
|
|
52
|
+
* resources are actually released; a call after completion resolves
|
|
53
|
+
* immediately.
|
|
54
|
+
*/
|
|
55
|
+
const dispose = () => {
|
|
56
|
+
if (inFlight)
|
|
57
|
+
return inFlight;
|
|
58
|
+
if (disposed)
|
|
59
|
+
return Promise.resolve();
|
|
60
|
+
disposed = true;
|
|
61
|
+
inFlight = runCleanups().finally(() => {
|
|
62
|
+
inFlight = undefined;
|
|
63
|
+
});
|
|
64
|
+
return inFlight;
|
|
65
|
+
};
|
|
52
66
|
return {
|
|
53
67
|
get disposed() {
|
|
54
68
|
return disposed;
|
|
@@ -9,8 +9,14 @@
|
|
|
9
9
|
export interface MockFn<TArgs extends readonly unknown[] = unknown[], TResult = unknown> {
|
|
10
10
|
(...args: TArgs): TResult;
|
|
11
11
|
readonly calls: readonly TArgs[];
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Result of each call, aligned index-for-index with {@link calls}. A
|
|
14
|
+
* call whose implementation threw occupies its slot with `undefined`;
|
|
15
|
+
* the thrown value is in {@link errors}.
|
|
16
|
+
*/
|
|
13
17
|
readonly results: readonly TResult[];
|
|
18
|
+
/** Values thrown by the implementation, in call order. */
|
|
19
|
+
readonly errors: readonly unknown[];
|
|
14
20
|
readonly invoked: boolean;
|
|
15
21
|
readonly callCount: number;
|
|
16
22
|
mockReturnValue: (value: TResult) => void;
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
export function createMockFn(defaultReturnValue) {
|
|
24
24
|
const calls = [];
|
|
25
25
|
const results = [];
|
|
26
|
+
const errors = [];
|
|
26
27
|
const initialMode = arguments.length > 0
|
|
27
28
|
? { kind: "value", value: defaultReturnValue }
|
|
28
29
|
: { kind: "none" };
|
|
@@ -50,7 +51,17 @@ export function createMockFn(defaultReturnValue) {
|
|
|
50
51
|
};
|
|
51
52
|
const mock = ((...args) => {
|
|
52
53
|
calls.push(args);
|
|
53
|
-
|
|
54
|
+
let result;
|
|
55
|
+
try {
|
|
56
|
+
result = produce(args);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
// Keep `results` aligned with `calls` even when the implementation
|
|
60
|
+
// throws; otherwise every later result shifts one index left.
|
|
61
|
+
results.push(undefined);
|
|
62
|
+
errors.push(error);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
54
65
|
results.push(result);
|
|
55
66
|
return result;
|
|
56
67
|
});
|
|
@@ -62,6 +73,10 @@ export function createMockFn(defaultReturnValue) {
|
|
|
62
73
|
get: () => results,
|
|
63
74
|
enumerable: true,
|
|
64
75
|
});
|
|
76
|
+
Object.defineProperty(mock, "errors", {
|
|
77
|
+
get: () => errors,
|
|
78
|
+
enumerable: true,
|
|
79
|
+
});
|
|
65
80
|
Object.defineProperty(mock, "invoked", {
|
|
66
81
|
get: () => calls.length > 0,
|
|
67
82
|
enumerable: true,
|
|
@@ -85,11 +100,13 @@ export function createMockFn(defaultReturnValue) {
|
|
|
85
100
|
mock.mockReset = () => {
|
|
86
101
|
calls.length = 0;
|
|
87
102
|
results.length = 0;
|
|
103
|
+
errors.length = 0;
|
|
88
104
|
mode = initialMode;
|
|
89
105
|
};
|
|
90
106
|
mock.mockClear = () => {
|
|
91
107
|
calls.length = 0;
|
|
92
108
|
results.length = 0;
|
|
109
|
+
errors.length = 0;
|
|
93
110
|
};
|
|
94
111
|
return mock;
|
|
95
112
|
}
|
|
@@ -14,6 +14,14 @@ export declare function mergeLoggerContext(base: LoggerContext | undefined, next
|
|
|
14
14
|
* top level, the way a real transport would render it.
|
|
15
15
|
*/
|
|
16
16
|
export declare function mergeContext(context: LoggerContext | undefined, metadata: LogMetadata | undefined): LogMetadata | undefined;
|
|
17
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* Structural comparison, so object metadata can actually be matched.
|
|
19
|
+
*
|
|
20
|
+
* Uses the same walker as the assertions. The previous
|
|
21
|
+
* `JSON.stringify` comparison matched any two `Map`s or `Set`s (both
|
|
22
|
+
* render as `{}`), dropped `undefined` values, and treated key order as
|
|
23
|
+
* significant, so `findByMetadata` could both match what it should not
|
|
24
|
+
* and miss what it should find.
|
|
25
|
+
*/
|
|
18
26
|
export declare function deepMatches(actual: unknown, expected: unknown): boolean;
|
|
19
27
|
//# sourceMappingURL=spyLogger.context.d.ts.map
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* @module spyLogger/spyLogger.context
|
|
5
5
|
*/
|
|
6
|
+
import { deepEqual } from "../assertions/deepEqual/deepEqual.core.js";
|
|
6
7
|
/** Merges two logger contexts, preserving the nested shape. */
|
|
7
8
|
export function mergeLoggerContext(base, next) {
|
|
8
9
|
if (!base)
|
|
@@ -28,14 +29,16 @@ export function mergeContext(context, metadata) {
|
|
|
28
29
|
...metadata,
|
|
29
30
|
};
|
|
30
31
|
}
|
|
31
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* Structural comparison, so object metadata can actually be matched.
|
|
34
|
+
*
|
|
35
|
+
* Uses the same walker as the assertions. The previous
|
|
36
|
+
* `JSON.stringify` comparison matched any two `Map`s or `Set`s (both
|
|
37
|
+
* render as `{}`), dropped `undefined` values, and treated key order as
|
|
38
|
+
* significant, so `findByMetadata` could both match what it should not
|
|
39
|
+
* and miss what it should find.
|
|
40
|
+
*/
|
|
32
41
|
export function deepMatches(actual, expected) {
|
|
33
|
-
|
|
34
|
-
return true;
|
|
35
|
-
if (typeof actual !== "object" || typeof expected !== "object")
|
|
36
|
-
return false;
|
|
37
|
-
if (actual === null || expected === null)
|
|
38
|
-
return false;
|
|
39
|
-
return JSON.stringify(actual) === JSON.stringify(expected);
|
|
42
|
+
return deepEqual(actual, expected);
|
|
40
43
|
}
|
|
41
44
|
//# sourceMappingURL=spyLogger.context.js.map
|
|
@@ -59,10 +59,21 @@ export function createRecordingLogger(recorder, derived, initialLevel, initially
|
|
|
59
59
|
log: (logLevel, message, options) => {
|
|
60
60
|
record("log", logLevel, message, options?.metadata);
|
|
61
61
|
},
|
|
62
|
-
child
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
62
|
+
// A real child logger merges `options.metadata` into every entry it
|
|
63
|
+
// writes and may lower its own level; the spy mirrors both so a test
|
|
64
|
+
// can assert on `logger.child({ metadata: { module } })` output.
|
|
65
|
+
child: (options) => {
|
|
66
|
+
const context = options?.metadata
|
|
67
|
+
? mergeLoggerContext(derived.context, {
|
|
68
|
+
identifiers: {},
|
|
69
|
+
metadata: options.metadata,
|
|
70
|
+
})
|
|
71
|
+
: derived.context;
|
|
72
|
+
return createRecordingLogger(recorder, {
|
|
73
|
+
name: `${derived.name}.${options?.name ?? "child"}`,
|
|
74
|
+
...(context ? { context } : {}),
|
|
75
|
+
}, options?.level ?? level, enabled);
|
|
76
|
+
},
|
|
66
77
|
withContext: (context) => createRecordingLogger(recorder, {
|
|
67
78
|
name: derived.name,
|
|
68
79
|
context: mergeLoggerContext(derived.context, context),
|
|
@@ -53,6 +53,11 @@ export function createTestClock(initialTime) {
|
|
|
53
53
|
(duration.minutes ?? 0) * 60_000 +
|
|
54
54
|
(duration.hours ?? 0) * 3_600_000 +
|
|
55
55
|
(duration.days ?? 0) * 86_400_000;
|
|
56
|
+
// Validated like `advance`: a NaN or Infinity component silently turned
|
|
57
|
+
// the clock into `Invalid Date` and every later assertion on it lied.
|
|
58
|
+
if (!Number.isFinite(ms)) {
|
|
59
|
+
throw new TypeError(`Cannot add ${JSON.stringify(duration)} to the clock: the duration is not finite.`);
|
|
60
|
+
}
|
|
56
61
|
currentTime += ms;
|
|
57
62
|
};
|
|
58
63
|
const reset = () => {
|
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
* Helpers for testing serialization round-trips and type preservation.
|
|
5
5
|
*/
|
|
6
6
|
import { JSONSerializer } from "@zudojs/serialization";
|
|
7
|
-
import { findDifference } from "../assertions/deepEqual.core.js";
|
|
7
|
+
import { findDifference } from "../assertions/deepEqual/deepEqual.core.js";
|
|
8
|
+
import { describeValue } from "../assertions/deepEqual/deepEqual.describe.js";
|
|
8
9
|
const defaultSerializer = new JSONSerializer();
|
|
9
10
|
/**
|
|
10
11
|
* Assert that a value round-trips through serialization correctly.
|
|
@@ -66,7 +67,11 @@ export function assertTypePreservesRoundTrip(value, checker, description) {
|
|
|
66
67
|
const json = serializer.serialize(value, { preserveTypes: true });
|
|
67
68
|
const restored = serializer.deserialize(json, { preserveTypes: true });
|
|
68
69
|
if (!checker(restored)) {
|
|
69
|
-
|
|
70
|
+
// Rendered with `describeValue`, not `JSON.stringify`: the values this
|
|
71
|
+
// helper exists for (BigInt, Map, Set, circular graphs) are exactly the
|
|
72
|
+
// ones `JSON.stringify` refuses, so the failure used to surface as a
|
|
73
|
+
// TypeError from the message itself rather than as the assertion.
|
|
74
|
+
throw new Error(`Type preservation failed for ${description}: ${describeValue(value)} → ${describeValue(restored)}`);
|
|
70
75
|
}
|
|
71
76
|
}
|
|
72
77
|
//# sourceMappingURL=serializationAssertions.core.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zudojs/testing",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.1",
|
|
4
4
|
"description": "Test helpers, fixtures, mocks, and utilities for testing Zudojs applications.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -19,21 +19,20 @@
|
|
|
19
19
|
"!dist/.tsbuildinfo"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@zudojs/config": "1.
|
|
23
|
-
"@zudojs/constants": "1.
|
|
24
|
-
"@zudojs/container": "1.
|
|
25
|
-
"@zudojs/errors": "1.
|
|
26
|
-
"@zudojs/events": "1.
|
|
27
|
-
"@zudojs/http": "1.
|
|
28
|
-
"@zudojs/logger": "1.
|
|
29
|
-
"@zudojs/messaging": "1.0.
|
|
30
|
-
"@zudojs/middleware": "1.0.
|
|
31
|
-
"@zudojs/queue": "1.
|
|
32
|
-
"@zudojs/security": "1.
|
|
33
|
-
"@zudojs/serialization": "1.
|
|
34
|
-
"@zudojs/storage": "1.
|
|
35
|
-
"@zudojs/types": "1.
|
|
36
|
-
"@zudojs/validation": "1.0.0"
|
|
22
|
+
"@zudojs/config": "1.1.0",
|
|
23
|
+
"@zudojs/constants": "1.1.0",
|
|
24
|
+
"@zudojs/container": "1.1.1",
|
|
25
|
+
"@zudojs/errors": "1.1.0",
|
|
26
|
+
"@zudojs/events": "1.1.0",
|
|
27
|
+
"@zudojs/http": "1.2.0",
|
|
28
|
+
"@zudojs/logger": "1.2.0",
|
|
29
|
+
"@zudojs/messaging": "1.0.2",
|
|
30
|
+
"@zudojs/middleware": "1.0.2",
|
|
31
|
+
"@zudojs/queue": "1.2.0",
|
|
32
|
+
"@zudojs/security": "1.1.0",
|
|
33
|
+
"@zudojs/serialization": "1.1.0",
|
|
34
|
+
"@zudojs/storage": "1.1.1",
|
|
35
|
+
"@zudojs/types": "1.1.0"
|
|
37
36
|
},
|
|
38
37
|
"devDependencies": {
|
|
39
38
|
"typescript": "7.0.2",
|
|
@@ -43,6 +42,10 @@
|
|
|
43
42
|
"node": ">=24.0.0"
|
|
44
43
|
},
|
|
45
44
|
"license": "MIT",
|
|
45
|
+
"author": {
|
|
46
|
+
"name": "Oluwayemi Oyinlola",
|
|
47
|
+
"url": "https://github.com/oyinlola-tech"
|
|
48
|
+
},
|
|
46
49
|
"publishConfig": {
|
|
47
50
|
"access": "public"
|
|
48
51
|
},
|
|
File without changes
|
|
File without changes
|
|
File without changes
|