From 084b4ec0f6d1bdd3bc5fd9bdcc06645754e0511b Mon Sep 17 00:00:00 2001 From: Lucas Azzola Date: Sat, 2 Jun 2018 22:12:53 +1000 Subject: [PATCH] [RFC] Inline Snapshots --- .../legacy_code_todo_rewrite/jest_expect.js | 4 + packages/jest-jasmine2/src/jest_expect.js | 4 + packages/jest-message-util/src/index.js | 13 ++- packages/jest-snapshot/package.json | 6 +- packages/jest-snapshot/src/State.js | 59 ++++++++--- packages/jest-snapshot/src/index.js | 98 +++++++++++++++++-- packages/jest-snapshot/src/utils.js | 64 ++++++++++++ yarn.lock | 7 ++ 8 files changed, 231 insertions(+), 24 deletions(-) diff --git a/packages/jest-circus/src/legacy_code_todo_rewrite/jest_expect.js b/packages/jest-circus/src/legacy_code_todo_rewrite/jest_expect.js index 62204131faee..de48a4a8ea78 100644 --- a/packages/jest-circus/src/legacy_code_todo_rewrite/jest_expect.js +++ b/packages/jest-circus/src/legacy_code_todo_rewrite/jest_expect.js @@ -14,7 +14,9 @@ import expect from 'expect'; import { addSerializer, toMatchSnapshot, + toMatchInlineSnapshot, toThrowErrorMatchingSnapshot, + toThrowErrorMatchingInlineSnapshot, } from 'jest-snapshot'; type JasmineMatcher = { @@ -29,7 +31,9 @@ export default (config: {expand: boolean}) => { expand: config.expand, }); expect.extend({ + toMatchInlineSnapshot, toMatchSnapshot, + toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, }); diff --git a/packages/jest-jasmine2/src/jest_expect.js b/packages/jest-jasmine2/src/jest_expect.js index 0f3dceb3c18c..46ef61026e4c 100644 --- a/packages/jest-jasmine2/src/jest_expect.js +++ b/packages/jest-jasmine2/src/jest_expect.js @@ -13,7 +13,9 @@ import expect from 'expect'; import { addSerializer, toMatchSnapshot, + toMatchInlineSnapshot, toThrowErrorMatchingSnapshot, + toThrowErrorMatchingInlineSnapshot, } from 'jest-snapshot'; type JasmineMatcher = { @@ -27,7 +29,9 @@ export default (config: {expand: boolean}) => { global.expect = expect; expect.setState({expand: config.expand}); expect.extend({ + toMatchInlineSnapshot, toMatchSnapshot, + toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, }); (expect: Object).addSnapshotSerializer = addSerializer; diff --git a/packages/jest-message-util/src/index.js b/packages/jest-message-util/src/index.js index 16d46a24902d..29ef2e4fa64a 100644 --- a/packages/jest-message-util/src/index.js +++ b/packages/jest-message-util/src/index.js @@ -221,7 +221,12 @@ const formatPaths = (config: StackTraceConfig, relativeTestPath, line) => { return STACK_TRACE_COLOR(match[1]) + filePath + STACK_TRACE_COLOR(match[3]); }; -const getTopFrame = (lines: string[]) => { +export const getTopFrame = ( + lines: string[], + options: StackTraceOptions = {}, +) => { + lines = removeInternalStackEntries(lines, options); + for (const line of lines) { if (line.includes(PATH_NODE_MODULES) || line.includes(PATH_JEST_PACKAGES)) { continue; @@ -243,14 +248,12 @@ export const formatStackTrace = ( options: StackTraceOptions, testPath: ?Path, ) => { - let lines = stack.split(/\n/); + const lines = stack.split(/\n/); + const topFrame = getTopFrame(lines, options); let renderedCallsite = ''; const relativeTestPath = testPath ? slash(path.relative(config.rootDir, testPath)) : null; - lines = removeInternalStackEntries(lines, options); - - const topFrame = getTopFrame(lines); if (topFrame) { const filename = topFrame.file; diff --git a/packages/jest-snapshot/package.json b/packages/jest-snapshot/package.json index d8821cca8f06..f753aabc93c2 100644 --- a/packages/jest-snapshot/package.json +++ b/packages/jest-snapshot/package.json @@ -8,11 +8,15 @@ "license": "MIT", "main": "build/index.js", "dependencies": { + "babel-traverse": "^6.0.0", + "babel-types": "^6.0.0", "chalk": "^2.0.1", "jest-diff": "^23.0.1", "jest-matcher-utils": "^23.0.1", + "jest-message-util": "^23.0.1", "mkdirp": "^0.5.1", "natural-compare": "^1.4.0", - "pretty-format": "^23.0.1" + "pretty-format": "^23.0.1", + "prettier": "~1.13.4" } } diff --git a/packages/jest-snapshot/src/State.js b/packages/jest-snapshot/src/State.js index aaacad4d32f2..00c1644b006c 100644 --- a/packages/jest-snapshot/src/State.js +++ b/packages/jest-snapshot/src/State.js @@ -10,14 +10,17 @@ import type {Path, SnapshotUpdateState} from 'types/Config'; import fs from 'fs'; +import {getTopFrame} from 'jest-message-util'; import { saveSnapshotFile, + saveInlineSnapshots, getSnapshotData, getSnapshotPath, keyToTestName, serialize, testNameToKey, unescape, + type InlineSnapshot, } from './utils'; export type SnapshotStateOptions = {| @@ -33,6 +36,8 @@ export default class SnapshotState { _updateSnapshot: SnapshotUpdateState; _snapshotData: {[key: string]: string}; _snapshotPath: Path; + _inlineSnapshotData: {[key: string]: InlineSnapshot}; + _testPath: Path; _uncheckedKeys: Set; added: number; expand: boolean; @@ -42,12 +47,14 @@ export default class SnapshotState { constructor(testPath: Path, options: SnapshotStateOptions) { this._snapshotPath = options.snapshotPath || getSnapshotPath(testPath); + this._testPath = testPath; const {data, dirty} = getSnapshotData( this._snapshotPath, options.updateSnapshot, ); this._snapshotData = data; this._dirty = dirty; + this._inlineSnapshotData = Object.create(null); this._uncheckedKeys = new Set(Object.keys(this._snapshotData)); this._counters = new Map(); this._index = 0; @@ -67,22 +74,42 @@ export default class SnapshotState { }); } - _addSnapshot(key: string, receivedSerialized: string) { + _addSnapshot(key: string, receivedSerialized: string, isInline: boolean) { this._dirty = true; - this._snapshotData[key] = receivedSerialized; + if (isInline) { + const stack = new Error().stack.split(/\n/); + const frame = getTopFrame(stack); + if (!frame) { + throw new Error("Jest: Couln't infer stack frame for inline snapshot."); + } + this._inlineSnapshotData[key] = { + frame, + snapshot: receivedSerialized, + }; + } else { + this._snapshotData[key] = receivedSerialized; + } } save() { - const isEmpty = Object.keys(this._snapshotData).length === 0; + const hasExternalSnapshots = Object.keys(this._snapshotData).length; + const hasInlineSnapshots = Object.keys(this._inlineSnapshotData).length; + const isEmpty = !hasExternalSnapshots && !hasInlineSnapshots; + const status = { deleted: false, saved: false, }; if ((this._dirty || this._uncheckedKeys.size) && !isEmpty) { - saveSnapshotFile(this._snapshotData, this._snapshotPath); + if (hasExternalSnapshots) { + saveSnapshotFile(this._snapshotData, this._snapshotPath); + } + if (hasInlineSnapshots) { + saveInlineSnapshots(this._inlineSnapshotData, this._testPath); + } status.saved = true; - } else if (isEmpty && fs.existsSync(this._snapshotPath)) { + } else if (!hasExternalSnapshots && fs.existsSync(this._snapshotPath)) { if (this._updateSnapshot === 'all') { fs.unlinkSync(this._snapshotPath); } @@ -108,9 +135,15 @@ export default class SnapshotState { } } - match(testName: string, received: any, key?: string) { + match( + testName: string, + received: any, + key?: string, + inlineSnapshot?: string, + ) { this._counters.set(testName, (this._counters.get(testName) || 0) + 1); const count = Number(this._counters.get(testName)); + const isInline = typeof inlineSnapshot === 'string'; if (!key) { key = testNameToKey(testName, count); @@ -119,11 +152,13 @@ export default class SnapshotState { this._uncheckedKeys.delete(key); const receivedSerialized = serialize(received); - const expected = this._snapshotData[key]; + const expected = isInline ? inlineSnapshot : this._snapshotData[key]; const pass = expected === receivedSerialized; - const hasSnapshot = this._snapshotData[key] !== undefined; + const hasSnapshot = isInline + ? inlineSnapshot !== '' + : this._snapshotData[key] !== undefined; - if (pass) { + if (pass && !isInline) { // Executing a snapshot file as JavaScript and writing the strings back // when other snapshots have changed loses the proper escaping for some // characters. Since we check every snapshot in every test, use the newly @@ -142,7 +177,7 @@ export default class SnapshotState { // * There's no snapshot file or a file without this snapshot on a CI environment. if ( (hasSnapshot && this._updateSnapshot === 'all') || - ((!hasSnapshot || !fs.existsSync(this._snapshotPath)) && + ((!hasSnapshot || (!isInline && !fs.existsSync(this._snapshotPath))) && (this._updateSnapshot === 'new' || this._updateSnapshot === 'all')) ) { if (this._updateSnapshot === 'all') { @@ -152,12 +187,12 @@ export default class SnapshotState { } else { this.added++; } - this._addSnapshot(key, receivedSerialized); + this._addSnapshot(key, receivedSerialized, isInline); } else { this.matched++; } } else { - this._addSnapshot(key, receivedSerialized); + this._addSnapshot(key, receivedSerialized, isInline); this.added++; } diff --git a/packages/jest-snapshot/src/index.js b/packages/jest-snapshot/src/index.js index 985f9abe785d..d630ee8e99a3 100644 --- a/packages/jest-snapshot/src/index.js +++ b/packages/jest-snapshot/src/index.js @@ -53,10 +53,45 @@ const toMatchSnapshot = function( propertyMatchers?: any, testName?: string, ) { - this.dontThrow && this.dontThrow(); + return _toMatchSnapshot(received, propertyMatchers, testName); +}; + +const toMatchInlineSnapshot = function( + received: any, + propertyMatchersOrInlineSnapshot?: any, + inlineSnapshot?: string, +) { + const propertyMatchers = inlineSnapshot + ? propertyMatchersOrInlineSnapshot + : undefined; + if (!inlineSnapshot) { + inlineSnapshot = propertyMatchersOrInlineSnapshot || ''; + } + return _toMatchSnapshot({ + context: this, + inlineSnapshot, + propertyMatchers, + received, + }); +}; + +const _toMatchSnapshot = function({ + context, + received, + propertyMatchers, + testName, + inlineSnapshot, +}: { + context: MatcherState, + received: any, + propertyMatchers?: any, + testName?: string, + inlineSnapshot?: string, +}) { + context.dontThrow && context.dontThrow(); testName = typeof propertyMatchers === 'string' ? propertyMatchers : testName; - const {currentTestName, isNot, snapshotState}: MatcherState = this; + const {currentTestName, isNot, snapshotState} = context; if (isNot) { throw new Error('Jest: `.not` cannot be used with `.toMatchSnapshot()`.'); @@ -72,6 +107,7 @@ const toMatchSnapshot = function( : currentTestName || ''; if (typeof propertyMatchers === 'object') { + const propertyMatchers = propertyMatchers; const propertyPass = this.equals(received, propertyMatchers, [ this.utils.iterableEquality, this.utils.subsetEquality, @@ -102,7 +138,12 @@ const toMatchSnapshot = function( } } - const result = snapshotState.match(fullTestName, received); + const result = snapshotState.match( + fullTestName, + received, + /* key */ undefined, + inlineSnapshot, + ); const {pass} = result; let {actual, expected} = result; @@ -153,9 +194,47 @@ const toThrowErrorMatchingSnapshot = function( testName?: string, fromPromise: boolean, ) { - this.dontThrow && this.dontThrow(); + return _toThrowErrorMatchingSnapshot({ + context: this, + fromPromise, + received, + testName, + }); +}; + +const toThrowErrorMatchingInlineSnapshot = function( + received: any, + fromPromiseOrInlineSnapshot: any, + inlineSnapshot?: string, +) { + const fromPromise = inlineSnapshot ? fromPromiseOrInlineSnapshot : undefined; + if (!inlineSnapshot) { + inlineSnapshot = fromPromiseOrInlineSnapshot; + } + return _toThrowErrorMatchingSnapshot({ + context: this, + fromPromise, + inlineSnapshot, + received, + }); +}; + +const _toThrowErrorMatchingSnapshot = function({ + context, + received, + testName, + fromPromise, + inlineSnapshot, +}: { + context: MatcherState, + received: any, + testName?: string, + fromPromise: boolean, + inlineSnapshot?: string, +}) { + context.dontThrow && context.dontThrow(); - const {isNot} = this; + const {isNot} = context; if (isNot) { throw new Error( @@ -184,7 +263,12 @@ const toThrowErrorMatchingSnapshot = function( ); } - return toMatchSnapshot.call(this, error.message, testName); + return _toMatchSnapshot({ + context, + inlineSnapshot, + received: error.message, + testName, + }); }; module.exports = { @@ -193,7 +277,9 @@ module.exports = { addSerializer, cleanup, getSerializers, + toMatchInlineSnapshot, toMatchSnapshot, + toThrowErrorMatchingInlineSnapshot, toThrowErrorMatchingSnapshot, utils, }; diff --git a/packages/jest-snapshot/src/utils.js b/packages/jest-snapshot/src/utils.js index 13f907db3239..92ebb30205d3 100644 --- a/packages/jest-snapshot/src/utils.js +++ b/packages/jest-snapshot/src/utils.js @@ -16,6 +16,14 @@ import mkdirp from 'mkdirp'; import naturalCompare from 'natural-compare'; import path from 'path'; import prettyFormat from 'pretty-format'; +import prettier from 'prettier'; +import traverse from 'babel-traverse'; +import {templateElement, templateLiteral} from 'babel-types'; + +export type InlineSnapshot = {| + snapshot: string, + frame: {line: number, column: number}, +|}; export const SNAPSHOT_EXTENSION = 'snap'; export const SNAPSHOT_VERSION = '1'; @@ -179,3 +187,59 @@ export const saveSnapshotFile = ( writeSnapshotVersion() + '\n\n' + snapshots.join('\n\n') + '\n', ); }; + +export const saveInlineSnapshots = ( + snapshotData: {[key: string]: InlineSnapshot}, + sourceFilePath: Path, +) => { + const sourceFile = fs.readFileSync(sourceFilePath, 'utf8'); + const snapshots = Object.values(snapshotData); + + const config = prettier.resolveConfig.sync(sourceFilePath); + const newSourceFile = prettier.format( + sourceFile, + Object.assign({}, config, { + filepath: sourceFilePath, + parser: createParser(snapshots), + }), + ); + + if (newSourceFile !== sourceFile) { + fs.writeFileSync(sourceFilePath, newSourceFile); + } +}; + +const createParser = snapshots => (text, parsers) => { + const ast = parsers.babylon(text); + traverse(ast, { + CallExpression({node}) { + if ( + node.callee.type !== 'MemberExpression' || + node.callee.property.type !== 'Identifier' + ) { + return; + } + const matcher = node.callee.property; + for (const {snapshot, frame} of snapshots) { + if ( + matcher.loc.start.line === frame.line && + matcher.loc.start.column === frame.column - 1 + ) { + if ( + node.arguments[0] && + node.arguments[0].type === 'TemplateLiteral' + ) { + node.arguments[0].quasis[0].value.raw = snapshot; + } else { + node.arguments[0] = templateLiteral( + [templateElement({raw: snapshot})], + [], + ); + } + } + } + }, + }); + + return ast; +}; diff --git a/yarn.lock b/yarn.lock index 6e68f073a1b1..6043b472c166 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5798,6 +5798,13 @@ libqp@1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/libqp/-/libqp-1.1.0.tgz#f5e6e06ad74b794fb5b5b66988bf728ef1dedbe8" +line-column@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/line-column/-/line-column-1.0.2.tgz#d25af2936b6f4849172b312e4792d1d987bc34a2" + dependencies: + isarray "^1.0.0" + isobject "^2.0.0" + lines-and-columns@^1.1.6: version "1.1.6" resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"