@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 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
- if (!actual.has(key)) {
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
- const found = diff(actual.get(key), value, `${path}[${describeValue(key)}]`, seen);
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
- if (!actual.has(entry)) {
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 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.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 = () => {
@@ -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
- 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.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.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",
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.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
  },