@zudojs/testing 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/dist/assertions/deepEqual.core.js +33 -3
- package/dist/assertions/errorAssertions.core.js +15 -1
- 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 +6 -1
- package/package.json +19 -15
package/README.md
CHANGED
|
@@ -66,8 +66,14 @@ assertResponseBody(response, { id: "u_1", roles: new Set(["admin"]) });
|
|
|
66
66
|
- `cleanup.dispose()` rejects with an `AggregateError` when any cleanup fails,
|
|
67
67
|
after running them all.
|
|
68
68
|
- `mockResolvedValue` and `mockRejectedValue` return promises; `results` stays
|
|
69
|
-
aligned index-for-index with `calls
|
|
69
|
+
aligned index-for-index with `calls`, even when the implementation throws
|
|
70
|
+
(the slot holds `undefined` and the thrown value lands in `errors`).
|
|
70
71
|
- Spies forward their receiver, so a method reading `this` still works.
|
|
72
|
+
- `assertThrows` is for synchronous code. Handing it an async function throws
|
|
73
|
+
"use assertRejects" instead of a misleading "did not throw", and the
|
|
74
|
+
rejection is handled rather than leaked.
|
|
75
|
+
- `findByMetadata` and the structural assertions compare `Set` members and
|
|
76
|
+
`Map` keys by value, so `new Set([{ id: 1 }])` matches `new Set([{ id: 1 }])`.
|
|
71
77
|
|
|
72
78
|
## Use Cases
|
|
73
79
|
|
|
@@ -87,11 +87,18 @@ function diff(actual, expected, path, seen) {
|
|
|
87
87
|
reason: `expected ${expected.size} entries, received ${actual.size}`,
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
+
// Keys are matched by identity first and structurally second, so a
|
|
91
|
+
// Map keyed by objects compares by value like everything else here.
|
|
92
|
+
const unmatched = [...actual.keys()];
|
|
90
93
|
for (const [key, value] of expected) {
|
|
91
|
-
|
|
94
|
+
const actualKey = actual.has(key)
|
|
95
|
+
? key
|
|
96
|
+
: findStructuralMatch(unmatched, key, seen);
|
|
97
|
+
if (actualKey === NO_MATCH) {
|
|
92
98
|
return { path, reason: `missing key ${describeValue(key)}` };
|
|
93
99
|
}
|
|
94
|
-
|
|
100
|
+
unmatched.splice(unmatched.indexOf(actualKey), 1);
|
|
101
|
+
const found = diff(actual.get(actualKey), value, `${path}[${describeValue(key)}]`, seen);
|
|
95
102
|
if (found)
|
|
96
103
|
return found;
|
|
97
104
|
}
|
|
@@ -104,10 +111,18 @@ function diff(actual, expected, path, seen) {
|
|
|
104
111
|
reason: `expected ${expected.size} items, received ${actual.size}`,
|
|
105
112
|
};
|
|
106
113
|
}
|
|
114
|
+
// `Set.has` is identity-based, so two Sets holding equal but distinct
|
|
115
|
+
// objects compared unequal. Each expected entry is matched against a
|
|
116
|
+
// still-unmatched actual entry structurally instead.
|
|
117
|
+
const unmatched = [...actual];
|
|
107
118
|
for (const entry of expected) {
|
|
108
|
-
|
|
119
|
+
const match = actual.has(entry)
|
|
120
|
+
? entry
|
|
121
|
+
: findStructuralMatch(unmatched, entry, seen);
|
|
122
|
+
if (match === NO_MATCH) {
|
|
109
123
|
return { path, reason: `missing item ${describeValue(entry)}` };
|
|
110
124
|
}
|
|
125
|
+
unmatched.splice(unmatched.indexOf(match), 1);
|
|
111
126
|
}
|
|
112
127
|
return undefined;
|
|
113
128
|
}
|
|
@@ -146,6 +161,21 @@ function diff(actual, expected, path, seen) {
|
|
|
146
161
|
}
|
|
147
162
|
return undefined;
|
|
148
163
|
}
|
|
164
|
+
const NO_MATCH = Symbol("deepEqual.noMatch");
|
|
165
|
+
/**
|
|
166
|
+
* Finds a candidate structurally equal to `expected`. Each probe walks a
|
|
167
|
+
* copy of `seen`: the cycle guard records a pair as "assumed equal" before
|
|
168
|
+
* comparing it, and a probe that fails must not leave that assumption
|
|
169
|
+
* behind for the next candidate.
|
|
170
|
+
*/
|
|
171
|
+
function findStructuralMatch(candidates, expected, seen) {
|
|
172
|
+
for (const candidate of candidates) {
|
|
173
|
+
if (diff(candidate, expected, "", new Map(seen)) === undefined) {
|
|
174
|
+
return candidate;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return NO_MATCH;
|
|
178
|
+
}
|
|
149
179
|
/**
|
|
150
180
|
* Find the first structural difference between two values.
|
|
151
181
|
*
|
|
@@ -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
|
*
|
|
@@ -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.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 = () => {
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import { JSONSerializer } from "@zudojs/serialization";
|
|
7
7
|
import { findDifference } from "../assertions/deepEqual.core.js";
|
|
8
|
+
import { describeValue } from "../assertions/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.0",
|
|
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,21 @@
|
|
|
19
19
|
"!dist/.tsbuildinfo"
|
|
20
20
|
],
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@zudojs/config": "1.0.
|
|
23
|
-
"@zudojs/constants": "1.0.
|
|
24
|
-
"@zudojs/container": "1.
|
|
25
|
-
"@zudojs/errors": "1.0.
|
|
26
|
-
"@zudojs/events": "1.0.
|
|
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.0.
|
|
33
|
-
"@zudojs/serialization": "1.0.
|
|
34
|
-
"@zudojs/storage": "1.
|
|
22
|
+
"@zudojs/config": "1.0.1",
|
|
23
|
+
"@zudojs/constants": "1.0.1",
|
|
24
|
+
"@zudojs/container": "1.1.0",
|
|
25
|
+
"@zudojs/errors": "1.0.1",
|
|
26
|
+
"@zudojs/events": "1.0.1",
|
|
27
|
+
"@zudojs/http": "1.1.0",
|
|
28
|
+
"@zudojs/logger": "1.1.0",
|
|
29
|
+
"@zudojs/messaging": "1.0.1",
|
|
30
|
+
"@zudojs/middleware": "1.0.1",
|
|
31
|
+
"@zudojs/queue": "1.1.0",
|
|
32
|
+
"@zudojs/security": "1.0.1",
|
|
33
|
+
"@zudojs/serialization": "1.0.1",
|
|
34
|
+
"@zudojs/storage": "1.1.0",
|
|
35
35
|
"@zudojs/types": "1.0.0",
|
|
36
|
-
"@zudojs/validation": "1.0.
|
|
36
|
+
"@zudojs/validation": "1.0.1"
|
|
37
37
|
},
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"typescript": "7.0.2",
|
|
@@ -43,6 +43,10 @@
|
|
|
43
43
|
"node": ">=24.0.0"
|
|
44
44
|
},
|
|
45
45
|
"license": "MIT",
|
|
46
|
+
"author": {
|
|
47
|
+
"name": "Oluwayemi Oyinlola",
|
|
48
|
+
"url": "https://github.com/oyinlola-tech"
|
|
49
|
+
},
|
|
46
50
|
"publishConfig": {
|
|
47
51
|
"access": "public"
|
|
48
52
|
},
|