Skip to content

Commit

Permalink
Add snapshot update / delete capability (facebook#48096)
Browse files Browse the repository at this point in the history
Summary:
Pull Request resolved: facebook#48096

Changelog: [Internal]

Managing snapshot state internally by adding / updating snapshot data.

Reviewed By: christophpurrer

Differential Revision: D66707175

fbshipit-source-id: 0366d834eafa0ca702f03de0210392181fd90a58
  • Loading branch information
andrewdacenko authored and facebook-github-bot committed Dec 10, 2024
1 parent a8a136f commit a298cca
Show file tree
Hide file tree
Showing 7 changed files with 275 additions and 67 deletions.
25 changes: 15 additions & 10 deletions packages/react-native-fantom/runner/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -135,7 +139,7 @@ module.exports = async function runTest(
featureFlags: testConfig.flags.jsOnly,
snapshotConfig: {
updateSnapshot: snapshotState._updateSnapshot,
data: snapshotState._initialData,
data: getInitialSnapshotData(snapshotState),
},
});

Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
100 changes: 100 additions & 0 deletions packages/react-native-fantom/runner/snapshotUtils.js
Original file line number Diff line number Diff line change
@@ -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<TestSnapshotResults>,
): 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,
};
};
18 changes: 5 additions & 13 deletions packages/react-native-fantom/runtime/expect.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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();
}
}

Expand Down
13 changes: 12 additions & 1 deletion packages/react-native-fantom/runtime/setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -24,6 +24,7 @@ export type TestCaseResult = {
duration: number,
failureMessages: Array<string>,
numPassingAsserts: number,
snapshotResults: TestSnapshotResults,
// location: string,
};

Expand All @@ -38,6 +39,13 @@ export type TestSuiteResult =
},
};

type SnapshotState = {
name: string,
snapshotResults: TestSnapshotResults,
};

let currentSnapshotState: SnapshotState;

const tests: Array<{
title: string,
ancestorTitles: Array<string>,
Expand Down Expand Up @@ -152,6 +160,7 @@ function executeTests() {
duration: 0,
failureMessages: [],
numPassingAsserts: 0,
snapshotResults: {},
};

test.result = result;
Expand All @@ -177,6 +186,8 @@ function executeTests() {
status === 'failed' && error
? [error.stack ?? error.message ?? String(error)]
: [];

result.snapshotResults = snapshotContext.getSnapshotResults();
}
}

Expand Down
103 changes: 71 additions & 32 deletions packages/react-native-fantom/runtime/snapshotContext.js
Original file line number Diff line number Diff line change
Expand Up @@ -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};
}
}

Expand Down
Loading

0 comments on commit a298cca

Please sign in to comment.