diff --git a/package.json b/package.json index 5801862c88d..93048ff6537 100644 --- a/package.json +++ b/package.json @@ -85,6 +85,7 @@ "inquirer": "^7.1.0", "jest": "^29.6.3", "jest-diff": "^29.7.0", + "jest-snapshot": "^29.7.0", "jest-junit": "^10.0.0", "jscodeshift": "^0.14.0", "metro-babel-register": "^0.81.0", diff --git a/packages/react-native-fantom/runner/entrypoint-template.js b/packages/react-native-fantom/runner/entrypoint-template.js index 872b21ea1f8..c52c66209e0 100644 --- a/packages/react-native-fantom/runner/entrypoint-template.js +++ b/packages/react-native-fantom/runner/entrypoint-template.js @@ -9,6 +9,7 @@ * @oncall react_native */ +import type {SnapshotConfig} from '../runtime/snapshotContext'; import type {FantomTestConfigJsOnlyFeatureFlags} from './getFantomTestConfig'; module.exports = function entrypointTemplate({ @@ -16,11 +17,13 @@ module.exports = function entrypointTemplate({ setupModulePath, featureFlagsModulePath, featureFlags, + snapshotConfig, }: { testPath: string, setupModulePath: string, featureFlagsModulePath: string, featureFlags: FantomTestConfigJsOnlyFeatureFlags, + snapshotConfig: SnapshotConfig, }): string { return `/** * Copyright (c) Meta Platforms, Inc. and affiliates. @@ -47,6 +50,6 @@ ${Object.entries(featureFlags) : '' } -registerTest(() => require('${testPath}')); +registerTest(() => require('${testPath}'), ${JSON.stringify(snapshotConfig)}); `; }; diff --git a/packages/react-native-fantom/runner/runner.js b/packages/react-native-fantom/runner/runner.js index 93fc4e5b8dc..1d5f1007b90 100644 --- a/packages/react-native-fantom/runner/runner.js +++ b/packages/react-native-fantom/runner/runner.js @@ -24,6 +24,7 @@ import { import fs from 'fs'; // $FlowExpectedError[untyped-import] import {formatResultsErrors} from 'jest-message-util'; +import {SnapshotState, buildSnapshotResolver} from 'jest-snapshot'; import Metro from 'metro'; import nullthrows from 'nullthrows'; import path from 'path'; @@ -90,12 +91,29 @@ function generateBytecodeBundle({ } module.exports = async function runTest( - globalConfig: {...}, - config: {...}, + globalConfig: { + updateSnapshot: 'all' | 'new' | 'none', + ... + }, + config: { + rootDir: string, + prettierPath: string, + snapshotFormat: {...}, + ... + }, environment: {...}, runtime: {...}, testPath: string, ): mixed { + const snapshotResolver = await buildSnapshotResolver(config); + const snapshotPath = snapshotResolver.resolveSnapshotPath(testPath); + const snapshotState = new SnapshotState(snapshotPath, { + updateSnapshot: globalConfig.updateSnapshot, + snapshotFormat: config.snapshotFormat, + prettierPath: config.prettierPath, + rootDir: config.rootDir, + }); + const startTime = Date.now(); const testConfig = getFantomTestConfig(testPath); @@ -115,6 +133,10 @@ module.exports = async function runTest( setupModulePath: `${path.relative(BUILD_OUTPUT_PATH, setupModulePath)}`, featureFlagsModulePath: `${path.relative(BUILD_OUTPUT_PATH, featureFlagsModulePath)}`, featureFlags: testConfig.flags.jsOnly, + snapshotConfig: { + updateSnapshot: snapshotState._updateSnapshot, + data: snapshotState._initialData, + }, }); const entrypointPath = path.join( diff --git a/packages/react-native-fantom/runtime/expect.js b/packages/react-native-fantom/runtime/expect.js index 5e6728950eb..b52d41a4b81 100644 --- a/packages/react-native-fantom/runtime/expect.js +++ b/packages/react-native-fantom/runtime/expect.js @@ -10,8 +10,12 @@ */ import {ensureMockFunction} from './mocks'; +import {snapshotContext} from './snapshotContext'; 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 @@ -249,6 +253,30 @@ class Expect { } } + toMatchSnapshot(expected?: string): void { + if (this.#isNot) { + throw new ErrorWithCustomBlame( + 'Snapshot matchers cannot be used with not.', + ).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(); + } + } + #isExpectedResult(pass: boolean): boolean { return this.#isNot ? !pass : pass; } diff --git a/packages/react-native-fantom/runtime/setup.js b/packages/react-native-fantom/runtime/setup.js index fc8321532b4..6fa4652e496 100644 --- a/packages/react-native-fantom/runtime/setup.js +++ b/packages/react-native-fantom/runtime/setup.js @@ -9,8 +9,11 @@ * @oncall react_native */ +import type {SnapshotConfig} from './snapshotContext'; + import expect from './expect'; import {createMockFunction} from './mocks'; +import {setupSnapshotConfig, snapshotContext} from './snapshotContext'; import nullthrows from 'nullthrows'; export type TestCaseResult = { @@ -152,6 +155,7 @@ function executeTests() { }; test.result = result; + snapshotContext.setTargetTest(result.fullName); if (!test.isSkipped && (!hasFocusedTests || test.isFocused)) { let status; @@ -189,7 +193,12 @@ global.$$RunTests$$ = () => { executeTests(); }; -export function registerTest(setUpTest: () => void) { +export function registerTest( + setUpTest: () => void, + snapshotConfig: SnapshotConfig, +) { + setupSnapshotConfig(snapshotConfig); + runWithGuard(() => { setUpTest(); }); diff --git a/packages/react-native-fantom/runtime/snapshotContext.js b/packages/react-native-fantom/runtime/snapshotContext.js new file mode 100644 index 00000000000..6d00ad981e7 --- /dev/null +++ b/packages/react-native-fantom/runtime/snapshotContext.js @@ -0,0 +1,74 @@ +/** + * 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 + */ + +export type SnapshotConfig = { + updateSnapshot: 'all' | 'new' | 'none', + data: {[key: string]: string}, +}; + +let snapshotConfig: ?SnapshotConfig; + +// Destructure [err, value] from the return value of getSnapshot +type SnapshotResponse = [null, string] | [string, void]; + +class SnapshotState { + #callCount: number = 0; + #testFullName: string; + + constructor(name: string) { + this.#testFullName = name; + } + + getSnapshot(label: ?string): SnapshotResponse { + const snapshotKey = `${this.#testFullName}${ + label != null ? `: ${label}` : '' + } ${++this.#callCount}`; + + if (snapshotConfig == null) { + return [ + '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, + ]; + } + + return [null, snapshotConfig.data[snapshotKey]]; + } +} + +class SnapshotContext { + #snapshotState: ?SnapshotState = null; + + setTargetTest(testFullName: string) { + this.#snapshotState = new SnapshotState(testFullName); + } + + getSnapshot(label: ?string): SnapshotResponse { + return ( + this.#snapshotState?.getSnapshot(label) ?? [ + 'Snapshot state is not set, call `setTargetTest()` first', + undefined, + ] + ); + } +} + +export const snapshotContext: SnapshotContext = new SnapshotContext(); + +export function setupSnapshotConfig(config: SnapshotConfig) { + snapshotConfig = config; +} 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 new file mode 100644 index 00000000000..1b549e08a4e --- /dev/null +++ b/packages/react-native-fantom/src/__tests__/__snapshots__/expect-itest.js.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`expect toMatchSnapshot() primitive types 1`] = `null` + +exports[`expect toMatchSnapshot() primitive types 2`] = `1` + +exports[`expect toMatchSnapshot() primitive types 3`] = `"foo"` + +exports[`expect toMatchSnapshot() complex types 1`] = `Object { + "foo": "bar", +}` + +exports[`expect toMatchSnapshot() complex types 2`] = ` + hello +` + +exports[`expect toMatchSnapshot() named snapshots: named snapshot 1`] = `Object { + "a": "b", +}` diff --git a/packages/react-native-fantom/src/__tests__/expect-itest.js b/packages/react-native-fantom/src/__tests__/expect-itest.js index 9db94a70e3d..82a254df02a 100644 --- a/packages/react-native-fantom/src/__tests__/expect-itest.js +++ b/packages/react-native-fantom/src/__tests__/expect-itest.js @@ -9,6 +9,8 @@ * @oncall react_native */ +import * as React from 'react'; + function ensureError(fn: () => void): void { try { fn(); @@ -512,4 +514,21 @@ describe('expect', () => { expect(1).not.toBeGreaterThanOrEqual('string value'); }).toThrow(); }); + + describe('toMatchSnapshot()', () => { + test('primitive types', () => { + expect(null).toMatchSnapshot(); + expect(1).toMatchSnapshot(); + expect('foo').toMatchSnapshot(); + }); + + test('complex types', () => { + expect({foo: 'bar'}).toMatchSnapshot(); + expect(hello).toMatchSnapshot(); + }); + + test('named snapshots', () => { + expect({a: 'b'}).toMatchSnapshot('named snapshot'); + }); + }); });