@mandujs/core 0.41.2 → 0.43.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.
Files changed (88) hide show
  1. package/package.json +21 -4
  2. package/src/auth/__tests__/login.test.ts +420 -419
  3. package/src/auth/__tests__/reset.test.ts +296 -296
  4. package/src/brain/adapters/anthropic-oauth.ts +421 -420
  5. package/src/brain/adapters/index.ts +2 -1
  6. package/src/brain/adapters/ollama.ts +1 -1
  7. package/src/brain/adapters/openai-oauth.ts +534 -533
  8. package/src/brain/brain.ts +2 -1
  9. package/src/brain/redactor.ts +196 -196
  10. package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
  11. package/src/bundler/__tests__/cold-start.test.ts +504 -504
  12. package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
  13. package/src/bundler/__tests__/hdr.test.ts +1 -1
  14. package/src/bundler/analyzer.ts +958 -958
  15. package/src/bundler/build.ts +104 -14
  16. package/src/bundler/dev.ts +125 -0
  17. package/src/bundler/hmr-types.ts +1 -0
  18. package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
  19. package/src/bundler/plugins/index.ts +14 -0
  20. package/src/bundler/plugins/react-compiler-lint.ts +253 -0
  21. package/src/bundler/plugins/react-compiler.ts +162 -0
  22. package/src/bundler/types.ts +12 -0
  23. package/src/change/integrity.ts +2 -1
  24. package/src/client/index.ts +10 -0
  25. package/src/client/island.ts +38 -11
  26. package/src/client/router.ts +6 -1
  27. package/src/config/mandu.ts +57 -0
  28. package/src/config/validate.ts +42 -0
  29. package/src/content/collection.ts +844 -809
  30. package/src/content/content-layer.ts +316 -314
  31. package/src/content/content.test.ts +433 -433
  32. package/src/content/digest.ts +133 -133
  33. package/src/content/generate-types.ts +168 -168
  34. package/src/content/index.ts +6 -1
  35. package/src/content/llms-txt.ts +277 -277
  36. package/src/contract/define.ts +474 -474
  37. package/src/contract/route-helpers.ts +2 -1
  38. package/src/contract/zod-utils.ts +158 -155
  39. package/src/db/index.ts +513 -513
  40. package/src/desktop/__tests__/smoke.test.ts +100 -100
  41. package/src/desktop/webview-fallback.ts +583 -583
  42. package/src/desktop/window.ts +3 -1
  43. package/src/dev-error-overlay/overlay-client.ts +300 -300
  44. package/src/devtools/ai/mcp-connector.ts +499 -498
  45. package/src/devtools/client/components/kitchen-root.tsx +7 -2
  46. package/src/email/resend.ts +163 -163
  47. package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
  48. package/src/guard/ast-analyzer.ts +806 -806
  49. package/src/guard/graph.ts +898 -898
  50. package/src/guard/index.ts +16 -0
  51. package/src/guard/statistics.ts +578 -578
  52. package/src/guard/tsgolint-bridge.ts +512 -0
  53. package/src/i18n/locale-resolver.ts +214 -214
  54. package/src/id/__tests__/id.test.ts +120 -120
  55. package/src/intent/index.ts +321 -321
  56. package/src/island/index.ts +39 -23
  57. package/src/kitchen/api/contract-api.ts +15 -8
  58. package/src/kitchen/kitchen-ui.ts +2137 -2137
  59. package/src/lockfile/index.ts +3 -2
  60. package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
  61. package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
  62. package/src/middleware/secure/index.ts +417 -417
  63. package/src/observability/event-bus.ts +2 -2
  64. package/src/observability/metrics.ts +334 -334
  65. package/src/observability/tracing.ts +694 -694
  66. package/src/openapi/generator.ts +1 -1
  67. package/src/perf/user-marks.ts +553 -553
  68. package/src/plugins/registry.ts +387 -387
  69. package/src/resource/ddl/diff.ts +392 -392
  70. package/src/resource/ddl/snapshot.ts +448 -447
  71. package/src/resource/generator-schema.ts +477 -476
  72. package/src/resource/parser.ts +4 -2
  73. package/src/resource/schema.ts +1 -1
  74. package/src/router/fs-patterns.ts +422 -422
  75. package/src/runtime/fast-refresh-types.ts +126 -128
  76. package/src/runtime/image-handler.ts +206 -195
  77. package/src/runtime/router.test.ts +476 -476
  78. package/src/runtime/security.ts +155 -155
  79. package/src/runtime/server.ts +36 -19
  80. package/src/runtime/session-key.ts +328 -328
  81. package/src/scheduler/__tests__/scheduler.test.ts +514 -514
  82. package/src/seo/resolve/index.ts +353 -353
  83. package/src/spec/load.ts +1 -1
  84. package/src/testing/reporter.ts +676 -676
  85. package/src/testing/server.ts +196 -196
  86. package/src/testing/snapshot.ts +444 -444
  87. package/src/utils/__tests__/lru-cache.test.ts +186 -186
  88. package/src/utils/bun.ts +8 -8
@@ -1,444 +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
- }
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
+ }