diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c5cedd2e56..721224415e57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - `[jest-snapshot]` Display change counts in annotation lines ([#8982](https://github.com/facebook/jest/pull/8982)) - `[jest-snapshot]` [**BREAKING**] Improve report when the matcher has properties ([#9104](https://github.com/facebook/jest/pull/9104)) - `[jest-snapshot]` Improve colors when snapshots are updatable ([#9132](https://github.com/facebook/jest/pull/9132)) +- `[jest-snapshot]` Ignore indentation for most serialized objects ([#9203](https://github.com/facebook/jest/pull/9203)) - `[@jest/test-result]` Create method to create empty `TestResult` ([#8867](https://github.com/facebook/jest/pull/8867)) - `[jest-worker]` [**BREAKING**] Return a promise from `end()`, resolving with the information whether workers exited gracefully ([#8206](https://github.com/facebook/jest/pull/8206)) - `[jest-reporters]` Transform file paths into hyperlinks ([#8980](https://github.com/facebook/jest/pull/8980)) diff --git a/packages/jest-snapshot/src/__tests__/__snapshots__/printSnapshot.test.ts.snap b/packages/jest-snapshot/src/__tests__/__snapshots__/printSnapshot.test.ts.snap index 9e0b3a31889b..0a6ee670fc5e 100644 --- a/packages/jest-snapshot/src/__tests__/__snapshots__/printSnapshot.test.ts.snap +++ b/packages/jest-snapshot/src/__tests__/__snapshots__/printSnapshot.test.ts.snap @@ -375,6 +375,90 @@ Snapshot: "delete" Received: "insert" `; +exports[`printSnapshotAndReceived ignore indentation markup delete 1`] = ` +- Snapshot - 2 ++ Received + 0 + +
+-
+

+ Ignore indentation for most serialized objects +

+

+ Call + + diffLinesUnified2 + + to compare without indentation +

+-
+
+`; + +exports[`printSnapshotAndReceived ignore indentation markup fall back 1`] = ` +- Snapshot - 5 ++ Received + 7 + +- +- className="language-js" +- > +- for (key in foo) { ++
++ ++ className="language-js" ++ > ++ for (key in foo) { + if (Object.prototype.hasOwnProperty.call(foo, key)) { + doSomething(key); + } + } +- ++ ++
+`; + +exports[`printSnapshotAndReceived ignore indentation markup insert 1`] = ` +- Snapshot - 0 ++ Received + 7 + + ++ + when ++ ++ ++ title="ascending from older to newer" ++ > ++ ↓ ++ + +`; + +exports[`printSnapshotAndReceived ignore indentation object delete 1`] = ` +- Snapshot - 2 ++ Received + 0 + + Object { +- "payload": Object { + "text": "Ignore indentation in snapshot", + "time": "2019-11-11", +- }, + "type": "CREATE_ITEM", + } +`; + +exports[`printSnapshotAndReceived ignore indentation object insert 1`] = ` +- Snapshot - 0 ++ Received + 2 + + Object { ++ "payload": Object { + "text": "Ignore indentation in snapshot", + "time": "2019-11-11", ++ }, + "type": "CREATE_ITEM", + } +`; + exports[`printSnapshotAndReceived isLineDiffable false asymmetric matcher 1`] = ` Snapshot: null Received: Object { diff --git a/packages/jest-snapshot/src/__tests__/dedentLines.test.ts b/packages/jest-snapshot/src/__tests__/dedentLines.test.ts new file mode 100644 index 000000000000..43cdcb8447ed --- /dev/null +++ b/packages/jest-snapshot/src/__tests__/dedentLines.test.ts @@ -0,0 +1,249 @@ +/** + * 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 format = require('pretty-format'); +import {dedentLines} from '../dedentLines'; + +const $$typeof = Symbol.for('react.test.json'); +const plugins = [format.plugins.ReactTestComponent]; + +const formatLines2 = val => format(val, {indent: 2, plugins}).split('\n'); +const formatLines0 = val => format(val, {indent: 0, plugins}).split('\n'); + +describe('dedentLines non-null', () => { + test('no lines', () => { + const indented = []; + const dedented = indented; + + expect(dedentLines(indented)).toEqual(dedented); + }); + + test('one line empty string', () => { + const indented = ['']; + const dedented = indented; + + expect(dedentLines(indented)).toEqual(dedented); + }); + + test('one line empty object', () => { + const val = {}; + const indented = formatLines2(val); + const dedented = formatLines0(val); + + expect(dedentLines(indented)).toEqual(dedented); + }); + + test('one line self-closing element', () => { + const val = { + $$typeof, + children: null, + type: 'br', + }; + const indented = formatLines2(val); + const dedented = formatLines0(val); + + expect(dedentLines(indented)).toEqual(dedented); + }); + + test('object value empty string', () => { + const val = { + key: '', + }; + const indented = formatLines2(val); + const dedented = formatLines0(val); + + expect(dedentLines(indented)).toEqual(dedented); + }); + + test('object value string includes double-quote marks', () => { + const val = { + key: '"Always bet on JavaScript",', + }; + const indented = formatLines2(val); + const dedented = formatLines0(val); + + expect(dedentLines(indented)).toEqual(dedented); + }); + + test('markup with props and text', () => { + const val = { + $$typeof, + children: [ + { + $$typeof, + props: { + alt: 'Jest logo', + src: 'jest.svg', + }, + type: 'img', + }, + { + $$typeof, + children: ['Delightful JavaScript testing'], + type: 'h2', + }, + ], + type: 'header', + }; + const indented = formatLines2(val); + const dedented = formatLines0(val); + + expect(dedentLines(indented)).toEqual(dedented); + }); + + test('markup with components as props', () => { + // https://daveceddia.com/pluggable-slots-in-react-components/ + const val = { + $$typeof, + children: null, + props: { + content: { + $$typeof, + children: ['main content here'], + type: 'Content', + }, + sidebar: { + $$typeof, + children: null, + props: { + user: '0123456789abcdef', + }, + type: 'UserStats', + }, + }, + type: 'Body', + }; + const indented = formatLines2(val); + const dedented = formatLines0(val); + + expect(dedentLines(indented)).toEqual(dedented); + }); +}); + +describe('dedentLines null', () => { + test.each([ + ['object key multi-line', {'multi\nline\nkey': false}], + ['object value multi-line', {key: 'multi\nline\nvalue'}], + ['object key and value multi-line', {'multi\nline': '\nleading nl'}], + ])('%s', (name, val) => { + expect(dedentLines(formatLines2(val))).toEqual(null); + }); + + test('markup prop multi-line', () => { + const val = { + $$typeof, + children: null, + props: { + alt: 'trailing newline\n', + src: 'jest.svg', + }, + type: 'img', + }; + const indented = formatLines2(val); + + expect(dedentLines(indented)).toEqual(null); + }); + + test('markup prop component with multi-line text', () => { + // https://daveceddia.com/pluggable-slots-in-react-components/ + const val = { + $$typeof, + children: [ + { + $$typeof, + children: null, + props: { + content: { + $$typeof, + children: ['\n'], + type: 'Content', + }, + sidebar: { + $$typeof, + children: null, + props: { + user: '0123456789abcdef', + }, + type: 'UserStats', + }, + }, + type: 'Body', + }, + ], + type: 'main', + }; + const indented = formatLines2(val); + + expect(dedentLines(indented)).toEqual(null); + }); + + test('markup text multi-line', () => { + const text = [ + 'for (key in foo) {', + ' if (Object.prototype.hasOwnProperty.call(foo, key)) {', + ' doSomething(key);', + ' }', + '}', + ].join('\n'); + const val = { + $$typeof, + children: [ + { + $$typeof, + children: [text], + props: { + className: 'language-js', + }, + type: 'pre', + }, + ], + type: 'div', + }; + const indented = formatLines2(val); + + expect(dedentLines(indented)).toEqual(null); + }); + + test('markup text multiple lines', () => { + const lines = [ + 'for (key in foo) {', + ' if (Object.prototype.hasOwnProperty.call(foo, key)) {', + ' doSomething(key);', + ' }', + '}', + ]; + const val = { + $$typeof, + children: [ + { + $$typeof, + children: lines, + props: { + className: 'language-js', + }, + type: 'pre', + }, + ], + type: 'div', + }; + const indented = formatLines2(val); + + expect(dedentLines(indented)).toEqual(null); + }); + + test('markup unclosed self-closing start tag', () => { + const indented = [' { + const indented = ['

', ' Delightful JavaScript testing']; + + expect(dedentLines(indented)).toEqual(null); + }); +}); diff --git a/packages/jest-snapshot/src/__tests__/printSnapshot.test.ts b/packages/jest-snapshot/src/__tests__/printSnapshot.test.ts index 97858fdbeb13..be2c9a431678 100644 --- a/packages/jest-snapshot/src/__tests__/printSnapshot.test.ts +++ b/packages/jest-snapshot/src/__tests__/printSnapshot.test.ts @@ -1176,6 +1176,127 @@ describe('printSnapshotAndReceived', () => { expect(testWithStringify(expected, received, false)).toMatchSnapshot(); }); + describe('ignore indentation', () => { + const $$typeof = Symbol.for('react.test.json'); + + test('markup delete', () => { + const received = { + $$typeof, + children: [ + { + $$typeof, + children: ['Ignore indentation for most serialized objects'], + type: 'h3', + }, + { + $$typeof, + children: [ + 'Call ', + { + $$typeof, + children: ['diffLinesUnified2'], + type: 'code', + }, + ' to compare without indentation', + ], + type: 'p', + }, + ], + type: 'div', + }; + const expected = { + $$typeof, + children: [received], + type: 'div', + }; + + expect(testWithStringify(expected, received, false)).toMatchSnapshot(); + }); + + test('markup fall back', () => { + // Because text has more than one adjacent line. + const text = [ + 'for (key in foo) {', + ' if (Object.prototype.hasOwnProperty.call(foo, key)) {', + ' doSomething(key);', + ' }', + '}', + ].join('\n'); + + const expected = { + $$typeof, + children: [text], + props: { + className: 'language-js', + }, + type: 'pre', + }; + const received = { + $$typeof, + children: [expected], + type: 'div', + }; + + expect(testWithStringify(expected, received, false)).toMatchSnapshot(); + }); + + test('markup insert', () => { + const text = 'when'; + const expected = { + $$typeof, + children: [text], + type: 'th', + }; + const received = { + $$typeof, + children: [ + { + $$typeof, + children: [text], + type: 'span', + }, + { + $$typeof, + children: ['↓'], + props: { + title: 'ascending from older to newer', + }, + type: 'abbr', + }, + ], + type: 'th', + }; + + expect(testWithStringify(expected, received, false)).toMatchSnapshot(); + }); + + describe('object', () => { + const text = 'Ignore indentation in snapshot'; + const time = '2019-11-11'; + const type = 'CREATE_ITEM'; + const less = { + text, + time, + type, + }; + const more = { + payload: { + text, + time, + }, + type, + }; + + test('delete', () => { + expect(testWithStringify(more, less, false)).toMatchSnapshot(); + }); + + test('insert', () => { + expect(testWithStringify(less, more, false)).toMatchSnapshot(); + }); + }); + }); + describe('without serialize', () => { test('backtick single line expected and received', () => { const expected = 'var foo = `backtick`;'; diff --git a/packages/jest-snapshot/src/dedentLines.ts b/packages/jest-snapshot/src/dedentLines.ts new file mode 100644 index 000000000000..3d637a2200d1 --- /dev/null +++ b/packages/jest-snapshot/src/dedentLines.ts @@ -0,0 +1,149 @@ +/** + * 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. + */ + +const getIndentationLength = (line: string): number => { + const result = /^( {2})+/.exec(line); + return result === null ? 0 : result[0].length; +}; + +const dedentLine = (line: string): string => + line.slice(getIndentationLength(line)); + +// Return true if: +// "key": "value has multiple lines\n… +// "key has multiple lines\n… +const hasUnmatchedDoubleQuoteMarks = (string: string): boolean => { + let n = 0; + + let i = string.indexOf('"', 0); + while (i !== -1) { + if (i === 0 || string[i - 1] !== '\\') { + n += 1; + } + + i = string.indexOf('"', i + 1); + } + + return n % 2 !== 0; +}; + +const isFirstLineOfTag = (line: string) => /^( {2})*\, + output: Array, +): boolean => { + let line = input[output.length]; + output.push(dedentLine(line)); + + if (line.includes('>')) { + return true; + } + + while (output.length < input.length) { + line = input[output.length]; + + if (hasUnmatchedDoubleQuoteMarks(line)) { + return false; // because props include a multiline string + } else if (isFirstLineOfTag(line)) { + // Recursion only if props have markup. + if (!dedentMarkup(input, output)) { + return false; + } + } else { + output.push(dedentLine(line)); + + if (line.includes('>')) { + return true; + } + } + } + + return false; +}; + +// Push dedented lines of markup onto output and return true; +// otherwise return false because: +// * props include a multiline string +// * text has more than one adjacent line +// * markup does not close +const dedentMarkup = (input: Array, output: Array): boolean => { + let line = input[output.length]; + + if (!dedentStartTag(input, output)) { + return false; + } + + if (input[output.length - 1].includes('/>')) { + return true; + } + + let isText = false; + const stack: Array = []; + stack.push(getIndentationLength(line)); + + while (stack.length > 0 && output.length < input.length) { + line = input[output.length]; + + if (isFirstLineOfTag(line)) { + if (line.includes('')) { + stack.push(getIndentationLength(line)); + } + } + isText = false; + } else { + if (isText) { + return false; // because text has more than one adjacent line + } + + const indentationLengthOfTag = stack[stack.length - 1]; + output.push(line.slice(indentationLengthOfTag + 2)); + isText = true; + } + } + + return stack.length === 0; +}; + +// Return lines unindented by heuristic; +// otherwise return null because: +// * props include a multiline string +// * text has more than one adjacent line +// * markup does not close +export const dedentLines = (input: Array): Array | null => { + const output: Array = []; + + while (output.length < input.length) { + const line = input[output.length]; + + if (hasUnmatchedDoubleQuoteMarks(line)) { + return null; + } else if (isFirstLineOfTag(line)) { + if (!dedentMarkup(input, output)) { + return null; + } + } else { + output.push(dedentLine(line)); + } + } + + return output; +}; diff --git a/packages/jest-snapshot/src/printSnapshot.ts b/packages/jest-snapshot/src/printSnapshot.ts index 4b7bbb80a9a1..2c73544cd1c9 100644 --- a/packages/jest-snapshot/src/printSnapshot.ts +++ b/packages/jest-snapshot/src/printSnapshot.ts @@ -17,6 +17,7 @@ import { Diff, DiffOptionsColor, diffLinesUnified, + diffLinesUnified2, diffStringsRaw, diffStringsUnified, } from 'jest-diff'; @@ -42,6 +43,7 @@ import { bForeground2, bForeground3, } from './colors'; +import {dedentLines} from './dedentLines'; import {MatchSnapshotConfig} from './types'; import {deserializeString, minify, serialize} from './utils'; @@ -306,8 +308,22 @@ export const printSnapshotAndReceived = ( } if (isLineDiffable(received)) { - // TODO future PR will replace with diffLinesUnified2 to ignore indentation - return diffLinesUnified(a.split('\n'), b.split('\n'), options); + const aLines2 = a.split('\n'); + const bLines2 = b.split('\n'); + + const aLines0 = dedentLines(aLines2); + + if (aLines0 !== null) { + // Compare lines without indentation. + const bLines0 = serialize(received, 0).split('\n'); + return diffLinesUnified2(aLines2, bLines2, aLines0, bLines0, options); + } + + // Fall back because: + // * props include a multiline string + // * text has more than one adjacent line + // * markup does not close + return diffLinesUnified(aLines2, bLines2, options); } const printLabel = getLabelPrinter(aAnnotation, bAnnotation); diff --git a/packages/jest-snapshot/src/utils.ts b/packages/jest-snapshot/src/utils.ts index 379319575706..8d9f6a8374f4 100644 --- a/packages/jest-snapshot/src/utils.ts +++ b/packages/jest-snapshot/src/utils.ts @@ -139,10 +139,11 @@ export const removeExtraLineBreaks = (string: string): string => const escapeRegex = true; const printFunctionName = false; -export const serialize = (val: unknown): string => +export const serialize = (val: unknown, indent = 2): string => normalizeNewlines( prettyFormat(val, { escapeRegex, + indent, plugins: getSerializers(), printFunctionName, }),