diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f72957d6254..8902fd6d309d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - `[jest-haste-map]` Add `getFileIterator` to `HasteFS` for faster file iteration ([#7010](https://github.com/facebook/jest/pull/7010)). - `[jest-worker]` [**BREAKING**] Add functionality to call a `setup` method in the worker before the first call and a `teardown` method when ending the farm ([#7014](https://github.com/facebook/jest/pull/7014)). - `[jest-config]` [**BREAKING**] Set default `notifyMode` to `failure-change` ([#7024](https://github.com/facebook/jest/pull/7024)) +- `[jest-snapshot]` Enable configurable snapshot paths ([#6143](https://github.com/facebook/jest/pull/6143)) ### Fixes diff --git a/TestUtils.js b/TestUtils.js index 09956a281e41..f7d376858913 100644 --- a/TestUtils.js +++ b/TestUtils.js @@ -102,6 +102,7 @@ const DEFAULT_PROJECT_CONFIG: ProjectConfig = { setupTestFrameworkScriptFile: null, skipFilter: false, skipNodeResolution: false, + snapshotResolver: null, snapshotSerializers: [], testEnvironment: 'node', testEnvironmentOptions: {}, diff --git a/docs/Configuration.md b/docs/Configuration.md index 45f46910ecd1..5e68d10c74a0 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -642,6 +642,29 @@ If you want this path to be [relative to the root directory of your project](#ro For example, Jest ships with several plug-ins to `jasmine` that work by monkey-patching the jasmine API. If you wanted to add even more jasmine plugins to the mix (or if you wanted some custom, project-wide matchers for example), you could do so in this module. +### `snapshotResolver` [string] + +Default: `undefined` + +The path to a module that can resolve test<->snapshot path. This config option lets you customize where Jest stores that snapshot files on disk. + +Example snapshot resolver module: + +```js +// my-snapshot-resolver-module +module.exports = { + // resolves from test to snapshot path + resolveSnapshotPath: (testPath, snapshotExtension) => + testPath.replace('__tests__', '__snapshots__') + snapshotExtension, + + // resolves from snapshot to test path + resolveTestPath: (snapshotFilePath, snapshotExtension) => + snapshotFilePath + .replace('__snapshots__', '__tests__') + .slice(0, -snapshotExtension.length), +}; +``` + ### `snapshotSerializers` [array] Default: `[]` diff --git a/e2e/__tests__/snapshot_resolver.test.js b/e2e/__tests__/snapshot_resolver.test.js new file mode 100644 index 000000000000..aa1db8ee8f98 --- /dev/null +++ b/e2e/__tests__/snapshot_resolver.test.js @@ -0,0 +1,40 @@ +/** + * @flow + */ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const runJest = require('../runJest'); + +const snapshotDir = path.resolve( + __dirname, + '../snapshot-resolver/__snapshots__', +); +const snapshotFile = path.resolve(snapshotDir, 'snapshot.test.js.snap'); + +describe('Custom snapshot resolver', () => { + const cleanup = () => { + if (fs.existsSync(snapshotFile)) { + fs.unlinkSync(snapshotFile); + } + if (fs.existsSync(snapshotDir)) { + fs.rmdirSync(snapshotDir); + } + }; + + beforeEach(cleanup); + afterAll(cleanup); + + it('Resolves snapshot files using custom resolver', () => { + const result = runJest('snapshot-resolver', ['-w=1', '--ci=false']); + + expect(result.stderr).toMatch('1 snapshot written from 1 test suite'); + + // $FlowFixMe dynamic require + const content = require(snapshotFile); + expect(content).toHaveProperty( + 'snapshots are written to custom location 1', + ); + }); +}); diff --git a/e2e/snapshot-resolver/__tests__/snapshot.test.js b/e2e/snapshot-resolver/__tests__/snapshot.test.js new file mode 100644 index 000000000000..a7e8d335028e --- /dev/null +++ b/e2e/snapshot-resolver/__tests__/snapshot.test.js @@ -0,0 +1,3 @@ +test('snapshots are written to custom location', () => { + expect('foobar').toMatchSnapshot(); +}); diff --git a/e2e/snapshot-resolver/customSnapshotResolver.js b/e2e/snapshot-resolver/customSnapshotResolver.js new file mode 100644 index 000000000000..4b397f194551 --- /dev/null +++ b/e2e/snapshot-resolver/customSnapshotResolver.js @@ -0,0 +1,9 @@ +module.exports = { + resolveSnapshotPath: (testPath, snapshotExtension) => + testPath.replace('__tests__', '__snapshots__') + snapshotExtension, + + resolveTestPath: (snapshotFilePath, snapshotExtension) => + snapshotFilePath + .replace('__snapshots__', '__tests__') + .slice(0, -snapshotExtension.length), +}; diff --git a/e2e/snapshot-resolver/package.json b/e2e/snapshot-resolver/package.json new file mode 100644 index 000000000000..97737c2e5c58 --- /dev/null +++ b/e2e/snapshot-resolver/package.json @@ -0,0 +1,6 @@ +{ + "jest": { + "testEnvironment": "node", + "snapshotResolver": "/customSnapshotResolver.js" + } +} diff --git a/jest.config.js b/jest.config.js index 3bed6dd185ee..2f73f03df927 100644 --- a/jest.config.js +++ b/jest.config.js @@ -42,6 +42,7 @@ module.exports = { '/packages/jest-runtime/src/__tests__/module_dir/', '/packages/jest-runtime/src/__tests__/NODE_PATH_dir', '/packages/jest-snapshot/src/__tests__/plugins', + '/packages/jest-snapshot/src/__tests__/fixtures/', '/packages/jest-validate/src/__tests__/fixtures/', '/packages/jest-worker/src/__performance_tests__', '/packages/pretty-format/perf/test.js', diff --git a/packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js b/packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js index 47df43f83778..b4928305e7b8 100644 --- a/packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js +++ b/packages/jest-circus/src/legacy_code_todo_rewrite/jest_adapter_init.js @@ -13,7 +13,11 @@ import type {Event, TestEntry} from 'types/Circus'; import {extractExpectedAssertionsErrors, getState, setState} from 'expect'; import {formatExecError, formatResultsErrors} from 'jest-message-util'; -import {SnapshotState, addSerializer} from 'jest-snapshot'; +import { + SnapshotState, + addSerializer, + buildSnapshotResolver, +} from 'jest-snapshot'; import {addEventHandler, dispatch, ROOT_DESCRIBE_BLOCK_NAME} from '../state'; import {getTestID, getOriginalPromise} from '../utils'; import run from '../run'; @@ -96,7 +100,9 @@ export const initialize = ({ }); const {expand, updateSnapshot} = globalConfig; - const snapshotState = new SnapshotState(testPath, { + const snapshotResolver = buildSnapshotResolver(config); + const snapshotPath = snapshotResolver.resolveSnapshotPath(testPath); + const snapshotState = new SnapshotState(snapshotPath, { expand, getBabelTraverse, getPrettier, diff --git a/packages/jest-cli/src/SearchSource.js b/packages/jest-cli/src/SearchSource.js index 57caf26e8506..020425067b5a 100644 --- a/packages/jest-cli/src/SearchSource.js +++ b/packages/jest-cli/src/SearchSource.js @@ -18,6 +18,7 @@ import DependencyResolver from 'jest-resolve-dependencies'; import testPathPatternToRegExp from './testPathPatternToRegexp'; import {escapePathForRegex} from 'jest-regex-util'; import {replaceRootDirInPath} from 'jest-config'; +import {buildSnapshotResolver} from 'jest-snapshot'; type SearchResult = {| noSCM?: boolean, @@ -153,6 +154,7 @@ export default class SearchSource { const dependencyResolver = new DependencyResolver( this._context.resolver, this._context.hasteFS, + buildSnapshotResolver(this._context.config), ); const tests = toTests( diff --git a/packages/jest-cli/src/TestScheduler.js b/packages/jest-cli/src/TestScheduler.js index 7446d20b8ac6..498f90b98f98 100644 --- a/packages/jest-cli/src/TestScheduler.js +++ b/packages/jest-cli/src/TestScheduler.js @@ -161,6 +161,7 @@ export default class TestScheduler { const status = snapshot.cleanup( context.hasteFS, this._globalConfig.updateSnapshot, + snapshot.buildSnapshotResolver(context.config), ); aggregatedResults.snapshot.filesRemoved += status.filesRemoved; diff --git a/packages/jest-cli/src/lib/is_valid_path.js b/packages/jest-cli/src/lib/is_valid_path.js index e0e4f02ce8dc..c115ce05d690 100644 --- a/packages/jest-cli/src/lib/is_valid_path.js +++ b/packages/jest-cli/src/lib/is_valid_path.js @@ -8,7 +8,7 @@ */ import type {GlobalConfig, ProjectConfig} from 'types/Config'; -import Snapshot from 'jest-snapshot'; +import {isSnapshotPath} from 'jest-snapshot'; export default function isValidPath( globalConfig: GlobalConfig, @@ -18,6 +18,6 @@ export default function isValidPath( return ( !filePath.includes(globalConfig.coverageDirectory) && !config.watchPathIgnorePatterns.some(pattern => filePath.match(pattern)) && - !filePath.endsWith(`.${Snapshot.EXTENSION}`) + !isSnapshotPath(filePath) ); } diff --git a/packages/jest-config/src/ValidConfig.js b/packages/jest-config/src/ValidConfig.js index e264be5e2ead..edd84404cff3 100644 --- a/packages/jest-config/src/ValidConfig.js +++ b/packages/jest-config/src/ValidConfig.js @@ -91,6 +91,7 @@ export default ({ silent: true, skipFilter: false, skipNodeResolution: false, + snapshotResolver: '/snapshotResolver.js', snapshotSerializers: ['my-serializer-module'], testEnvironment: 'jest-environment-jsdom', testEnvironmentOptions: {userAgent: 'Agent/007'}, diff --git a/packages/jest-config/src/index.js b/packages/jest-config/src/index.js index 3f2962b6288a..5138379d9ccd 100644 --- a/packages/jest-config/src/index.js +++ b/packages/jest-config/src/index.js @@ -185,6 +185,7 @@ const getConfigs = ( setupTestFrameworkScriptFile: options.setupTestFrameworkScriptFile, skipFilter: options.skipFilter, skipNodeResolution: options.skipNodeResolution, + snapshotResolver: options.snapshotResolver, snapshotSerializers: options.snapshotSerializers, testEnvironment: options.testEnvironment, testEnvironmentOptions: options.testEnvironmentOptions, diff --git a/packages/jest-config/src/normalize.js b/packages/jest-config/src/normalize.js index 896edf6d39d5..8f41c357190f 100644 --- a/packages/jest-config/src/normalize.js +++ b/packages/jest-config/src/normalize.js @@ -453,6 +453,7 @@ export default function normalize(options: InitialOptions, argv: Argv) { case 'moduleLoader': case 'runner': case 'setupTestFrameworkScriptFile': + case 'snapshotResolver': case 'testResultsProcessor': case 'testRunner': case 'filter': diff --git a/packages/jest-editor-support/src/Snapshot.js b/packages/jest-editor-support/src/Snapshot.js index d9b3a2b56cb8..0b1334a6d2f2 100644 --- a/packages/jest-editor-support/src/Snapshot.js +++ b/packages/jest-editor-support/src/Snapshot.js @@ -10,9 +10,11 @@ 'use strict'; +import type {ProjectConfig} from 'types/Config'; + import traverse from 'babel-traverse'; import {getASTfor} from './parsers/babylon_parser'; -import {utils} from 'jest-snapshot'; +import {buildSnapshotResolver, utils} from 'jest-snapshot'; type Node = any; @@ -95,11 +97,17 @@ const buildName: ( export default class Snapshot { _parser: Function; _matchers: Array; - constructor(parser: any, customMatchers?: Array) { + _projectConfig: ?ProjectConfig; + constructor( + parser: any, + customMatchers?: Array, + projectConfig?: ProjectConfig, + ) { this._parser = parser || getASTfor; this._matchers = ['toMatchSnapshot', 'toThrowErrorMatchingSnapshot'].concat( customMatchers || [], ); + this._projectConfig = projectConfig; } getMetadata(filePath: string): Array { @@ -127,7 +135,9 @@ export default class Snapshot { }, }); - const snapshotPath = utils.getSnapshotPath(filePath); + // NOTE if no projectConfig is given the default resolver will be used + const snapshotResolver = buildSnapshotResolver(this._projectConfig || {}); + const snapshotPath = snapshotResolver.resolveSnapshotPath(filePath); const snapshots = utils.getSnapshotData(snapshotPath, 'none').data; let lastParent = null; let count = 1; diff --git a/packages/jest-jasmine2/src/setup_jest_globals.js b/packages/jest-jasmine2/src/setup_jest_globals.js index c25b638ffd70..71414ae3f6a0 100644 --- a/packages/jest-jasmine2/src/setup_jest_globals.js +++ b/packages/jest-jasmine2/src/setup_jest_globals.js @@ -11,7 +11,11 @@ import type {GlobalConfig, Path, ProjectConfig} from 'types/Config'; import type {Plugin} from 'types/PrettyFormat'; import {extractExpectedAssertionsErrors, getState, setState} from 'expect'; -import {SnapshotState, addSerializer} from 'jest-snapshot'; +import { + buildSnapshotResolver, + SnapshotState, + addSerializer, +} from 'jest-snapshot'; export type SetupOptions = {| config: ProjectConfig, @@ -96,9 +100,12 @@ export default ({ .forEach(path => { addSerializer(localRequire(path)); }); + patchJasmine(); const {expand, updateSnapshot} = globalConfig; - const snapshotState = new SnapshotState(testPath, { + const snapshotResolver = buildSnapshotResolver(config); + const snapshotPath = snapshotResolver.resolveSnapshotPath(testPath); + const snapshotState = new SnapshotState(snapshotPath, { expand, getBabelTraverse: () => require('babel-traverse').default, getPrettier: () => diff --git a/packages/jest-resolve-dependencies/src/__tests__/dependency_resolver.test.js b/packages/jest-resolve-dependencies/src/__tests__/dependency_resolver.test.js index a37aee480e3e..9bc911c89e70 100644 --- a/packages/jest-resolve-dependencies/src/__tests__/dependency_resolver.test.js +++ b/packages/jest-resolve-dependencies/src/__tests__/dependency_resolver.test.js @@ -9,6 +9,7 @@ const path = require('path'); const {normalize} = require('jest-config'); +const {buildSnapshotResolver} = require('jest-snapshot'); const DependencyResolver = require('../index'); const maxWorkers = 1; @@ -34,6 +35,7 @@ beforeEach(() => { dependencyResolver = new DependencyResolver( hasteMap.resolver, hasteMap.hasteFS, + buildSnapshotResolver(config), ); }); }); diff --git a/packages/jest-resolve-dependencies/src/index.js b/packages/jest-resolve-dependencies/src/index.js index 19c98f001a84..b5e5f5a4efbc 100644 --- a/packages/jest-resolve-dependencies/src/index.js +++ b/packages/jest-resolve-dependencies/src/index.js @@ -10,16 +10,8 @@ import type {HasteFS} from 'types/HasteMap'; import type {Path} from 'types/Config'; import type {Resolver, ResolveModuleConfig} from 'types/Resolve'; -import Snapshot from 'jest-snapshot'; - -import {replacePathSepForRegex} from 'jest-regex-util'; - -const snapshotDirRegex = new RegExp(replacePathSepForRegex('/__snapshots__/')); -const snapshotFileRegex = new RegExp( - replacePathSepForRegex(`__snapshots__/(.*).${Snapshot.EXTENSION}`), -); -const isSnapshotPath = (path: string): boolean => - !!path.match(snapshotDirRegex); +import type {SnapshotResolver} from 'types/SnapshotResolver'; +import {isSnapshotPath} from 'jest-snapshot'; /** * DependencyResolver is used to resolve the direct dependencies of a module or @@ -28,10 +20,16 @@ const isSnapshotPath = (path: string): boolean => class DependencyResolver { _hasteFS: HasteFS; _resolver: Resolver; + _snapshotResolver: SnapshotResolver; - constructor(resolver: Resolver, hasteFS: HasteFS) { + constructor( + resolver: Resolver, + hasteFS: HasteFS, + snapshotResolver: SnapshotResolver, + ) { this._resolver = resolver; this._hasteFS = hasteFS; + this._snapshotResolver = snapshotResolver; } resolve(file: Path, options?: ResolveModuleConfig): Array { @@ -89,10 +87,8 @@ class DependencyResolver { const changed = new Set(); for (const path of paths) { if (this._hasteFS.exists(path)) { - // /path/to/__snapshots__/test.js.snap is always adjacent to - // /path/to/test.js const modulePath = isSnapshotPath(path) - ? path.replace(snapshotFileRegex, '$1') + ? this._snapshotResolver.resolveTestPath(path) : path; changed.add(modulePath); if (filter(modulePath)) { diff --git a/packages/jest-snapshot/src/State.js b/packages/jest-snapshot/src/State.js index 923d60461beb..60fe6e945d50 100644 --- a/packages/jest-snapshot/src/State.js +++ b/packages/jest-snapshot/src/State.js @@ -14,7 +14,6 @@ import {getTopFrame, getStackTraceLines} from 'jest-message-util'; import { saveSnapshotFile, getSnapshotData, - getSnapshotPath, keyToTestName, serialize, testNameToKey, @@ -26,7 +25,6 @@ export type SnapshotStateOptions = {| updateSnapshot: SnapshotUpdateState, getPrettier: () => null | any, getBabelTraverse: () => Function, - snapshotPath?: string, expand?: boolean, |}; @@ -55,8 +53,8 @@ export default class SnapshotState { unmatched: number; updated: number; - constructor(testPath: Path, options: SnapshotStateOptions) { - this._snapshotPath = options.snapshotPath || getSnapshotPath(testPath); + constructor(snapshotPath: Path, options: SnapshotStateOptions) { + this._snapshotPath = snapshotPath; const {data, dirty} = getSnapshotData( this._snapshotPath, options.updateSnapshot, diff --git a/packages/jest-snapshot/src/__tests__/__snapshots__/snapshot_resolver.test.js.snap b/packages/jest-snapshot/src/__tests__/__snapshots__/snapshot_resolver.test.js.snap new file mode 100644 index 000000000000..57dc269e4bfb --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/__snapshots__/snapshot_resolver.test.js.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`malformed custom resolver in project config inconsistent functions throws 1`] = `"Custom snapshot resolver functions must transform paths consistently, i.e. expects resolveTestPath(resolveSnapshotPath('some-path/__tests__/snapshot_resolver.test.js')) === some-path/__SPECS__/snapshot_resolver.test.js"`; + +exports[`malformed custom resolver in project config missing resolveSnapshotPath throws 1`] = ` +"Custom snapshot resolver must implement a \`resolveSnapshotPath\` function. +Documentation: https://facebook.github.io/jest/docs/en/configuration.html#snapshotResolver" +`; + +exports[`malformed custom resolver in project config missing resolveTestPath throws 1`] = ` +"Custom snapshot resolver must implement a \`resolveTestPath\` function. +Documentation: https://facebook.github.io/jest/docs/en/configuration.html#snapshotResolver" +`; diff --git a/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-inconsistent-fns.js b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-inconsistent-fns.js new file mode 100644 index 000000000000..9e50e0591860 --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-inconsistent-fns.js @@ -0,0 +1,9 @@ +module.exports = { + resolveSnapshotPath: (testPath, snapshotExtension) => + testPath.replace('__tests__', '__snapshots__') + snapshotExtension, + + resolveTestPath: (snapshotFilePath, snapshotExtension) => + snapshotFilePath + .replace('__snapshots__', '__SPECS__') + .slice(0, -snapshotExtension.length), +}; diff --git a/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-missing-resolveSnapshotPath.js b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-missing-resolveSnapshotPath.js new file mode 100644 index 000000000000..e1a5503acd0b --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-missing-resolveSnapshotPath.js @@ -0,0 +1,3 @@ +module.exports = { + resolveTestPath: () => {}, +}; diff --git a/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-missing-resolveTestPath.js b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-missing-resolveTestPath.js new file mode 100644 index 000000000000..5f9037356ffa --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver-missing-resolveTestPath.js @@ -0,0 +1,3 @@ +module.exports = { + resolveSnapshotPath: () => {}, +}; diff --git a/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver.js b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver.js new file mode 100644 index 000000000000..4b397f194551 --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/fixtures/customSnapshotResolver.js @@ -0,0 +1,9 @@ +module.exports = { + resolveSnapshotPath: (testPath, snapshotExtension) => + testPath.replace('__tests__', '__snapshots__') + snapshotExtension, + + resolveTestPath: (snapshotFilePath, snapshotExtension) => + snapshotFilePath + .replace('__snapshots__', '__tests__') + .slice(0, -snapshotExtension.length), +}; diff --git a/packages/jest-snapshot/src/__tests__/snapshot_resolver.test.js b/packages/jest-snapshot/src/__tests__/snapshot_resolver.test.js new file mode 100644 index 000000000000..819671fc3a8a --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/snapshot_resolver.test.js @@ -0,0 +1,108 @@ +const path = require('path'); +const {buildSnapshotResolver} = require('../snapshot_resolver'); + +describe('defaults', () => { + let snapshotResolver; + const projectConfig = { + rootDir: 'default', + // snapshotResolver: null, + }; + + beforeEach(() => { + snapshotResolver = buildSnapshotResolver(projectConfig); + }); + + it('returns cached object if called multiple times', () => { + expect(buildSnapshotResolver(projectConfig)).toBe(snapshotResolver); + }); + + it('resolveSnapshotPath()', () => { + expect(snapshotResolver.resolveSnapshotPath('/abc/cde/a.test.js')).toBe( + path.join('/abc', 'cde', '__snapshots__', 'a.test.js.snap'), + ); + }); + + it('resolveTestPath()', () => { + expect( + snapshotResolver.resolveTestPath('/abc/cde/__snapshots__/a.test.js.snap'), + ).toBe(path.resolve('/abc/cde/a.test.js')); + }); +}); + +describe('custom resolver in project config', () => { + let snapshotResolver; + const customSnapshotResolverFile = path.join( + __dirname, + 'fixtures', + 'customSnapshotResolver.js', + ); + const projectConfig = { + rootDir: 'custom1', + snapshotResolver: customSnapshotResolverFile, + }; + + beforeEach(() => { + snapshotResolver = buildSnapshotResolver(projectConfig); + }); + + it('returns cached object if called multiple times', () => { + expect(buildSnapshotResolver(projectConfig)).toBe(snapshotResolver); + }); + + it('resolveSnapshotPath()', () => { + expect( + snapshotResolver.resolveSnapshotPath( + path.resolve('/abc/cde/__tests__/a.test.js'), + ), + ).toBe(path.resolve('/abc/cde/__snapshots__/a.test.js.snap')); + }); + + it('resolveTestPath()', () => { + expect( + snapshotResolver.resolveTestPath( + path.resolve('/abc', 'cde', '__snapshots__', 'a.test.js.snap'), + ), + ).toBe(path.resolve('/abc/cde/__tests__/a.test.js')); + }); +}); + +describe('malformed custom resolver in project config', () => { + const newProjectConfig = (filename: string) => { + const customSnapshotResolverFile = path.join( + __dirname, + 'fixtures', + filename, + ); + return { + rootDir: 'missing-resolveSnapshotPath', + snapshotResolver: customSnapshotResolverFile, + }; + }; + + it('missing resolveSnapshotPath throws ', () => { + const projectConfig = newProjectConfig( + 'customSnapshotResolver-missing-resolveSnapshotPath.js', + ); + expect(() => { + buildSnapshotResolver(projectConfig); + }).toThrowErrorMatchingSnapshot(); + }); + + it('missing resolveTestPath throws ', () => { + const projectConfig = newProjectConfig( + 'customSnapshotResolver-missing-resolveTestPath.js', + ); + expect(() => { + buildSnapshotResolver(projectConfig); + }).toThrowErrorMatchingSnapshot(); + }); + + it('inconsistent functions throws ', () => { + const projectConfig = newProjectConfig( + 'customSnapshotResolver-inconsistent-fns.js', + ); + expect(() => { + buildSnapshotResolver(projectConfig); + }).toThrowErrorMatchingSnapshot(); + }); +}); diff --git a/packages/jest-snapshot/src/__tests__/utils.test.js b/packages/jest-snapshot/src/__tests__/utils.test.js index 339911334ecb..ea6d2191f430 100644 --- a/packages/jest-snapshot/src/__tests__/utils.test.js +++ b/packages/jest-snapshot/src/__tests__/utils.test.js @@ -13,7 +13,6 @@ const chalk = require('chalk'); const { getSnapshotData, - getSnapshotPath, keyToTestName, saveSnapshotFile, serialize, @@ -51,12 +50,6 @@ test('testNameToKey', () => { expect(testNameToKey('abc cde ', 12)).toBe('abc cde 12'); }); -test('getSnapshotPath()', () => { - expect(getSnapshotPath('/abc/cde/a.test.js')).toBe( - path.join('/abc', 'cde', '__snapshots__', 'a.test.js.snap'), - ); -}); - test('saveSnapshotFile() works with \r\n', () => { const filename = path.join(__dirname, 'remove-newlines.snap'); const data = { diff --git a/packages/jest-snapshot/src/index.js b/packages/jest-snapshot/src/index.js index 193176b42a21..93c31286a026 100644 --- a/packages/jest-snapshot/src/index.js +++ b/packages/jest-snapshot/src/index.js @@ -10,11 +10,16 @@ import type {HasteFS} from 'types/HasteMap'; import type {MatcherState} from 'types/Matchers'; import type {Path, SnapshotUpdateState} from 'types/Config'; +import type {SnapshotResolver} from 'types/SnapshotResolver'; import fs from 'fs'; -import path from 'path'; import diff from 'jest-diff'; import {EXPECTED_COLOR, matcherHint, RECEIVED_COLOR} from 'jest-matcher-utils'; +import { + buildSnapshotResolver, + isSnapshotPath, + EXTENSION, +} from './snapshot_resolver'; import SnapshotState from './State'; import {addSerializer, getSerializers} from './plugins'; import * as utils from './utils'; @@ -22,20 +27,17 @@ import * as utils from './utils'; const fileExists = (filePath: Path, hasteFS: HasteFS): boolean => hasteFS.exists(filePath) || fs.existsSync(filePath); -const cleanup = (hasteFS: HasteFS, update: SnapshotUpdateState) => { - const pattern = '\\.' + utils.SNAPSHOT_EXTENSION + '$'; +const cleanup = ( + hasteFS: HasteFS, + update: SnapshotUpdateState, + snapshotResolver: SnapshotResolver, +) => { + const pattern = '\\.' + EXTENSION + '$'; const files = hasteFS.matchFiles(pattern); const filesRemoved = files .filter( snapshotFile => - !fileExists( - path.resolve( - path.dirname(snapshotFile), - '..', - path.basename(snapshotFile, '.' + utils.SNAPSHOT_EXTENSION), - ), - hasteFS, - ), + !fileExists(snapshotResolver.resolveTestPath(snapshotFile), hasteFS), ) .map(snapshotFile => { if (update === 'all') { @@ -290,11 +292,13 @@ const _toThrowErrorMatchingSnapshot = ({ }; module.exports = { - EXTENSION: utils.SNAPSHOT_EXTENSION, + EXTENSION, SnapshotState, addSerializer, + buildSnapshotResolver, cleanup, getSerializers, + isSnapshotPath, toMatchInlineSnapshot, toMatchSnapshot, toThrowErrorMatchingInlineSnapshot, diff --git a/packages/jest-snapshot/src/snapshot_resolver.js b/packages/jest-snapshot/src/snapshot_resolver.js new file mode 100644 index 000000000000..db2af2f7a4a5 --- /dev/null +++ b/packages/jest-snapshot/src/snapshot_resolver.js @@ -0,0 +1,91 @@ +import type {ProjectConfig, Path} from 'types/Config'; +import type {SnapshotResolver} from 'types/SnapshotResolver'; +import chalk from 'chalk'; +import path from 'path'; + +export const EXTENSION = 'snap'; +export const DOT_EXTENSION = '.' + EXTENSION; + +export const isSnapshotPath = (path: string): boolean => + path.endsWith(DOT_EXTENSION); + +const cache: Map = new Map(); +export const buildSnapshotResolver = ( + config: ProjectConfig, +): SnapshotResolver => { + const key = config.rootDir; + if (!cache.has(key)) { + cache.set(key, createSnapshotResolver(config.snapshotResolver)); + } + return cache.get(key); +}; + +function createSnapshotResolver(snapshotResolverPath: ?Path): SnapshotResolver { + return typeof snapshotResolverPath === 'string' + ? createCustomSnapshotResolver(snapshotResolverPath) + : { + resolveSnapshotPath: (testPath: Path) => + path.join( + path.join(path.dirname(testPath), '__snapshots__'), + path.basename(testPath) + DOT_EXTENSION, + ), + + resolveTestPath: (snapshotPath: Path) => + path.resolve( + path.dirname(snapshotPath), + '..', + path.basename(snapshotPath, DOT_EXTENSION), + ), + }; +} + +function createCustomSnapshotResolver( + snapshotResolverPath: Path, +): SnapshotResolver { + const custom = (require(snapshotResolverPath): SnapshotResolver); + + if (typeof custom.resolveSnapshotPath !== 'function') { + throw new TypeError(mustImplement('resolveSnapshotPath')); + } + if (typeof custom.resolveTestPath !== 'function') { + throw new TypeError(mustImplement('resolveTestPath')); + } + + const customResolver = { + resolveSnapshotPath: testPath => + custom.resolveSnapshotPath(testPath, DOT_EXTENSION), + resolveTestPath: snapshotPath => + custom.resolveTestPath(snapshotPath, DOT_EXTENSION), + }; + + verifyConsistentTransformations(customResolver); + + return customResolver; +} + +function mustImplement(functionName: string) { + return ( + chalk.bold( + `Custom snapshot resolver must implement a \`${functionName}\` function.`, + ) + + '\nDocumentation: https://facebook.github.io/jest/docs/en/configuration.html#snapshotResolver' + ); +} + +function verifyConsistentTransformations(custom: SnapshotResolver) { + const fakeTestPath = path.posix.join( + 'some-path', + '__tests__', + 'snapshot_resolver.test.js', + ); + const transformedPath = custom.resolveTestPath( + custom.resolveSnapshotPath(fakeTestPath), + ); + if (transformedPath !== fakeTestPath) { + throw new Error( + chalk.bold( + `Custom snapshot resolver functions must transform paths consistently, i.e. expects resolveTestPath(resolveSnapshotPath('${fakeTestPath}')) === ${transformedPath}`, + ), + ); + } +} diff --git a/packages/jest-snapshot/src/utils.js b/packages/jest-snapshot/src/utils.js index 2732eb12b399..13f7b76a4465 100644 --- a/packages/jest-snapshot/src/utils.js +++ b/packages/jest-snapshot/src/utils.js @@ -17,7 +17,6 @@ import naturalCompare from 'natural-compare'; import path from 'path'; import prettyFormat from 'pretty-format'; -export const SNAPSHOT_EXTENSION = 'snap'; export const SNAPSHOT_VERSION = '1'; const SNAPSHOT_VERSION_REGEXP = /^\/\/ Jest Snapshot v(.+),/; export const SNAPSHOT_GUIDE_LINK = 'https://goo.gl/fbAQLP'; @@ -92,12 +91,6 @@ export const keyToTestName = (key: string) => { return key.replace(/ \d+$/, ''); }; -export const getSnapshotPath = (testPath: Path) => - path.join( - path.join(path.dirname(testPath), '__snapshots__'), - path.basename(testPath) + '.' + SNAPSHOT_EXTENSION, - ); - export const getSnapshotData = ( snapshotPath: Path, update: SnapshotUpdateState, diff --git a/types/Config.js b/types/Config.js index f7270b049a64..d13f38827d4a 100644 --- a/types/Config.js +++ b/types/Config.js @@ -153,6 +153,7 @@ export type InitialOptions = { silent?: boolean, skipFilter?: boolean, skipNodeResolution?: boolean, + snapshotResolver?: Path, snapshotSerializers?: Array, errorOnDeprecated?: boolean, testEnvironment?: string, @@ -273,6 +274,7 @@ export type ProjectConfig = {| setupTestFrameworkScriptFile: ?Path, skipFilter: boolean, skipNodeResolution: boolean, + snapshotResolver: ?Path, snapshotSerializers: Array, testEnvironment: string, testEnvironmentOptions: Object, diff --git a/types/SnapshotResolver.js b/types/SnapshotResolver.js new file mode 100644 index 000000000000..492ac322b0d9 --- /dev/null +++ b/types/SnapshotResolver.js @@ -0,0 +1,6 @@ +import type {Path} from './Config'; + +export type SnapshotResolver = {| + resolveSnapshotPath(testPath: Path): Path, + resoveTestPath(snapshotPath: Path): Path, +|};