@mandujs/core 0.22.1 → 0.23.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.
@@ -0,0 +1,444 @@
1
+ /**
2
+ * @mandujs/core/testing/snapshot
3
+ *
4
+ * `toMatchSnapshot()` assertion helper for Mandu tests — mirrors the Jest
5
+ * API but without the Jest runtime. Snapshots live next to the source
6
+ * file as `__snapshots__/<basename>.snap` and are plain JSON for easy
7
+ * review under `git diff`.
8
+ *
9
+ * ## Why a custom implementation?
10
+ *
11
+ * `bun test` ships its own `toMatchSnapshot`, but it (a) stores files as
12
+ * a non-JSON textual format with newline escaping, (b) has no hook for
13
+ * output normalization, and (c) cannot be invoked imperatively outside
14
+ * of `expect()`. Mandu tests frequently capture generated code, routes
15
+ * manifests, and CLI output — all of which need deterministic scrubbing
16
+ * of timestamps, absolute paths, and random IDs before comparison.
17
+ *
18
+ * This module provides:
19
+ *
20
+ * - `matchSnapshot(value, options)` — pure function returning a
21
+ * `SnapshotResult`. No dependency on a test runner — callable from
22
+ * CLI scripts, golden-file tools, or ATE's oracle.
23
+ * - `toMatchSnapshot(value, name)` — test-binding that throws on
24
+ * mismatch. Designed to be called from `bun:test` `it()` blocks.
25
+ * - Update-in-place via `UPDATE_SNAPSHOTS=1` (or passing
26
+ * `update: true` programmatically).
27
+ *
28
+ * ## Determinism contract
29
+ *
30
+ * Comparison uses `JSON.stringify` with a **stable key ordering**. This
31
+ * means snapshots are invariant under object-key reordering — which
32
+ * prevents spurious diffs when a handler adds/removes unrelated keys.
33
+ * Arrays preserve order (they are semantically ordered).
34
+ *
35
+ * For timestamps, UUIDs, and other non-deterministic values, pass a
36
+ * `normalize` function that replaces them with stable placeholders
37
+ * before comparison. The tests in `tests/testing/snapshot.test.ts`
38
+ * demonstrate the common patterns.
39
+ *
40
+ * ## Storage layout
41
+ *
42
+ * ```
43
+ * myfeature.test.ts
44
+ * __snapshots__/
45
+ * myfeature.test.ts.snap ← JSON map { "snapshot name": "stringified value" }
46
+ * ```
47
+ *
48
+ * The snapshot file is one JSON object — a map of snapshot names to their
49
+ * stringified values. This lets a single test file own many named
50
+ * snapshots (`toMatchSnapshot(x, "user profile")`,
51
+ * `toMatchSnapshot(y, "settings")`) without spamming the filesystem.
52
+ *
53
+ * @module testing/snapshot
54
+ */
55
+
56
+ import fs from "node:fs";
57
+ import path from "node:path";
58
+
59
+ // ═══════════════════════════════════════════════════════════════════════════
60
+ // Types
61
+ // ═══════════════════════════════════════════════════════════════════════════
62
+
63
+ /** Options accepted by `matchSnapshot` / `toMatchSnapshot`. */
64
+ export interface SnapshotOptions {
65
+ /**
66
+ * Snapshot file path. When omitted, the caller must supply `testFile`
67
+ * so we can derive `<dir>/__snapshots__/<basename>.snap` automatically.
68
+ */
69
+ snapshotPath?: string;
70
+ /**
71
+ * Path to the test file the snapshot belongs to. Used to derive
72
+ * `snapshotPath` when it is not passed explicitly.
73
+ */
74
+ testFile?: string;
75
+ /** Named key inside the snapshot file. Defaults to "default". */
76
+ name?: string;
77
+ /**
78
+ * Force update-in-place regardless of the `UPDATE_SNAPSHOTS` env var.
79
+ * Useful in golden-file generators or one-shot scripts.
80
+ */
81
+ update?: boolean;
82
+ /**
83
+ * Pre-comparison transform. Useful to scrub timestamps, random IDs,
84
+ * or absolute paths so the stored snapshot is stable across runs and
85
+ * machines.
86
+ */
87
+ normalize?: (value: unknown) => unknown;
88
+ }
89
+
90
+ /** Outcome of a snapshot comparison. */
91
+ export interface SnapshotResult {
92
+ /** True when the stored snapshot matches (or we just wrote it). */
93
+ readonly match: boolean;
94
+ /** True when the file did not exist and we created it. */
95
+ readonly created: boolean;
96
+ /** True when we overwrote an existing snapshot (update mode). */
97
+ readonly updated: boolean;
98
+ /** The serialized form of the value being snapshotted. */
99
+ readonly actual: string;
100
+ /** The stored snapshot — `null` when the file did not exist. */
101
+ readonly expected: string | null;
102
+ /** Absolute path of the snapshot file. */
103
+ readonly snapshotPath: string;
104
+ /** The key inside the snapshot file. */
105
+ readonly name: string;
106
+ /**
107
+ * Minimal unified-style diff when `match === false`. Empty string
108
+ * on match or when the snapshot was just created.
109
+ */
110
+ readonly diff: string;
111
+ }
112
+
113
+ // ═══════════════════════════════════════════════════════════════════════════
114
+ // Serialization — deterministic JSON
115
+ // ═══════════════════════════════════════════════════════════════════════════
116
+
117
+ /**
118
+ * Stringify `value` with deterministic, sorted object keys. Arrays keep
119
+ * their order (order is semantically meaningful for arrays). Undefined
120
+ * values and functions become `null` so the output is valid JSON across
121
+ * all runtimes.
122
+ *
123
+ * Two-space indent matches `git diff` expectations and keeps snapshots
124
+ * reviewable in PRs. The trailing newline is intentional — `echo` /
125
+ * `cat` don't clobber the last line.
126
+ */
127
+ export function stableStringify(value: unknown): string {
128
+ const seen = new WeakSet<object>();
129
+
130
+ const replacer = (val: unknown): unknown => {
131
+ if (val === null || typeof val !== "object") return val;
132
+ if (seen.has(val as object)) return "[Circular]";
133
+ seen.add(val as object);
134
+
135
+ if (Array.isArray(val)) {
136
+ return val.map(replacer);
137
+ }
138
+
139
+ // Dates / RegExps / Errors — serialize by toString() so they compare by
140
+ // value instead of structure (empty-object serialization would hide real
141
+ // differences).
142
+ if (val instanceof Date) return `[Date ${val.toISOString()}]`;
143
+ if (val instanceof RegExp) return `[RegExp ${val.toString()}]`;
144
+ if (val instanceof Error) return `[Error ${val.name}: ${val.message}]`;
145
+ if (val instanceof Map) {
146
+ const entries: Array<[string, unknown]> = [];
147
+ for (const [k, v] of val.entries()) {
148
+ entries.push([String(k), replacer(v)]);
149
+ }
150
+ entries.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
151
+ return { __type: "Map", entries };
152
+ }
153
+ if (val instanceof Set) {
154
+ const items = Array.from(val.values()).map(replacer);
155
+ return { __type: "Set", items };
156
+ }
157
+
158
+ const rec = val as Record<string, unknown>;
159
+ const keys = Object.keys(rec).sort();
160
+ const out: Record<string, unknown> = {};
161
+ for (const k of keys) {
162
+ const v = rec[k];
163
+ // Drop undefined/function keys — JSON cannot represent them and we
164
+ // want the snapshot to elide them rather than fail serialization.
165
+ if (typeof v === "function" || typeof v === "undefined") continue;
166
+ out[k] = replacer(v);
167
+ }
168
+ return out;
169
+ };
170
+
171
+ const normalized = replacer(value);
172
+ const serialized = JSON.stringify(normalized, null, 2);
173
+ return `${serialized ?? "null"}\n`;
174
+ }
175
+
176
+ // ═══════════════════════════════════════════════════════════════════════════
177
+ // Storage IO
178
+ // ═══════════════════════════════════════════════════════════════════════════
179
+
180
+ /**
181
+ * Build the default snapshot path from a test file: co-locate in a
182
+ * `__snapshots__/` directory next to the test.
183
+ */
184
+ export function deriveSnapshotPath(testFile: string): string {
185
+ const dir = path.dirname(testFile);
186
+ const base = path.basename(testFile);
187
+ return path.join(dir, "__snapshots__", `${base}.snap`);
188
+ }
189
+
190
+ interface SnapshotFile {
191
+ readonly snapshots: Record<string, string>;
192
+ }
193
+
194
+ function readSnapshotFile(snapshotPath: string): SnapshotFile | null {
195
+ if (!fs.existsSync(snapshotPath)) return null;
196
+ const raw = fs.readFileSync(snapshotPath, "utf8");
197
+ if (!raw.trim()) return { snapshots: {} };
198
+ try {
199
+ const parsed = JSON.parse(raw) as unknown;
200
+ if (
201
+ typeof parsed !== "object" ||
202
+ parsed === null ||
203
+ typeof (parsed as Record<string, unknown>).snapshots !== "object" ||
204
+ (parsed as Record<string, unknown>).snapshots === null
205
+ ) {
206
+ // Legacy / corrupt file — treat as empty so we don't explode in CI.
207
+ return { snapshots: {} };
208
+ }
209
+ const raw_snapshots = (parsed as { snapshots: Record<string, unknown> }).snapshots;
210
+ const snapshots: Record<string, string> = {};
211
+ for (const [k, v] of Object.entries(raw_snapshots)) {
212
+ if (typeof v === "string") snapshots[k] = v;
213
+ }
214
+ return { snapshots };
215
+ } catch {
216
+ return { snapshots: {} };
217
+ }
218
+ }
219
+
220
+ function writeSnapshotFile(snapshotPath: string, file: SnapshotFile): void {
221
+ const dir = path.dirname(snapshotPath);
222
+ fs.mkdirSync(dir, { recursive: true });
223
+ // Sort keys deterministically so new additions don't reshuffle the file.
224
+ const sorted: Record<string, string> = {};
225
+ for (const key of Object.keys(file.snapshots).sort()) {
226
+ sorted[key] = file.snapshots[key];
227
+ }
228
+ const body = `${JSON.stringify({ snapshots: sorted }, null, 2)}\n`;
229
+ fs.writeFileSync(snapshotPath, body, "utf8");
230
+ }
231
+
232
+ // ═══════════════════════════════════════════════════════════════════════════
233
+ // Diff — tiny line-based delta for failure messages
234
+ // ═══════════════════════════════════════════════════════════════════════════
235
+
236
+ function computeDiff(expected: string, actual: string): string {
237
+ const expectedLines = expected.split("\n");
238
+ const actualLines = actual.split("\n");
239
+ const max = Math.max(expectedLines.length, actualLines.length);
240
+ const out: string[] = [];
241
+ out.push("--- expected (stored snapshot)");
242
+ out.push("+++ actual (current value)");
243
+ for (let i = 0; i < max; i++) {
244
+ const e = expectedLines[i];
245
+ const a = actualLines[i];
246
+ if (e === a) {
247
+ if (e !== undefined) out.push(` ${e}`);
248
+ } else {
249
+ if (e !== undefined) out.push(`- ${e}`);
250
+ if (a !== undefined) out.push(`+ ${a}`);
251
+ }
252
+ }
253
+ return out.join("\n");
254
+ }
255
+
256
+ // ═══════════════════════════════════════════════════════════════════════════
257
+ // Public API
258
+ // ═══════════════════════════════════════════════════════════════════════════
259
+
260
+ /**
261
+ * Whether update-in-place mode is active for this process.
262
+ *
263
+ * Controlled by the `UPDATE_SNAPSHOTS=1` environment variable. Any
264
+ * truthy value (`"1"`, `"true"`) activates. Exported for callers that
265
+ * want to emit different log output in update mode.
266
+ */
267
+ export function isUpdateMode(): boolean {
268
+ const raw = process.env.UPDATE_SNAPSHOTS;
269
+ if (!raw) return false;
270
+ const normalized = raw.toLowerCase();
271
+ return normalized === "1" || normalized === "true" || normalized === "yes";
272
+ }
273
+
274
+ /**
275
+ * Compare `value` against the stored snapshot at the resolved path.
276
+ *
277
+ * This is a pure function — it does not throw. Callers decide whether
278
+ * to fail the test (`toMatchSnapshot`) or surface the result some other
279
+ * way (`matchSnapshot()` in a CLI golden-file script).
280
+ */
281
+ export function matchSnapshot(
282
+ value: unknown,
283
+ options: SnapshotOptions = {},
284
+ ): SnapshotResult {
285
+ const snapshotPath =
286
+ options.snapshotPath ??
287
+ (options.testFile ? deriveSnapshotPath(options.testFile) : undefined);
288
+
289
+ if (!snapshotPath) {
290
+ throw new TypeError(
291
+ "[testing/snapshot] matchSnapshot requires either snapshotPath or testFile.",
292
+ );
293
+ }
294
+
295
+ const name = options.name ?? "default";
296
+ const normalized = options.normalize ? options.normalize(value) : value;
297
+ const actual = stableStringify(normalized);
298
+
299
+ const existingFile = readSnapshotFile(snapshotPath);
300
+ const existing = existingFile?.snapshots[name] ?? null;
301
+ const shouldUpdate = options.update ?? isUpdateMode();
302
+
303
+ // First-time capture → write without comparing. Treat this as a pass so
304
+ // new tests don't spuriously fail on the first run. CI environments can
305
+ // guard against accidental captures by running with `CI=true` and
306
+ // enforcing `existing !== null` at a higher layer if needed.
307
+ if (existing === null) {
308
+ const next: SnapshotFile = {
309
+ snapshots: {
310
+ ...(existingFile?.snapshots ?? {}),
311
+ [name]: actual,
312
+ },
313
+ };
314
+ writeSnapshotFile(snapshotPath, next);
315
+ return {
316
+ match: true,
317
+ created: true,
318
+ updated: false,
319
+ actual,
320
+ expected: null,
321
+ snapshotPath,
322
+ name,
323
+ diff: "",
324
+ };
325
+ }
326
+
327
+ if (existing === actual) {
328
+ return {
329
+ match: true,
330
+ created: false,
331
+ updated: false,
332
+ actual,
333
+ expected: existing,
334
+ snapshotPath,
335
+ name,
336
+ diff: "",
337
+ };
338
+ }
339
+
340
+ if (shouldUpdate) {
341
+ const next: SnapshotFile = {
342
+ snapshots: {
343
+ ...(existingFile?.snapshots ?? {}),
344
+ [name]: actual,
345
+ },
346
+ };
347
+ writeSnapshotFile(snapshotPath, next);
348
+ return {
349
+ match: true,
350
+ created: false,
351
+ updated: true,
352
+ actual,
353
+ expected: existing,
354
+ snapshotPath,
355
+ name,
356
+ diff: "",
357
+ };
358
+ }
359
+
360
+ return {
361
+ match: false,
362
+ created: false,
363
+ updated: false,
364
+ actual,
365
+ expected: existing,
366
+ snapshotPath,
367
+ name,
368
+ diff: computeDiff(existing, actual),
369
+ };
370
+ }
371
+
372
+ /**
373
+ * Test-suite binding. Calls `matchSnapshot()` and throws a well-formed
374
+ * `Error` on mismatch so `bun test` / `jest` display a clear failure.
375
+ *
376
+ * `name` defaults to the caller-supplied string (recommended) — omit
377
+ * it only when a single snapshot per test is sufficient.
378
+ */
379
+ export function toMatchSnapshot(
380
+ value: unknown,
381
+ options: SnapshotOptions | string = {},
382
+ ): SnapshotResult {
383
+ const opts: SnapshotOptions =
384
+ typeof options === "string" ? { name: options } : options;
385
+
386
+ const result = matchSnapshot(value, opts);
387
+
388
+ if (!result.match) {
389
+ const header =
390
+ `Snapshot mismatch: ${result.name}\n snapshot: ${result.snapshotPath}\n ` +
391
+ `Run with UPDATE_SNAPSHOTS=1 to accept the new value.\n`;
392
+ throw new Error(`${header}\n${result.diff}`);
393
+ }
394
+
395
+ return result;
396
+ }
397
+
398
+ /**
399
+ * Convenience normalizer: scrub values that change every run.
400
+ *
401
+ * Replaces:
402
+ * - ISO-8601 timestamps → `"<timestamp>"`
403
+ * - Unix epoch millis → `"<timestamp>"`
404
+ * - UUIDs (v4 + v7) → `"<uuid>"`
405
+ * - Absolute file paths → `"<abs path>"`
406
+ *
407
+ * Applied recursively to strings. Non-string values pass through.
408
+ */
409
+ export function scrubVolatile(value: unknown): unknown {
410
+ const ISO = /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})/g;
411
+ const UUID = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi;
412
+ // Absolute-ish paths: POSIX (/a/b) or Windows (C:\a\b). We intentionally do
413
+ // not scrub short paths like `/foo` that might appear in URLs.
414
+ const WIN_ABS = /[A-Z]:\\[^\s"']+/g;
415
+ const POSIX_ABS = /\/[A-Za-z0-9_.\-]+(?:\/[A-Za-z0-9_.\-]+){2,}/g;
416
+
417
+ const walk = (v: unknown): unknown => {
418
+ if (typeof v === "string") {
419
+ return v
420
+ .replace(ISO, "<timestamp>")
421
+ .replace(UUID, "<uuid>")
422
+ .replace(WIN_ABS, "<abs path>")
423
+ .replace(POSIX_ABS, "<abs path>");
424
+ }
425
+ if (typeof v === "number") {
426
+ // Epoch millis in the last ~50 years → placeholder.
427
+ if (Number.isInteger(v) && v > 1_000_000_000_000 && v < 10_000_000_000_000) {
428
+ return "<timestamp>";
429
+ }
430
+ return v;
431
+ }
432
+ if (Array.isArray(v)) return v.map(walk);
433
+ if (v && typeof v === "object") {
434
+ const out: Record<string, unknown> = {};
435
+ for (const [k, val] of Object.entries(v as Record<string, unknown>)) {
436
+ out[k] = walk(val);
437
+ }
438
+ return out;
439
+ }
440
+ return v;
441
+ };
442
+
443
+ return walk(value);
444
+ }