packages/jest-snapshot/src/utils.ts (204 lines of code) (raw):
/**
* Copyright (c) Facebook, Inc. and its affiliates. All Rights Reserved.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import * as path from 'path';
import chalk = require('chalk');
import * as fs from 'graceful-fs';
import naturalCompare = require('natural-compare');
import type {Config} from '@jest/types';
import {
OptionsReceived as PrettyFormatOptions,
format as prettyFormat,
} from 'pretty-format';
import {getSerializers} from './plugins';
import type {SnapshotData} from './types';
export const SNAPSHOT_VERSION = '1';
const SNAPSHOT_VERSION_REGEXP = /^\/\/ Jest Snapshot v(.+),/;
export const SNAPSHOT_GUIDE_LINK = 'https://goo.gl/fbAQLP';
export const SNAPSHOT_VERSION_WARNING = chalk.yellow(
`${chalk.bold('Warning')}: Before you upgrade snapshots, ` +
'we recommend that you revert any local changes to tests or other code, ' +
'to ensure that you do not store invalid state.',
);
const writeSnapshotVersion = () =>
`// Jest Snapshot v${SNAPSHOT_VERSION}, ${SNAPSHOT_GUIDE_LINK}`;
const validateSnapshotVersion = (snapshotContents: string) => {
const versionTest = SNAPSHOT_VERSION_REGEXP.exec(snapshotContents);
const version = versionTest && versionTest[1];
if (!version) {
return new Error(
chalk.red(
`${chalk.bold('Outdated snapshot')}: No snapshot header found. ` +
'Jest 19 introduced versioned snapshots to ensure all developers ' +
'on a project are using the same version of Jest. ' +
'Please update all snapshots during this upgrade of Jest.\n\n',
) + SNAPSHOT_VERSION_WARNING,
);
}
if (version < SNAPSHOT_VERSION) {
return new Error(
// eslint-disable-next-line prefer-template
chalk.red(
`${chalk.red.bold('Outdated snapshot')}: The version of the snapshot ` +
'file associated with this test is outdated. The snapshot file ' +
'version ensures that all developers on a project are using ' +
'the same version of Jest. ' +
'Please update all snapshots during this upgrade of Jest.',
) +
'\n\n' +
`Expected: v${SNAPSHOT_VERSION}\n` +
`Received: v${version}\n\n` +
SNAPSHOT_VERSION_WARNING,
);
}
if (version > SNAPSHOT_VERSION) {
return new Error(
// eslint-disable-next-line prefer-template
chalk.red(
`${chalk.red.bold('Outdated Jest version')}: The version of this ` +
'snapshot file indicates that this project is meant to be used ' +
'with a newer version of Jest. The snapshot file version ensures ' +
'that all developers on a project are using the same version of ' +
'Jest. Please update your version of Jest and re-run the tests.',
) +
'\n\n' +
`Expected: v${SNAPSHOT_VERSION}\n` +
`Received: v${version}`,
);
}
return null;
};
function isObject(item: unknown): boolean {
return item != null && typeof item === 'object' && !Array.isArray(item);
}
export const testNameToKey = (testName: string, count: number): string =>
`${testName} ${count}`;
export const keyToTestName = (key: string): string => {
if (!/ \d+$/.test(key)) {
throw new Error('Snapshot keys must end with a number.');
}
return key.replace(/ \d+$/, '');
};
export const getSnapshotData = (
snapshotPath: string,
update: Config.SnapshotUpdateState,
): {
data: SnapshotData;
dirty: boolean;
} => {
const data = Object.create(null);
let snapshotContents = '';
let dirty = false;
if (fs.existsSync(snapshotPath)) {
try {
snapshotContents = fs.readFileSync(snapshotPath, 'utf8');
// eslint-disable-next-line no-new-func
const populate = new Function('exports', snapshotContents);
populate(data);
} catch {}
}
const validationResult = validateSnapshotVersion(snapshotContents);
const isInvalid = snapshotContents && validationResult;
if (update === 'none' && isInvalid) {
throw validationResult;
}
if ((update === 'all' || update === 'new') && isInvalid) {
dirty = true;
}
return {data, dirty};
};
// Add extra line breaks at beginning and end of multiline snapshot
// to make the content easier to read.
export 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.
export const removeExtraLineBreaks = (string: string): string =>
string.length > 2 && string.startsWith('\n') && string.endsWith('\n')
? string.slice(1, -1)
: string;
export const removeLinesBeforeExternalMatcherTrap = (stack: string): string => {
const lines = stack.split('\n');
for (let i = 0; i < lines.length; i += 1) {
// It's a function name specified in `packages/expect/src/index.ts`
// for external custom matchers.
if (lines[i].includes('__EXTERNAL_MATCHER_TRAP__')) {
return lines.slice(i + 1).join('\n');
}
}
return stack;
};
const escapeRegex = true;
const printFunctionName = false;
export const serialize = (
val: unknown,
indent = 2,
formatOverrides: PrettyFormatOptions = {},
): string =>
normalizeNewlines(
prettyFormat(val, {
escapeRegex,
indent,
plugins: getSerializers(),
printFunctionName,
...formatOverrides,
}),
);
export const minify = (val: unknown): string =>
prettyFormat(val, {
escapeRegex,
min: true,
plugins: getSerializers(),
printFunctionName,
});
// Remove double quote marks and unescape double quotes and backslashes.
export const deserializeString = (stringified: string): string =>
stringified.slice(1, -1).replace(/\\("|\\)/g, '$1');
export const escapeBacktickString = (str: string): string =>
str.replace(/`|\\|\${/g, '\\$&');
const printBacktickString = (str: string): string =>
`\`${escapeBacktickString(str)}\``;
export const ensureDirectoryExists = (filePath: string): void => {
try {
fs.mkdirSync(path.join(path.dirname(filePath)), {recursive: true});
} catch {}
};
const normalizeNewlines = (string: string) => string.replace(/\r\n|\r/g, '\n');
export const saveSnapshotFile = (
snapshotData: SnapshotData,
snapshotPath: string,
): void => {
const snapshots = Object.keys(snapshotData)
.sort(naturalCompare)
.map(
key =>
`exports[${printBacktickString(key)}] = ${printBacktickString(
normalizeNewlines(snapshotData[key]),
)};`,
);
ensureDirectoryExists(snapshotPath);
fs.writeFileSync(
snapshotPath,
`${writeSnapshotVersion()}\n\n${snapshots.join('\n\n')}\n`,
);
};
const deepMergeArray = (target: Array<any>, source: Array<any>) => {
const mergedOutput = Array.from(target);
source.forEach((sourceElement, index) => {
const targetElement = mergedOutput[index];
if (Array.isArray(target[index])) {
mergedOutput[index] = deepMergeArray(target[index], sourceElement);
} else if (isObject(targetElement)) {
mergedOutput[index] = deepMerge(target[index], sourceElement);
} else {
// Source does not exist in target or target is primitive and cannot be deep merged
mergedOutput[index] = sourceElement;
}
});
return mergedOutput;
};
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
export const deepMerge = (target: any, source: any): any => {
if (isObject(target) && isObject(source)) {
const mergedOutput = {...target};
Object.keys(source).forEach(key => {
if (isObject(source[key]) && !source[key].$$typeof) {
if (!(key in target)) Object.assign(mergedOutput, {[key]: source[key]});
else mergedOutput[key] = deepMerge(target[key], source[key]);
} else if (Array.isArray(source[key])) {
mergedOutput[key] = deepMergeArray(target[key], source[key]);
} else {
Object.assign(mergedOutput, {[key]: source[key]});
}
});
return mergedOutput;
} else if (Array.isArray(target) && Array.isArray(source)) {
return deepMergeArray(target, source);
}
return target;
};