@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 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
- if (actual.size !== expected.size) {
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
- if (actual.size !== expected.size) {
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,7 +3,7 @@
3
3
  *
4
4
  * Assert events, event types, and event payloads.
5
5
  */
6
- import { findDifference } from "./deepEqual.core.js";
6
+ import { findDifference } from "./deepEqual/deepEqual.core.js";
7
7
  /**
8
8
  * Asserts that an event has a specific type.
9
9
  *
@@ -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.core.js";
8
- export { describeValue } from "./deepEqual.describe.js";
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
@@ -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.core.js";
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 dispose = async () => {
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
- /** Result of each call, aligned index-for-index with {@link calls}. */
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
- const result = produce(args);
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
- /** Structural comparison, so object metadata can actually be matched. */
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
- /** Structural comparison, so object metadata can actually be matched. */
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
- if (Object.is(actual, expected))
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: (options) => createRecordingLogger(recorder, {
63
- name: `${derived.name}.${options?.name ?? "child"}`,
64
- ...(derived.context ? { context: derived.context } : {}),
65
- }, level, enabled),
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
- throw new Error(`Type preservation failed for ${description}: ${JSON.stringify(value)} → ${JSON.stringify(restored)}`);
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.0.0",
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.0.0",
23
- "@zudojs/constants": "1.0.0",
24
- "@zudojs/container": "1.0.0",
25
- "@zudojs/errors": "1.0.0",
26
- "@zudojs/events": "1.0.0",
27
- "@zudojs/http": "1.0.0",
28
- "@zudojs/logger": "1.0.0",
29
- "@zudojs/messaging": "1.0.0",
30
- "@zudojs/middleware": "1.0.0",
31
- "@zudojs/queue": "1.0.0",
32
- "@zudojs/security": "1.0.0",
33
- "@zudojs/serialization": "1.0.0",
34
- "@zudojs/storage": "1.0.0",
35
- "@zudojs/types": "1.0.0",
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
  },