@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.
- package/package.json +21 -4
- package/src/auth/__tests__/login.test.ts +420 -419
- package/src/auth/__tests__/reset.test.ts +296 -296
- package/src/brain/adapters/anthropic-oauth.ts +421 -420
- package/src/brain/adapters/index.ts +2 -1
- package/src/brain/adapters/ollama.ts +1 -1
- package/src/brain/adapters/openai-oauth.ts +534 -533
- package/src/brain/brain.ts +2 -1
- package/src/brain/redactor.ts +196 -196
- package/src/bundler/__tests__/cli-bench-utils.test.ts +149 -149
- package/src/bundler/__tests__/cold-start.test.ts +504 -504
- package/src/bundler/__tests__/fast-refresh.test.ts +607 -606
- package/src/bundler/__tests__/hdr.test.ts +1 -1
- package/src/bundler/analyzer.ts +958 -958
- package/src/bundler/build.ts +104 -14
- package/src/bundler/dev.ts +125 -0
- package/src/bundler/hmr-types.ts +1 -0
- package/src/bundler/plugins/__tests__/react-compiler-lint.test.ts +110 -0
- package/src/bundler/plugins/index.ts +14 -0
- package/src/bundler/plugins/react-compiler-lint.ts +253 -0
- package/src/bundler/plugins/react-compiler.ts +162 -0
- package/src/bundler/types.ts +12 -0
- package/src/change/integrity.ts +2 -1
- package/src/client/index.ts +10 -0
- package/src/client/island.ts +38 -11
- package/src/client/router.ts +6 -1
- package/src/config/mandu.ts +57 -0
- package/src/config/validate.ts +42 -0
- package/src/content/collection.ts +844 -809
- package/src/content/content-layer.ts +316 -314
- package/src/content/content.test.ts +433 -433
- package/src/content/digest.ts +133 -133
- package/src/content/generate-types.ts +168 -168
- package/src/content/index.ts +6 -1
- package/src/content/llms-txt.ts +277 -277
- package/src/contract/define.ts +474 -474
- package/src/contract/route-helpers.ts +2 -1
- package/src/contract/zod-utils.ts +158 -155
- package/src/db/index.ts +513 -513
- package/src/desktop/__tests__/smoke.test.ts +100 -100
- package/src/desktop/webview-fallback.ts +583 -583
- package/src/desktop/window.ts +3 -1
- package/src/dev-error-overlay/overlay-client.ts +300 -300
- package/src/devtools/ai/mcp-connector.ts +499 -498
- package/src/devtools/client/components/kitchen-root.tsx +7 -2
- package/src/email/resend.ts +163 -163
- package/src/guard/__tests__/tsgolint-bridge.test.ts +347 -0
- package/src/guard/ast-analyzer.ts +806 -806
- package/src/guard/graph.ts +898 -898
- package/src/guard/index.ts +16 -0
- package/src/guard/statistics.ts +578 -578
- package/src/guard/tsgolint-bridge.ts +512 -0
- package/src/i18n/locale-resolver.ts +214 -214
- package/src/id/__tests__/id.test.ts +120 -120
- package/src/intent/index.ts +321 -321
- package/src/island/index.ts +39 -23
- package/src/kitchen/api/contract-api.ts +15 -8
- package/src/kitchen/kitchen-ui.ts +2137 -2137
- package/src/lockfile/index.ts +3 -2
- package/src/middleware/oauth/__tests__/oauth.test.ts +575 -574
- package/src/middleware/rate-limit/__tests__/rate-limit.test.ts +642 -642
- package/src/middleware/secure/index.ts +417 -417
- package/src/observability/event-bus.ts +2 -2
- package/src/observability/metrics.ts +334 -334
- package/src/observability/tracing.ts +694 -694
- package/src/openapi/generator.ts +1 -1
- package/src/perf/user-marks.ts +553 -553
- package/src/plugins/registry.ts +387 -387
- package/src/resource/ddl/diff.ts +392 -392
- package/src/resource/ddl/snapshot.ts +448 -447
- package/src/resource/generator-schema.ts +477 -476
- package/src/resource/parser.ts +4 -2
- package/src/resource/schema.ts +1 -1
- package/src/router/fs-patterns.ts +422 -422
- package/src/runtime/fast-refresh-types.ts +126 -128
- package/src/runtime/image-handler.ts +206 -195
- package/src/runtime/router.test.ts +476 -476
- package/src/runtime/security.ts +155 -155
- package/src/runtime/server.ts +36 -19
- package/src/runtime/session-key.ts +328 -328
- package/src/scheduler/__tests__/scheduler.test.ts +514 -514
- package/src/seo/resolve/index.ts +353 -353
- package/src/spec/load.ts +1 -1
- package/src/testing/reporter.ts +676 -676
- package/src/testing/server.ts +196 -196
- package/src/testing/snapshot.ts +444 -444
- package/src/utils/__tests__/lru-cache.test.ts +186 -186
- package/src/utils/bun.ts +8 -8
package/src/testing/snapshot.ts
CHANGED
|
@@ -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
|
+
}
|