diff --git a/packages/react-native-fantom/runner/runner.js b/packages/react-native-fantom/runner/runner.js index 1d5f1007b908ed..99628faabe0a77 100644 --- a/packages/react-native-fantom/runner/runner.js +++ b/packages/react-native-fantom/runner/runner.js @@ -14,6 +14,10 @@ import type {TestSuiteResult} from '../runtime/setup'; import entrypointTemplate from './entrypoint-template'; import getFantomTestConfig from './getFantomTestConfig'; import {FantomTestConfigMode} from './getFantomTestConfig'; +import { + getInitialSnapshotData, + updateSnapshotsAndGetJestSnapshotResult, +} from './snapshotUtils'; import { getBuckModeForPlatform, getDebugInfoFromCommandResult, @@ -135,7 +139,7 @@ module.exports = async function runTest( featureFlags: testConfig.flags.jsOnly, snapshotConfig: { updateSnapshot: snapshotState._updateSnapshot, - data: snapshotState._initialData, + data: getInitialSnapshotData(snapshotState), }, }); @@ -221,6 +225,15 @@ module.exports = async function runTest( ), })) ?? []; + const snapshotResults = nullthrows( + rnTesterParsedOutput.testResult.testResults, + ).map(testResult => testResult.snapshotResults); + + const snapshotResult = updateSnapshotsAndGetJestSnapshotResult( + snapshotState, + snapshotResults, + ); + return { testFilePath: testPath, failureMessage: formatResultsErrors( @@ -238,15 +251,7 @@ module.exports = async function runTest( runtime: endTime - startTime, slow: false, }, - snapshot: { - added: 0, - fileDeleted: false, - matched: 0, - unchecked: 0, - uncheckedKeys: [], - unmatched: 0, - updated: 0, - }, + snapshot: snapshotResult, numTotalTests: testResults.length, numPassingTests: testResults.filter(test => test.status === 'passed') .length, diff --git a/packages/react-native-fantom/runner/snapshotUtils.js b/packages/react-native-fantom/runner/snapshotUtils.js new file mode 100644 index 00000000000000..5f2e7916fbc0fc --- /dev/null +++ b/packages/react-native-fantom/runner/snapshotUtils.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow strict-local + * @format + * @oncall react_native + */ + +import type {TestSnapshotResults} from '../runtime/snapshotContext'; +import type {SnapshotState} from 'jest-snapshot'; + +type JestSnapshotResult = { + added: number, + fileDeleted: boolean, + matched: number, + unchecked: number, + uncheckedKeys: string[], + unmatched: number, + updated: number, +}; + +// Add extra line breaks at beginning and end of multiline snapshot +// to make the content easier to read. +const addExtraLineBreaks = (string: string): string => + string.includes('\n') ? `\n${string}\n` : string; + +// Remove extra line breaks at beginning and end of multiline snapshot. +// Instead of trim, which can remove additional newlines or spaces +// at beginning or end of the content from a custom serializer. +const removeExtraLineBreaks = (string: string): string => + string.length > 2 && string.startsWith('\n') && string.endsWith('\n') + ? string.slice(1, -1) + : string; + +export const getInitialSnapshotData = ( + snapshotState: SnapshotState, +): {[key: string]: string} => { + const initialData: {[key: string]: string} = {}; + + for (const key in snapshotState._initialData) { + initialData[key] = removeExtraLineBreaks(snapshotState._initialData[key]); + } + + return initialData; +}; + +export const updateSnapshotsAndGetJestSnapshotResult = ( + snapshotState: SnapshotState, + testSnapshotResults: Array, +): JestSnapshotResult => { + for (const snapshotResults of testSnapshotResults) { + for (const [key, result] of Object.entries(snapshotResults)) { + if (result.pass) { + snapshotState.matched++; + snapshotState._uncheckedKeys.delete(key); + continue; + } + + if (snapshotState._snapshotData[key] === undefined) { + if (snapshotState._updateSnapshot === 'none') { + snapshotState.unmatched++; + continue; + } + + snapshotState._dirty = true; + snapshotState._snapshotData[key] = addExtraLineBreaks(result.value); + snapshotState.added++; + snapshotState.matched++; + snapshotState._uncheckedKeys.delete(key); + + continue; + } + + snapshotState._dirty = true; + snapshotState._snapshotData[key] = addExtraLineBreaks(result.value); + snapshotState.updated++; + snapshotState._uncheckedKeys.delete(key); + } + } + + const uncheckedCount = snapshotState.getUncheckedCount(); + const uncheckedKeys = snapshotState.getUncheckedKeys(); + if (uncheckedCount) { + snapshotState.removeUncheckedKeys(); + } + + const status = snapshotState.save(); + return { + added: snapshotState.added, + fileDeleted: status.deleted, + matched: snapshotState.matched, + unchecked: status.deleted ? 0 : snapshotState.getUncheckedCount(), + uncheckedKeys: [...uncheckedKeys], + unmatched: snapshotState.unmatched, + updated: snapshotState.updated, + }; +}; diff --git a/packages/react-native-fantom/runtime/expect.js b/packages/react-native-fantom/runtime/expect.js index b52d41a4b81849..590abcd11d8c4d 100644 --- a/packages/react-native-fantom/runtime/expect.js +++ b/packages/react-native-fantom/runtime/expect.js @@ -15,8 +15,6 @@ import deepEqual from 'deep-equal'; import {diff} from 'jest-diff'; import {format, plugins} from 'pretty-format'; -const COMPARISON_EQUALS_STRING = 'Compared values have no visual difference.'; - class ErrorWithCustomBlame extends Error { // Initially 5 to ignore all the frames from Babel helpers to instantiate this // custom error class. @@ -260,20 +258,14 @@ class Expect { ).blameToPreviousFrame(); } - const [err, currentSnapshot] = snapshotContext.getSnapshot(expected); - if (err != null) { - throw new ErrorWithCustomBlame(err).blameToPreviousFrame(); - } - const receivedValue = format(this.#received, { plugins: [plugins.ReactElement], }); - const result = - diff(currentSnapshot, receivedValue) ?? 'Failed to compare outputs'; - if (result !== COMPARISON_EQUALS_STRING) { - throw new ErrorWithCustomBlame( - `Expected to match snapshot.\n${result}`, - ).blameToPreviousFrame(); + + try { + snapshotContext.toMatchSnapshot(receivedValue, expected); + } catch (err) { + throw new ErrorWithCustomBlame(err.message).blameToPreviousFrame(); } } diff --git a/packages/react-native-fantom/runtime/setup.js b/packages/react-native-fantom/runtime/setup.js index 6fa4652e496140..a3c77d99469798 100644 --- a/packages/react-native-fantom/runtime/setup.js +++ b/packages/react-native-fantom/runtime/setup.js @@ -9,7 +9,7 @@ * @oncall react_native */ -import type {SnapshotConfig} from './snapshotContext'; +import type {SnapshotConfig, TestSnapshotResults} from './snapshotContext'; import expect from './expect'; import {createMockFunction} from './mocks'; @@ -24,6 +24,7 @@ export type TestCaseResult = { duration: number, failureMessages: Array, numPassingAsserts: number, + snapshotResults: TestSnapshotResults, // location: string, }; @@ -38,6 +39,13 @@ export type TestSuiteResult = }, }; +type SnapshotState = { + name: string, + snapshotResults: TestSnapshotResults, +}; + +let currentSnapshotState: SnapshotState; + const tests: Array<{ title: string, ancestorTitles: Array, @@ -152,6 +160,7 @@ function executeTests() { duration: 0, failureMessages: [], numPassingAsserts: 0, + snapshotResults: {}, }; test.result = result; @@ -177,6 +186,8 @@ function executeTests() { status === 'failed' && error ? [error.stack ?? error.message ?? String(error)] : []; + + result.snapshotResults = snapshotContext.getSnapshotResults(); } } diff --git a/packages/react-native-fantom/runtime/snapshotContext.js b/packages/react-native-fantom/runtime/snapshotContext.js index 6d00ad981e7a8e..03a55a665cfae8 100644 --- a/packages/react-native-fantom/runtime/snapshotContext.js +++ b/packages/react-native-fantom/runtime/snapshotContext.js @@ -9,61 +9,100 @@ * @oncall react_native */ +import {diff} from 'jest-diff'; + export type SnapshotConfig = { updateSnapshot: 'all' | 'new' | 'none', data: {[key: string]: string}, }; +export type TestSnapshotResults = { + [key: string]: + | { + pass: true, + } + | { + pass: false, + value: string, + }, +}; + +const COMPARISON_EQUALS_STRING = 'Compared values have no visual difference.'; + let snapshotConfig: ?SnapshotConfig; -// Destructure [err, value] from the return value of getSnapshot -type SnapshotResponse = [null, string] | [string, void]; +type SnapshotState = { + callCount: number, + testFullName: string, + snapshotResults: TestSnapshotResults, +}; -class SnapshotState { - #callCount: number = 0; - #testFullName: string; +class SnapshotContext { + #snapshotState: ?SnapshotState = null; - constructor(name: string) { - this.#testFullName = name; + setTargetTest(testFullName: string) { + this.#snapshotState = { + callCount: 0, + testFullName, + snapshotResults: {}, + }; } - getSnapshot(label: ?string): SnapshotResponse { - const snapshotKey = `${this.#testFullName}${ + toMatchSnapshot(received: string, label: ?string): void { + const snapshotState = this.#snapshotState; + if (snapshotState == null) { + throw new Error( + 'Snapshot state is not set, call `setTargetTest()` first', + ); + } + + const snapshotKey = `${snapshotState.testFullName}${ label != null ? `: ${label}` : '' - } ${++this.#callCount}`; + } ${++snapshotState.callCount}`; if (snapshotConfig == null) { - return [ + throw new Error( 'Snapshot config is not set. Did you forget to call `setupSnapshotConfig`?', - undefined, - ]; + ); } - if (snapshotConfig.data[snapshotKey] == null) { - return [ - `Expected to have snapshot \`${snapshotKey}\` but it was not found.`, - undefined, - ]; + const updateSnapshot = snapshotConfig.updateSnapshot; + const snapshot = snapshotConfig.data[snapshotKey]; + + if (snapshot == null) { + snapshotState.snapshotResults[snapshotKey] = { + pass: false, + value: received, + }; + + if (updateSnapshot === 'none') { + throw new Error( + `Expected to have snapshot \`${snapshotKey}\` but it was not found.`, + ); + } + + return; } - return [null, snapshotConfig.data[snapshotKey]]; - } -} + const result = diff(snapshot, received) ?? 'Failed to compare output'; + if (result !== COMPARISON_EQUALS_STRING) { + snapshotState.snapshotResults[snapshotKey] = { + pass: false, + value: received, + }; -class SnapshotContext { - #snapshotState: ?SnapshotState = null; + if (updateSnapshot !== 'all') { + throw new Error(`Expected to match snapshot.\n${result}`); + } - setTargetTest(testFullName: string) { - this.#snapshotState = new SnapshotState(testFullName); + return; + } + + snapshotState.snapshotResults[snapshotKey] = {pass: true}; } - getSnapshot(label: ?string): SnapshotResponse { - return ( - this.#snapshotState?.getSnapshot(label) ?? [ - 'Snapshot state is not set, call `setTargetTest()` first', - undefined, - ] - ); + getSnapshotResults(): TestSnapshotResults { + return {...this.#snapshotState?.snapshotResults}; } } diff --git a/packages/react-native-fantom/src/__tests__/__snapshots__/expect-itest.js.snap b/packages/react-native-fantom/src/__tests__/__snapshots__/expect-itest.js.snap index 1b549e08a4e2f6..29c9d4ecee4f5f 100644 --- a/packages/react-native-fantom/src/__tests__/__snapshots__/expect-itest.js.snap +++ b/packages/react-native-fantom/src/__tests__/__snapshots__/expect-itest.js.snap @@ -1,19 +1,68 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`expect toMatchSnapshot() primitive types 1`] = `null` +exports[`expect toMatchSnapshot() complex types 1`] = ` +Object { + "foo": "bar", +} +`; -exports[`expect toMatchSnapshot() primitive types 2`] = `1` +exports[`expect toMatchSnapshot() complex types 2`] = ` + + hello + +`; -exports[`expect toMatchSnapshot() primitive types 3`] = `"foo"` +exports[`expect toMatchSnapshot() complex types 3`] = `[Function foo]`; -exports[`expect toMatchSnapshot() complex types 1`] = `Object { - "foo": "bar", -}` +exports[`expect toMatchSnapshot() complex types 4`] = ` +Map { + "foo" => "bar", +} +`; -exports[`expect toMatchSnapshot() complex types 2`] = ` - hello -` +exports[`expect toMatchSnapshot() complex types 5`] = ` +Set { + 1, + 2, +} +`; + +exports[`expect toMatchSnapshot() complex types 6`] = `2025-01-02T00:00:00.000Z`; + +exports[`expect toMatchSnapshot() complex types 7`] = `[Error]`; + +exports[`expect toMatchSnapshot() complex types 8`] = `/asd/`; + +exports[`expect toMatchSnapshot() complex types 9`] = ` +Promise { + "_h": 0, + "_i": 0, + "_j": null, + "_k": null, +} +`; -exports[`expect toMatchSnapshot() named snapshots: named snapshot 1`] = `Object { +exports[`expect toMatchSnapshot() named snapshots: named snapshot 1`] = ` +Object { "a": "b", -}` +} +`; + +exports[`expect toMatchSnapshot() primitive types 1`] = `undefined`; + +exports[`expect toMatchSnapshot() primitive types 2`] = `null`; + +exports[`expect toMatchSnapshot() primitive types 3`] = `true`; + +exports[`expect toMatchSnapshot() primitive types 4`] = `1`; + +exports[`expect toMatchSnapshot() primitive types 5`] = `1n`; + +exports[`expect toMatchSnapshot() primitive types 6`] = `"foo"`; + +exports[`expect toMatchSnapshot() primitive types 8`] = `Symbol(foo)`; + +exports[`expect toMatchSnapshot() primitive types: multiline 7`] = ` +"foo +bar" +`; diff --git a/packages/react-native-fantom/src/__tests__/expect-itest.js b/packages/react-native-fantom/src/__tests__/expect-itest.js index 82a254df02a709..fb633b038d3ea9 100644 --- a/packages/react-native-fantom/src/__tests__/expect-itest.js +++ b/packages/react-native-fantom/src/__tests__/expect-itest.js @@ -517,14 +517,26 @@ describe('expect', () => { describe('toMatchSnapshot()', () => { test('primitive types', () => { + expect(undefined).toMatchSnapshot(); expect(null).toMatchSnapshot(); + expect(true).toMatchSnapshot(); expect(1).toMatchSnapshot(); + expect(BigInt(1)).toMatchSnapshot(); expect('foo').toMatchSnapshot(); + expect('foo\nbar').toMatchSnapshot('multiline'); + expect(Symbol('foo')).toMatchSnapshot(); }); test('complex types', () => { expect({foo: 'bar'}).toMatchSnapshot(); expect(hello).toMatchSnapshot(); + expect(function foo() {}).toMatchSnapshot(); + expect(new Map([['foo', 'bar']])).toMatchSnapshot(); + expect(new Set([1, 2])).toMatchSnapshot(); + expect(new Date('2025-01-02')).toMatchSnapshot(); + expect(new Error()).toMatchSnapshot(); + expect(new RegExp('asd')).toMatchSnapshot(); + expect(new Promise(() => {})).toMatchSnapshot(); }); test('named snapshots', () => {