1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497 |
x105
x105
x105
x105
x105
x105
x105
x105
x34
x34
x105
x24
x24
x105
x36
x36
x36
x36
x36
x36
x36
x36
x36
x36
x36
x105
x70
x70
x70
x70
x70
x32
x32
x32
x32
x32
x32
x9
x9
x9
x9
x9
x9
x16
x16
x16
x6
x6
x16
x16
x9
x9
x105
x105
x20
x3
x3
x3
x20
x105
x105
x105
x105
x105
x24
x24
x24
x24
x24
x24
x24
x24
x17
x17
x17
x24
x105
x17
x17
x17
x17
x17
x17
x17
x17
x17
x17
x57
x15
x15
x15
x57
x6
x6
x6
x15
x57
x17
x27
x27
x27
x27
x17
x16
x16
x17
x18
x18
x17
x9
x9
x9
x9
x9
x9
x9
x9
x9
x17
x27
x27
x17
x4
x4
x4
x17
x14
x14
x14
x14
x14
x35
x35
x35
x35
x35
x35
x35
x35
x35
x35
x35
x35
x14
x14
x14
x14
x14
x14
x14
x14
x14
x14
x14
x14
x105
x105 |
I
I
I
I
I
I
|
// Copyright 2018-2026 the Deno authors. MIT license.
import { basename } from "@std/path/basename";
import { dirname } from "@std/path/dirname";
import { fromFileUrl } from "@std/path/from-file-url";
import { join } from "@std/path/join";
const SNAPSHOT_DIR = "__snapshots__";
const SNAPSHOT_EXT = "snap";
// --- Jest-compatible state management ---
/**
* State for snapshot testing, following Jest's
* `expect.getState()`/`expect.setState()` API.
*
* @experimental
*/
export interface ExpectSnapshotState {
/** The name of the currently running test. Required for snapshot testing. */
currentTestName?: string | undefined;
/** The file path of the currently running test. Auto-detected from stack trace if not set. */
testPath?: string | undefined;
}
let expectState: ExpectSnapshotState = {};
/**
* Sets the expect state. Used by test runners to provide test context.
*
* @experimental
*/
export function setState(newState: Partial<ExpectSnapshotState>): void {
expectState = { ...expectState, ...newState };
}
/**
* Gets the current expect state.
*
* @experimental
*/
export function getState(): ExpectSnapshotState {
return { ...expectState };
}
// --- V8 structured stack trace API ---
/**
* Gets the test file path by walking up the call stack and finding the first
* frame that isn't part of the expect module internals.
*/
export function getTestFileFromStack(): string | null {
// deno-lint-ignore no-explicit-any
const ErrorCtor = Error as any;
const origPrepareStackTrace = ErrorCtor.prepareStackTrace;
try {
const obj: { stack: string | null } = { stack: null };
ErrorCtor.prepareStackTrace = (
_err: Error,
// deno-lint-ignore no-explicit-any
stack: any[],
): string | null => {
for (const frame of stack) {
if (frame.isEval()) continue;
const fileName: string | null = frame.getFileName();
if (fileName === null) continue;
// Skip expect module internal files
if (
fileName.includes("/expect/_") ||
fileName.includes("/expect/expect.ts")
) {
continue;
}
return fileName;
}
return null;
};
ErrorCtor.captureStackTrace(obj);
return obj.stack;
} finally {
ErrorCtor.prepareStackTrace = origPrepareStackTrace;
}
}
// --- Serialization ---
/** Serializes a value for snapshot comparison using `Deno.inspect`. */
export function serialize(actual: unknown): string {
return Deno.inspect(actual, {
depth: Infinity,
sorted: true,
trailingComma: true,
compact: false,
iterableLimit: Infinity,
strAbbreviateSize: Infinity,
breakLength: Infinity,
escapeSequences: false,
}).replaceAll("\r", "\\r");
}
// --- Snapshot string escaping ---
export function escapeStringForJs(str: string): string {
return str
.replace(/\\/g, "\\\\")
.replace(/`/g, "\\`")
.replace(/\$/g, "\\$");
}
function unescapeStringForJs(str: string): string {
return str
.replace(/\\\$/g, "$")
.replace(/\\`/g, "`")
.replace(/\\\\/g, "\\");
}
// --- Snapshot file parsing ---
function parseSnapshotFile(content: string): Map<string, string> {
const snapshots = new Map<string, string>();
const regex =
/snapshot\[`((?:[^`\\]|\\.)*)`\]\s*=\s*`((?:[^`\\]|\\.)*)`\s*;/gs;
let match;
while ((match = regex.exec(content)) !== null) {
const name = unescapeStringForJs(match[1]!);
let value = unescapeStringForJs(match[2]!);
// Multi-line snapshots have leading/trailing newlines in the backticks
if (value.startsWith("\n") && value.endsWith("\n")) {
value = value.slice(1, -1);
}
snapshots.set(name, value);
}
return snapshots;
}
// --- Update mode detection ---
let _isUpdate: boolean | undefined;
export function getIsUpdate(): boolean {
if (_isUpdate !== undefined) return _isUpdate;
try {
_isUpdate = Deno.args.some((arg) => arg === "--update" || arg === "-u");
} catch {
_isUpdate = false;
}
return _isUpdate!;
}
// --- Inline snapshot infrastructure ---
interface InlineSnapshotUpdateRequest {
fileName: string;
lineNumber: number;
columnNumber: number;
actualSnapshot: string;
}
const inlineUpdateRequests: InlineSnapshotUpdateRequest[] = [];
/**
* Gets the call site location of the caller of `toMatchInlineSnapshot`.
* Walks the stack to find the first frame outside of expect internals.
*/
export function getInlineCallSite(
// deno-lint-ignore no-explicit-any
captureTarget: (...args: any[]) => any,
): InlineSnapshotUpdateRequest | null {
// deno-lint-ignore no-explicit-any
const ErrorCtor = Error as any;
const origPrepareStackTrace = ErrorCtor.prepareStackTrace;
try {
const obj: { stack: InlineSnapshotUpdateRequest | null } = { stack: null };
ErrorCtor.prepareStackTrace = (
_err: Error,
// deno-lint-ignore no-explicit-any
stack: any[],
): InlineSnapshotUpdateRequest | null => {
for (const frame of stack) {
if (frame.isEval()) continue;
const fileName: string | null = frame.getFileName();
if (fileName === null) continue;
if (
fileName.includes("/expect/_") ||
fileName.includes("/expect/expect.ts")
) {
continue;
}
const lineNumber: number | null = frame.getLineNumber();
const columnNumber: number | null = frame.getColumnNumber();
if (lineNumber === null || columnNumber === null) continue;
return { fileName, lineNumber, columnNumber, actualSnapshot: "" };
}
return null;
};
ErrorCtor.captureStackTrace(obj, captureTarget);
return obj.stack;
} finally {
ErrorCtor.prepareStackTrace = origPrepareStackTrace;
}
}
/** Queues an inline snapshot update to be applied on teardown. */
export function pushInlineUpdate(request: InlineSnapshotUpdateRequest): void {
inlineUpdateRequests.push(request);
}
/** Returns the current inline update queue (for testing). */
export function getInlineUpdateRequests(): InlineSnapshotUpdateRequest[] {
return inlineUpdateRequests;
}
/** Clears the inline update queue (for testing). */
export function clearInlineUpdateRequests(): void {
inlineUpdateRequests.length = 0;
}
function makeSnapshotUpdater(
updateRequests: InlineSnapshotUpdateRequest[],
) {
return {
name: "snapshot-updater-plugin",
rules: {
"update-snapshot": {
// deno-lint-ignore no-explicit-any
create(context: any) {
const src = context.sourceCode.text as string;
const lineBreaks = [...src.matchAll(/\n|\r\n?/g)]
.map((m) => m.index);
const locationToSnapshot: Record<number, string> = {};
for (const req of updateRequests) {
const { lineNumber, columnNumber, actualSnapshot } = req;
const location = (lineBreaks[lineNumber - 2] ?? 0) + columnNumber;
locationToSnapshot[location] = actualSnapshot;
}
return {
// deno-lint-ignore no-explicit-any
"CallExpression"(node: any) {
const snapshot = locationToSnapshot[node.range[0]];
if (snapshot === undefined) return;
const args = node.arguments;
if (args.length === 0) {
context.report({
node,
message: "",
// deno-lint-ignore no-explicit-any
fix(fixer: any) {
return fixer.insertTextBeforeRange(
[node.range[1] - 1, node.range[1] - 1],
snapshot,
);
},
});
} else {
const lastArg = args[args.length - 1];
context.report({
node,
message: "",
// deno-lint-ignore no-explicit-any
fix(fixer: any) {
return fixer.replaceText(lastArg, snapshot);
},
});
}
},
};
},
},
},
};
}
function applyInlineUpdates(): void {
if (inlineUpdateRequests.length === 0) return;
// @ts-ignore Deno.lint may not exist in all versions
if (typeof Deno.lint?.runPlugin !== "function") {
throw new Error(
"Deno versions before 2.2.0 do not support Deno.lint, which is required to update inline snapshots",
);
}
const pathsToUpdate = Map.groupBy(inlineUpdateRequests, (r) => r.fileName);
for (const [path, requests] of pathsToUpdate) {
const file = Deno.readTextFileSync(path);
// @ts-ignore Deno.lint may not exist in all versions
const pluginRunResults = Deno.lint.runPlugin(
makeSnapshotUpdater(requests),
"dummy.ts",
file,
);
const fixes = (pluginRunResults as {
fix?: { range: [number, number]; text?: string }[];
}[])
.flatMap((v) => v.fix ?? []);
// Apply fixes in order
fixes.sort((a, b) => a.range[0] - b.range[0]);
let output = "";
let lastIndex = 0;
for (const fix of fixes) {
output += file.slice(lastIndex, fix.range[0]);
output += fix.text ?? "";
lastIndex = fix.range[1];
}
output += file.slice(lastIndex);
Deno.writeTextFileSync(path, output);
}
const shouldFormat = !Deno.args.some((arg) => arg === "--no-format");
if (shouldFormat) {
const command = new Deno.Command(Deno.execPath(), {
args: ["fmt", ...pathsToUpdate.keys()],
});
command.outputSync();
}
// deno-lint-ignore no-console
console.log(
`%c\n > ${inlineUpdateRequests.length} inline ${
inlineUpdateRequests.length === 1 ? "snapshot" : "snapshots"
} updated.`,
"color: green; font-weight: bold;",
);
}
let inlineTeardownRegistered = false;
/** Registers the global teardown for inline snapshot updates. */
export function registerInlineTeardown(): void {
if (inlineTeardownRegistered) return;
globalThis.addEventListener("unload", () => {
applyInlineUpdates();
});
inlineTeardownRegistered = true;
}
// --- Snapshot Context (per snapshot file) ---
/**
* @experimental
*/
export class SnapshotContext {
static contexts = new Map<string, SnapshotContext>();
static fromTestFile(testFilePath: string): SnapshotContext {
const filePath = testFilePath.startsWith("file://")
? fromFileUrl(testFilePath)
: testFilePath;
const dir = dirname(filePath);
const base = basename(filePath);
const snapshotPath = join(dir, SNAPSHOT_DIR, `${base}.${SNAPSHOT_EXT}`);
let context = this.contexts.get(snapshotPath);
if (context) return context;
context = new SnapshotContext(snapshotPath);
this.contexts.set(snapshotPath, context);
return context;
}
#snapshotFilePath: string;
#currentSnapshots: Map<string, string> | undefined;
#updatedSnapshots = new Map<string, string>();
#snapshotCounts = new Map<string, number>();
#snapshotsUpdated: string[] = [];
#snapshotUpdateQueue: string[] = [];
#teardownRegistered = false;
constructor(snapshotFilePath: string) {
this.#snapshotFilePath = snapshotFilePath;
}
#readSnapshotFile(): Map<string, string> {
if (this.#currentSnapshots) return this.#currentSnapshots;
try {
const content = Deno.readTextFileSync(this.#snapshotFilePath);
this.#currentSnapshots = parseSnapshotFile(content);
} catch (e) {
if (e instanceof Deno.errors.NotFound) {
this.#currentSnapshots = new Map();
} else {
throw e;
}
}
return this.#currentSnapshots;
}
/** Gets the snapshot count for a test name and increments it. */
getCount(testName: string): number {
let count = this.#snapshotCounts.get(testName) ?? 0;
this.#snapshotCounts.set(testName, ++count);
return count;
}
/** Gets an existing snapshot value by name. */
getSnapshot(name: string): string | undefined {
return this.#readSnapshotFile().get(name);
}
/** Checks if a snapshot exists by name. */
hasSnapshot(name: string): boolean {
return this.#readSnapshotFile().has(name);
}
/** Stores an updated snapshot value. Written to disk on teardown. */
updateSnapshot(name: string, snapshot: string): void {
if (!this.#snapshotsUpdated.includes(name)) {
this.#snapshotsUpdated.push(name);
}
const current = this.#readSnapshotFile();
if (!current.has(name)) {
current.set(name, "");
}
this.#updatedSnapshots.set(name, snapshot);
}
/** Tracks the order of snapshots for writing the file. */
pushToUpdateQueue(name: string): void {
this.#snapshotUpdateQueue.push(name);
}
/** Registers a teardown listener to write snapshots when tests complete. */
registerTeardown(): void {
if (this.#teardownRegistered) return;
globalThis.addEventListener("unload", this.#teardown);
this.#teardownRegistered = true;
}
#teardown = () => {
const currentSnapshots = this.#readSnapshotFile();
const buf = ["export const snapshot = {};"];
const removedNames = [...currentSnapshots.keys()].filter(
(name) => !this.#snapshotUpdateQueue.includes(name),
);
for (const name of this.#snapshotUpdateQueue) {
const updatedSnapshot = this.#updatedSnapshots.get(name);
const currentSnapshot = currentSnapshots.get(name);
let formattedSnapshot: string;
if (typeof updatedSnapshot === "string") {
formattedSnapshot = updatedSnapshot;
} else if (typeof currentSnapshot === "string") {
formattedSnapshot = currentSnapshot;
} else {
continue;
}
formattedSnapshot = escapeStringForJs(formattedSnapshot);
formattedSnapshot = formattedSnapshot.includes("\n")
? `\n${formattedSnapshot}\n`
: formattedSnapshot;
const formattedName = escapeStringForJs(name);
buf.push(`\nsnapshot[\`${formattedName}\`] = \`${formattedSnapshot}\`;`);
}
// Ensure snapshot directory exists
const snapshotDir = dirname(this.#snapshotFilePath);
try {
Deno.mkdirSync(snapshotDir, { recursive: true });
} catch {
// directory already exists
}
Deno.writeTextFileSync(this.#snapshotFilePath, buf.join("\n") + "\n");
const updated = this.#snapshotsUpdated.length;
if (updated > 0) {
// deno-lint-ignore no-console
console.log(
`%c\n > ${updated} ${
updated === 1 ? "snapshot" : "snapshots"
} updated.`,
"color: green; font-weight: bold;",
);
}
if (removedNames.length > 0) {
// deno-lint-ignore no-console
console.log(
`%c\n > ${removedNames.length} ${
removedNames.length === 1 ? "snapshot" : "snapshots"
} removed.`,
"color: red; font-weight: bold;",
);
for (const name of removedNames) {
// deno-lint-ignore no-console
console.log(`%c \u2022 ${name}`, "color: red;");
}
}
};
}
|