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');
+ });
+ });
});