Skip to content

Commit 6b3f579

Browse files
andrewdacenkometa-codesync[bot]
authored andcommitted
Add toMatchInlineSnapshot support (facebook#55892)
Summary: Pull Request resolved: facebook#55892 Adds `toMatchInlineSnapshot()` to the Fantom custom `expect` implementation, matching Jest's inline snapshot behavior. On first run, snapshot values are written directly into the test source file as template literal arguments. On subsequent runs, values are compared and the test fails on mismatch unless the `-u` flag is passed to force an update. The implementation spans the two-process Fantom architecture: **Runtime (Hermes VM):** - `snapshotContext.js`: New `toMatchInlineSnapshot` method that compares received values against existing inline snapshots, captures stack traces for new/mismatched snapshots, and tracks results per test. - `expect.js`: New `toMatchInlineSnapshot` method on the `Expect` class that serializes values with `prettyFormat` and delegates to snapshot context. - `setup.js`: Plumbs `inlineSnapshotResults` through `TestCaseResult`. **Runner (Node.js):** - `snapshotUtils.js`: New `processInlineSnapshotResults` resolves VM stack traces back to original source locations via source map symbolication. New `saveInlineSnapshotsToSource` directly rewrites the test source file by finding each `toMatchInlineSnapshot(...)` call and replacing its argument with the formatted template literal. This avoids jest-snapshot's `saveInlineSnapshots` which requires Babel and Prettier 2.x. - `runner.js`: Generates source maps when inline snapshots need updating, collects pending snapshots across test configs, and writes them after all configs complete. Changelog: [Internal] Differential Revision: D95077617
1 parent 7f7dbe7 commit 6b3f579

File tree

6 files changed

+486
-4
lines changed

6 files changed

+486
-4
lines changed

private/react-native-fantom/runner/runner.js

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,13 @@ import type {
1313
TestCaseResult,
1414
TestSuiteResult,
1515
} from '../runtime/setup';
16-
import type {TestSnapshotResults} from '../runtime/snapshotContext';
16+
import type {
17+
TestInlineSnapshotResults,
18+
TestSnapshotResults,
19+
} from '../runtime/snapshotContext';
1720
import type {BenchmarkResult} from '../src/Benchmark';
1821
import type {CoverageMap} from './coverage/types.flow';
22+
import type {PendingInlineSnapshot} from './snapshotUtils';
1923
import type {
2024
AsyncCommandResult,
2125
ConsoleLogMessage,
@@ -45,6 +49,8 @@ import {
4549
} from './paths';
4650
import {
4751
getInitialSnapshotData,
52+
processInlineSnapshotResults,
53+
saveInlineSnapshotsToSource,
4854
updateSnapshotsAndGetJestSnapshotResult,
4955
} from './snapshotUtils';
5056
import {
@@ -239,6 +245,7 @@ module.exports = async function runTest(
239245

240246
const testResultsByConfig = [];
241247
const benchmarkResults = [];
248+
const allPendingInlineSnapshots: Array<PendingInlineSnapshot> = [];
242249

243250
const skippedTestResults = ({
244251
ancestorTitles,
@@ -255,6 +262,7 @@ module.exports = async function runTest(
255262
fullName: title,
256263
numPassingAsserts: 0,
257264
snapshotResults: {} as TestSnapshotResults,
265+
inlineSnapshotResults: [] as TestInlineSnapshotResults,
258266
status: 'pending' as TestCaseResult['status'],
259267
testFilePath: testPath,
260268
title,
@@ -414,7 +422,16 @@ module.exports = async function runTest(
414422
const [processedResult, benchmarkResult] =
415423
await processRNTesterCommandResult(rnTesterCommandResult);
416424

417-
if (containsError(processedResult) || EnvironmentOptions.profileJS) {
425+
const hasInlineSnapshotUpdates =
426+
processedResult.testResults?.some(r =>
427+
r.inlineSnapshotResults.some(ir => !ir.pass),
428+
) ?? false;
429+
430+
if (
431+
containsError(processedResult) ||
432+
EnvironmentOptions.profileJS ||
433+
hasInlineSnapshotUpdates
434+
) {
418435
await createSourceMap({
419436
...bundleOptions,
420437
out: sourceMapPath,
@@ -452,6 +469,20 @@ module.exports = async function runTest(
452469
snapshotResults: testResult.snapshotResults,
453470
})) ?? [];
454471

472+
// Process inline snapshot results (requires source map for symbolication)
473+
if (hasInlineSnapshotUpdates) {
474+
const inlineResults = nullthrows(processedResult.testResults).map(
475+
r => r.inlineSnapshotResults,
476+
);
477+
const pending = processInlineSnapshotResults(
478+
snapshotState,
479+
inlineResults,
480+
sourceMapPath,
481+
testPath,
482+
);
483+
allPendingInlineSnapshots.push(...pending);
484+
}
485+
455486
// Display the Fantom test configuration as a suffix of the name of the root
456487
// `describe` block of the test, or adds one if the test doesn't have it.
457488
const maybeCommonAncestor = testResults[0]?.ancestorTitles?.[0];
@@ -515,6 +546,9 @@ module.exports = async function runTest(
515546
}
516547
}
517548

549+
// Write pending inline snapshots to the test source file
550+
saveInlineSnapshotsToSource(testPath, allPendingInlineSnapshots);
551+
518552
const endTime = Date.now();
519553

520554
const testResults = testResultsByConfig.flat();

private/react-native-fantom/runner/snapshotUtils.js

Lines changed: 248 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,16 @@
88
* @format
99
*/
1010

11-
import type {TestSnapshotResults} from '../runtime/snapshotContext';
11+
import type {
12+
TestInlineSnapshotResults,
13+
TestSnapshotResults,
14+
} from '../runtime/snapshotContext';
1215
import type {SnapshotState} from 'jest-snapshot';
1316

17+
import {symbolicateStackTrace} from './utils';
18+
import fs from 'fs';
19+
import path from 'path';
20+
1421
type JestSnapshotResult = {
1522
added: number,
1623
fileDeleted: boolean,
@@ -46,6 +53,246 @@ export const getInitialSnapshotData = (
4653
return initialData;
4754
};
4855

56+
const STACK_FRAME_REGEX: RegExp = /at .* \((.+):(\d+):(\d+)\)/;
57+
58+
/**
59+
* Extract the frame from a symbolicated stack trace that points to the test file.
60+
*/
61+
function extractTestFileFrame(
62+
symbolicatedStack: string,
63+
testPath: string,
64+
): {file: string, line: number, column: number} | null {
65+
const testBaseName = path.basename(testPath);
66+
const lines = symbolicatedStack.split('\n');
67+
68+
for (const line of lines) {
69+
const match = line.match(STACK_FRAME_REGEX);
70+
if (match) {
71+
const file = match[1];
72+
const lineNumber = parseInt(match[2], 10);
73+
const column = parseInt(match[3], 10);
74+
75+
// Match by basename since source map paths may be relative
76+
if (file.endsWith(testBaseName) || file === testPath) {
77+
return {file, line: lineNumber, column};
78+
}
79+
}
80+
}
81+
82+
return null;
83+
}
84+
85+
export type PendingInlineSnapshot = {
86+
line: number,
87+
snapshot: string,
88+
};
89+
90+
/**
91+
* Process inline snapshot results from the VM. Resolves stack traces to
92+
* source locations and returns pending snapshots for source file rewriting.
93+
* Also updates snapshotState counters.
94+
*/
95+
export const processInlineSnapshotResults = (
96+
snapshotState: SnapshotState,
97+
inlineSnapshotResults: Array<TestInlineSnapshotResults>,
98+
sourceMapPath: string,
99+
testPath: string,
100+
): Array<PendingInlineSnapshot> => {
101+
const pending: Array<PendingInlineSnapshot> = [];
102+
103+
for (const results of inlineSnapshotResults) {
104+
for (const result of results) {
105+
if (result.pass) {
106+
snapshotState.matched++;
107+
continue;
108+
}
109+
110+
// For new snapshots in CI mode, don't write
111+
if (result.isNew && snapshotState._updateSnapshot === 'none') {
112+
snapshotState.unmatched++;
113+
continue;
114+
}
115+
116+
// For mismatches without -u, don't write to source
117+
if (!result.isNew && snapshotState._updateSnapshot !== 'all') {
118+
snapshotState.unmatched++;
119+
continue;
120+
}
121+
122+
// Symbolicate stack trace to get original source location
123+
const symbolicatedStack = symbolicateStackTrace(
124+
sourceMapPath,
125+
result.stackTrace,
126+
);
127+
const frame = extractTestFileFrame(symbolicatedStack, testPath);
128+
129+
if (frame == null) {
130+
continue;
131+
}
132+
133+
pending.push({
134+
line: frame.line,
135+
snapshot: result.value,
136+
});
137+
138+
snapshotState._dirty = true;
139+
if (result.isNew) {
140+
snapshotState.added++;
141+
snapshotState.matched++;
142+
} else {
143+
snapshotState.updated++;
144+
}
145+
}
146+
}
147+
148+
return pending;
149+
};
150+
151+
/**
152+
* Escape a string for use inside a template literal.
153+
*/
154+
function escapeForTemplateLiteral(str: string): string {
155+
return str.replace(/`/g, '\\`').replace(/\$\{/g, '\\${');
156+
}
157+
158+
/**
159+
* Format a snapshot value as a template literal string for source insertion.
160+
*/
161+
function formatSnapshotAsTemplateLiteral(
162+
snapshot: string,
163+
baseIndent: string,
164+
): string {
165+
const escaped = escapeForTemplateLiteral(snapshot);
166+
167+
if (!escaped.includes('\n')) {
168+
return '`' + escaped + '`';
169+
}
170+
171+
// Multiline: snapshot has \n prefix and \n suffix from addExtraLineBreaks.
172+
// Indent content lines and place closing backtick at baseIndent.
173+
const lines = escaped.split('\n');
174+
const result = lines.map((line, i) => {
175+
if (i === 0) {
176+
// Opening empty line (after the leading \n)
177+
return line;
178+
}
179+
if (i === lines.length - 1) {
180+
// Closing line: just the base indent before the backtick
181+
return baseIndent;
182+
}
183+
if (line === '') {
184+
return '';
185+
}
186+
return baseIndent + ' ' + line;
187+
});
188+
189+
return '`' + result.join('\n') + '`';
190+
}
191+
192+
/**
193+
* Find the offset of the closing paren that matches the open paren at
194+
* `openParenOffset`. Handles template literals inside the arguments.
195+
*/
196+
function findMatchingParen(source: string, openParenOffset: number): number {
197+
let depth = 1;
198+
let i = openParenOffset + 1;
199+
let inTemplate = false;
200+
201+
while (i < source.length && depth > 0) {
202+
const ch = source[i];
203+
204+
if (inTemplate) {
205+
if (ch === '\\') {
206+
i += 2;
207+
continue;
208+
}
209+
if (ch === '`') {
210+
inTemplate = false;
211+
}
212+
} else {
213+
if (ch === '`') {
214+
inTemplate = true;
215+
} else if (ch === '(') {
216+
depth++;
217+
} else if (ch === ')') {
218+
depth--;
219+
if (depth === 0) {
220+
return i;
221+
}
222+
}
223+
}
224+
225+
i++;
226+
}
227+
228+
return -1;
229+
}
230+
231+
/**
232+
* Write pending inline snapshots directly into the test source file.
233+
* Replaces argument of each `toMatchInlineSnapshot(...)` call at the
234+
* resolved line with the formatted snapshot template literal.
235+
*/
236+
export function saveInlineSnapshotsToSource(
237+
testPath: string,
238+
pendingSnapshots: Array<PendingInlineSnapshot>,
239+
): void {
240+
if (pendingSnapshots.length === 0) {
241+
return;
242+
}
243+
244+
// Deduplicate by line (last value wins)
245+
const byLine = new Map<number, PendingInlineSnapshot>();
246+
for (const snapshot of pendingSnapshots) {
247+
byLine.set(snapshot.line, snapshot);
248+
}
249+
250+
let source = fs.readFileSync(testPath, 'utf8');
251+
const originalLines = source.split('\n');
252+
253+
// Process in reverse line order so earlier offsets stay valid
254+
const sorted = [...byLine.values()].sort((a, b) => b.line - a.line);
255+
256+
for (const {line, snapshot} of sorted) {
257+
const lineContent = originalLines[line - 1];
258+
if (lineContent == null) {
259+
continue;
260+
}
261+
262+
const matcherIndex = lineContent.indexOf('toMatchInlineSnapshot');
263+
if (matcherIndex === -1) {
264+
continue;
265+
}
266+
267+
const parenIndex = lineContent.indexOf('(', matcherIndex);
268+
if (parenIndex === -1) {
269+
continue;
270+
}
271+
272+
// Compute character offset of the open paren in the current source
273+
let lineOffset = 0;
274+
for (let i = 0; i < line - 1; i++) {
275+
lineOffset += originalLines[i].length + 1;
276+
}
277+
const openParenOffset = lineOffset + parenIndex;
278+
279+
const closeParenOffset = findMatchingParen(source, openParenOffset);
280+
if (closeParenOffset === -1) {
281+
continue;
282+
}
283+
284+
const baseIndent = lineContent.match(/^(\s*)/)?.[1] ?? '';
285+
const formatted = formatSnapshotAsTemplateLiteral(snapshot, baseIndent);
286+
287+
source =
288+
source.slice(0, openParenOffset + 1) +
289+
formatted +
290+
source.slice(closeParenOffset);
291+
}
292+
293+
fs.writeFileSync(testPath, source, 'utf8');
294+
}
295+
49296
export const updateSnapshotsAndGetJestSnapshotResult = (
50297
snapshotState: SnapshotState,
51298
testSnapshotResults: Array<TestSnapshotResults>,

private/react-native-fantom/runtime/expect.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,30 @@ class Expect {
528528
}
529529
}
530530

531+
toMatchInlineSnapshot(expected?: string): void {
532+
if (this.#isNot) {
533+
throw new ErrorWithCustomBlame(
534+
'Snapshot matchers cannot be used with not.',
535+
).blameToPreviousFrame();
536+
}
537+
538+
const receivedValue = format(this.#received, {
539+
plugins: [plugins.ReactElement],
540+
});
541+
542+
const stackTrace = new Error().stack ?? '';
543+
544+
try {
545+
snapshotContext.toMatchInlineSnapshot(
546+
receivedValue,
547+
expected,
548+
stackTrace,
549+
);
550+
} catch (err) {
551+
throw new ErrorWithCustomBlame(err.message).blameToPreviousFrame();
552+
}
553+
}
554+
531555
#isExpectedResult(pass: boolean): boolean {
532556
return this.#isNot ? !pass : pass;
533557
}

0 commit comments

Comments
 (0)