diff --git a/CHANGELOG.md b/CHANGELOG.md index 64737724c07b..9875d6670adf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - `[@jest/reporter]` Display todo and skip test descriptions when verbose is true ([#8038](https://github.com/facebook/jest/pull/8038)) - `[jest-runner]` Support default exports for test environments ([#8163](https://github.com/facebook/jest/pull/8163)) - `[pretty-format]` Support React.Suspense ([#8180](https://github.com/facebook/jest/pull/8180)) +- `[jest-snapshot]` Indent inline snapshots ([#8198](https://github.com/facebook/jest/pull/8198)) ### Fixes diff --git a/e2e/__tests__/__snapshots__/toMatchInlineSnapshot.test.ts.snap b/e2e/__tests__/__snapshots__/toMatchInlineSnapshot.test.ts.snap index d1d8ef47911c..6de95cb08b08 100644 --- a/e2e/__tests__/__snapshots__/toMatchInlineSnapshot.test.ts.snap +++ b/e2e/__tests__/__snapshots__/toMatchInlineSnapshot.test.ts.snap @@ -3,40 +3,40 @@ exports[`basic support: initial write 1`] = ` test('inline snapshots', () => expect({apple: 'original value'}).toMatchInlineSnapshot(\` -Object { - "apple": "original value", -} -\`)); + Object { + "apple": "original value", + } + \`)); `; exports[`basic support: snapshot mismatch 1`] = ` test('inline snapshots', () => expect({apple: 'updated value'}).toMatchInlineSnapshot(\` -Object { - "apple": "original value", -} -\`)); + Object { + "apple": "original value", + } + \`)); `; exports[`basic support: snapshot passed 1`] = ` test('inline snapshots', () => expect({apple: 'original value'}).toMatchInlineSnapshot(\` -Object { - "apple": "original value", -} -\`)); + Object { + "apple": "original value", + } + \`)); `; exports[`basic support: snapshot updated 1`] = ` test('inline snapshots', () => expect({apple: 'updated value'}).toMatchInlineSnapshot(\` -Object { - "apple": "updated value", -} -\`)); + Object { + "apple": "updated value", + } + \`)); `; @@ -45,10 +45,10 @@ test('handles property matchers', () => { expect({createdAt: new Date()}).toMatchInlineSnapshot( {createdAt: expect.any(Date)}, \` -Object { - "createdAt": Any, -} -\` + Object { + "createdAt": Any, + } + \` ); }); @@ -59,10 +59,10 @@ test('handles property matchers', () => { expect({createdAt: "string"}).toMatchInlineSnapshot( {createdAt: expect.any(Date)}, \` -Object { - "createdAt": Any, -} -\` + Object { + "createdAt": Any, + } + \` ); }); @@ -73,10 +73,10 @@ test('handles property matchers', () => { expect({createdAt: new Date()}).toMatchInlineSnapshot( {createdAt: expect.any(Date)}, \` -Object { - "createdAt": Any, -} -\` + Object { + "createdAt": Any, + } + \` ); }); @@ -87,10 +87,10 @@ test('handles property matchers', () => { expect({createdAt: 'string'}).toMatchInlineSnapshot( {createdAt: expect.any(String)}, \` -Object { - "createdAt": Any, -} -\` + Object { + "createdAt": Any, + } + \` ); }); @@ -139,10 +139,10 @@ test('inline snapshots', async () => { exports[`writes snapshots with non-literals in expect(...) 1`] = ` it('works with inline snapshots', () => { expect({a: 1}).toMatchInlineSnapshot(\` -Object { - "a": 1, -} -\`); + Object { + "a": 1, + } + \`); }); `; diff --git a/packages/jest-snapshot/src/__tests__/inline_snapshots.test.ts b/packages/jest-snapshot/src/__tests__/inline_snapshots.test.ts index a7b7ad418eeb..fd47d3d0348a 100644 --- a/packages/jest-snapshot/src/__tests__/inline_snapshots.test.ts +++ b/packages/jest-snapshot/src/__tests__/inline_snapshots.test.ts @@ -199,3 +199,108 @@ test('saveInlineSnapshots() works with non-literals in expect call', () => { "expect({a: 'a'}).toMatchInlineSnapshot(`{a: 'a'}`);\n", ); }); + +test('saveInlineSnapshots() indents multi-line snapshots with spaces', () => { + const filename = path.join(__dirname, 'my.test.js'); + (fs.readFileSync as jest.Mock).mockImplementation( + () => + "it('is a test', () => {\n" + + " expect({a: 'a'}).toMatchInlineSnapshot();\n" + + '});\n', + ); + (prettier.resolveConfig.sync as jest.Mock).mockReturnValue({ + bracketSpacing: false, + singleQuote: true, + }); + + saveInlineSnapshots( + [ + { + frame: {column: 20, file: filename, line: 2} as Frame, + snapshot: `\nObject {\n a: 'a'\n}\n`, + }, + ], + prettier, + babelTraverse, + ); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + filename, + "it('is a test', () => {\n" + + " expect({a: 'a'}).toMatchInlineSnapshot(`\n" + + ' Object {\n' + + " a: 'a'\n" + + ' }\n' + + ' `);\n' + + '});\n', + ); +}); + +test('saveInlineSnapshots() indents multi-line snapshots with tabs', () => { + const filename = path.join(__dirname, 'my.test.js'); + (fs.readFileSync as jest.Mock).mockImplementation( + () => + "it('is a test', () => {\n" + + " expect({a: 'a'}).toMatchInlineSnapshot();\n" + + '});\n', + ); + (prettier.resolveConfig.sync as jest.Mock).mockReturnValue({ + bracketSpacing: false, + singleQuote: true, + useTabs: true, + }); + + saveInlineSnapshots( + [ + { + frame: {column: 20, file: filename, line: 2} as Frame, + snapshot: `\nObject {\n a: 'a'\n}\n`, + }, + ], + prettier, + babelTraverse, + ); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + filename, + "it('is a test', () => {\n" + + "\texpect({a: 'a'}).toMatchInlineSnapshot(`\n" + + '\t\tObject {\n' + + "\t\t a: 'a'\n" + + '\t\t}\n' + + '\t`);\n' + + '});\n', + ); +}); + +test('saveInlineSnapshots() indents snapshots after prettier reformats', () => { + const filename = path.join(__dirname, 'my.test.js'); + (fs.readFileSync as jest.Mock).mockImplementation( + () => "it('is a test', () => expect({a: 'a'}).toMatchInlineSnapshot());\n", + ); + (prettier.resolveConfig.sync as jest.Mock).mockReturnValue({ + bracketSpacing: false, + singleQuote: true, + }); + + saveInlineSnapshots( + [ + { + frame: {column: 40, file: filename, line: 1} as Frame, + snapshot: `\nObject {\n a: 'a'\n}\n`, + }, + ], + prettier, + babelTraverse, + ); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + filename, + "it('is a test', () =>\n" + + " expect({a: 'a'}).toMatchInlineSnapshot(`\n" + + ' Object {\n' + + " a: 'a'\n" + + ' }\n' + + ' `));\n', + ); +}); diff --git a/packages/jest-snapshot/src/index.ts b/packages/jest-snapshot/src/index.ts index d468c7a7dc01..59ec5af859bc 100644 --- a/packages/jest-snapshot/src/index.ts +++ b/packages/jest-snapshot/src/index.ts @@ -51,6 +51,7 @@ const NOT_SNAPSHOT_MATCHERS = `.${BOLD_WEIGHT( const HINT_ARG = BOLD_WEIGHT('hint'); const INLINE_SNAPSHOT_ARG = 'snapshot'; const PROPERTY_MATCHERS_ARG = 'properties'; +const INDENTATION_REGEX = /^([^\S\n]*)\S/m; // Display name in report when matcher fails same as in snapshot file, // but with optional hint argument in bold weight. @@ -73,6 +74,45 @@ const printName = ( ); }; +function stripAddedIndentation(inlineSnapshot: string) { + // Find indentation if exists. + const match = inlineSnapshot.match(INDENTATION_REGEX); + if (!match || !match[1]) { + // No indentation. + return inlineSnapshot; + } + + const indentation = match[1]; + const lines = inlineSnapshot.split('\n'); + if (lines.length <= 2) { + // Must be at least 3 lines. + return inlineSnapshot; + } + + if (lines[0].trim() !== '' || lines[lines.length - 1].trim() !== '') { + // If not blank first and last lines, abort. + return inlineSnapshot; + } + + for (let i = 1; i < lines.length - 1; i++) { + if (lines[i].indexOf(indentation) !== 0) { + // All lines except first and last should have the same indent as the + // first line (or more). If this isn't the case we don't want to touch it. + return inlineSnapshot; + } + + lines[i] = lines[i].substr(indentation.length); + } + + // Last line is a special case because it won't have the same indent as others + // but may still have been given some indent to line up. + lines[lines.length - 1] = ''; + + // Return inline snapshot, now at indent 0. + inlineSnapshot = lines.join('\n'); + return inlineSnapshot; +} + const fileExists = (filePath: Config.Path, hasteFS: HasteFS): boolean => hasteFS.exists(filePath) || fs.existsSync(filePath); @@ -182,7 +222,7 @@ const toMatchInlineSnapshot = function( return _toMatchSnapshot({ context: this, expectedArgument, - inlineSnapshot: inlineSnapshot || '', + inlineSnapshot: stripAddedIndentation(inlineSnapshot || ''), matcherName, options, propertyMatchers, diff --git a/packages/jest-snapshot/src/inline_snapshots.ts b/packages/jest-snapshot/src/inline_snapshots.ts index fad8e372c7f3..3e256df2912f 100644 --- a/packages/jest-snapshot/src/inline_snapshots.ts +++ b/packages/jest-snapshot/src/inline_snapshots.ts @@ -78,15 +78,25 @@ const saveSnapshotsForFile = ( ? prettier.getFileInfo.sync(sourceFilePath).inferredParser : (config && config.parser) || simpleDetectParser(sourceFilePath); - // Format the source code using the custom parser API. + // Insert snapshots using the custom parser API. After insertion, the code is + // formatted, except snapshot indentation. Snapshots cannot be formatted until + // after the initial format because we don't know where the call expression + // will be placed (specifically its indentation). const newSourceFile = prettier.format(sourceFile, { ...config, filepath: sourceFilePath, - parser: createParser(snapshots, inferredParser, babelTraverse), + parser: createInsertionParser(snapshots, inferredParser, babelTraverse), }); - if (newSourceFile !== sourceFile) { - fs.writeFileSync(sourceFilePath, newSourceFile); + // Format the snapshots using the custom parser API. + const formattedNewSourceFile = prettier.format(newSourceFile, { + ...config, + filepath: sourceFilePath, + parser: createFormattingParser(inferredParser, babelTraverse), + }); + + if (formattedNewSourceFile !== sourceFile) { + fs.writeFileSync(sourceFilePath, formattedNewSourceFile); } }; @@ -108,7 +118,40 @@ const groupSnapshotsByFrame = groupSnapshotsBy(({frame: {line, column}}) => ); const groupSnapshotsByFile = groupSnapshotsBy(({frame: {file}}) => file); -const createParser = ( +const indent = (snapshot: string, numIndents: number, indentation: string) => { + const lines = snapshot.split('\n'); + return lines + .map((line, index) => { + if (index === 0) { + // First line is either a 1-line snapshot or a blank line. + return line; + } else if (index !== lines.length - 1) { + // Not last line, indent one level deeper than expect call. + return indentation.repeat(numIndents + 1) + line; + } else { + // The last line should be placed on the same level as the expect call. + return indentation.repeat(numIndents) + line; + } + }) + .join('\n'); +}; + +const getAst = ( + parsers: {[key: string]: (text: string) => any}, + inferredParser: string, + text: string, +) => { + // Flow uses a 'Program' parent node, babel expects a 'File'. + let ast = parsers[inferredParser](text); + if (ast.type !== 'File') { + ast = file(ast, ast.comments, ast.tokens); + delete ast.program.comments; + } + return ast; +}; + +// This parser inserts snapshots into the AST. +const createInsertionParser = ( snapshots: Array, inferredParser: string, babelTraverse: Function, @@ -122,14 +165,8 @@ const createParser = ( const groupedSnapshots = groupSnapshotsByFrame(snapshots); const remainingSnapshots = new Set(snapshots.map(({snapshot}) => snapshot)); - let ast = parsers[inferredParser](text); - - // Flow uses a 'Program' parent node, babel expects a 'File'. - if (ast.type !== 'File') { - ast = file(ast, ast.comments, ast.tokens); - delete ast.program.comments; - } + const ast = getAst(parsers, inferredParser, text); babelTraverse(ast, { CallExpression({node: {arguments: args, callee}}: {node: CallExpression}) { if ( @@ -176,6 +213,70 @@ const createParser = ( return ast; }; +// This parser formats snapshots to the correct indentation. +const createFormattingParser = ( + inferredParser: string, + babelTraverse: Function, +) => ( + text: string, + parsers: {[key: string]: (text: string) => any}, + options: any, +) => { + // Workaround for https://github.com/prettier/prettier/issues/3150 + options.parser = inferredParser; + + const ast = getAst(parsers, inferredParser, text); + babelTraverse(ast, { + CallExpression({node: {arguments: args, callee}}: {node: CallExpression}) { + if ( + callee.type !== 'MemberExpression' || + callee.property.type !== 'Identifier' || + callee.property.name !== 'toMatchInlineSnapshot' || + !callee.loc || + callee.computed + ) { + return; + } + + let snapshotIndex: number | undefined; + let snapshot: string | undefined; + for (let i = 0; i < args.length; i++) { + const node = args[i]; + if (node.type === 'TemplateLiteral') { + snapshotIndex = i; + snapshot = node.quasis[0].value.raw; + } + } + if (snapshot === undefined || snapshotIndex === undefined) { + return; + } + + const useSpaces = !options.useTabs; + snapshot = indent( + snapshot, + Math.ceil( + useSpaces + ? callee.loc.start.column / options.tabWidth + : callee.loc.start.column / 2, // Each tab is 2 characters. + ), + useSpaces ? ' '.repeat(options.tabWidth) : '\t', + ); + + const replacementNode = templateLiteral( + [ + templateElement({ + raw: snapshot, + }), + ], + [], + ); + args[snapshotIndex] = replacementNode; + }, + }); + + return ast; +}; + const simpleDetectParser = (filePath: Config.Path) => { const extname = path.extname(filePath); if (/tsx?$/.test(extname)) {