diff --git a/docs/rules/no-large-snapshots.md b/docs/rules/no-large-snapshots.md index 075f76ea6..f785193a9 100644 --- a/docs/rules/no-large-snapshots.md +++ b/docs/rules/no-large-snapshots.md @@ -98,7 +98,7 @@ line 4 ## Options -This rule has option for modifying the max number of lines allowed for a +This rule has an option for modifying the max number of lines allowed for a snapshot: In an `eslintrc` file: @@ -110,3 +110,49 @@ In an `eslintrc` file: } ... ``` + +In addition there is an option for whitelisting large snapshot files. Since +`//eslint` comments will be removed when a `.snap` file is updated, this option +provides a way of whitelisting large snapshots. The list of whitelistedSnapshots +is keyed first on the absolute filepath of the snapshot file. You can then +provide an array of strings to match the snapshot names against. If you're using +a `.eslintrc.js` file, you can use regular expressions AND strings. + +In an `.eslintrc.js` file: + +```javascript +... + + "rules": { + "jest/no-large-snapshots": ["error", + { + "whitelistedSnapshots": { + "/path/to/file.js.snap": ["snapshot name 1", /a big snapshot \d+/] + } + }] + } + +... +``` + +Note: If you store your paths as relative paths, you can use `path.resolve` so +that it can be shared between computers. For example, suppose you have your +whitelisted snapshots in a file called `allowed-snaps.js` which stores them as +relative paths. To convert them to absolute paths you can do something like the +following: + +```javascript +const path = require('path'); +const {mapKeys} = require('lodash'); + + +const allowedSnapshots = require('./allowed-snaps.js'); +const whitelistedSnapshots = mapKeys(allowedSnapshots, (val, file) => path.resolve(__dirname, file)); + +... + rules: { + "jest/no-large-snapshots": ["error", + { whitelistedSnapshots } + ] + } +``` diff --git a/package.json b/package.json index 7084cf3b8..6f70da022 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "@babel/preset-env": "^7.4.4", "@commitlint/cli": "^6.0.0", "@commitlint/config-conventional": "^6.0.0", + "babel-eslint": "^10.0.2", "babel-jest": "^24.8.0", "eslint": "^5.1.0", "eslint-config-prettier": "^5.1.0", diff --git a/src/rules/__tests__/__snapshots__/no-large-snapshots.test.js.snap b/src/rules/__tests__/__snapshots__/no-large-snapshots.test.js.snap index d2f27e51c..1c877d4aa 100644 --- a/src/rules/__tests__/__snapshots__/no-large-snapshots.test.js.snap +++ b/src/rules/__tests__/__snapshots__/no-large-snapshots.test.js.snap @@ -1,5 +1,621 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`no-large-snapshots ExpressionStatement function should not report whitelisted large snapshots based on regexp 1`] = ` +Array [ + Object { + "data": Object { + "lineCount": 58, + "lineLimit": 50, + }, + "messageId": "tooLongSnapshots", + "node": Node { + "_babelType": "ExpressionStatement", + "end": 328, + "expression": Node { + "_babelType": "AssignmentExpression", + "end": 327, + "left": Node { + "_babelType": "MemberExpression", + "computed": true, + "end": 36, + "loc": SourceLocation { + "end": Position { + "column": 36, + "line": 1, + }, + "start": Position { + "column": 0, + "line": 1, + }, + }, + "object": Node { + "_babelType": "Identifier", + "end": 7, + "loc": SourceLocation { + "end": Position { + "column": 7, + "line": 1, + }, + "identifierName": "exports", + "start": Position { + "column": 0, + "line": 1, + }, + }, + "name": "exports", + "range": Array [ + 0, + 7, + ], + "start": 0, + "type": "Identifier", + }, + "property": Node { + "_babelType": "TemplateLiteral", + "end": 35, + "expressions": Array [], + "loc": SourceLocation { + "end": Position { + "column": 35, + "line": 1, + }, + "start": Position { + "column": 8, + "line": 1, + }, + }, + "quasis": Array [ + Node { + "_babelType": "TemplateElement", + "end": 34, + "loc": SourceLocation { + "end": Position { + "column": 35, + "line": 1, + }, + "start": Position { + "column": 8, + "line": 1, + }, + }, + "range": Array [ + 8, + 35, + ], + "start": 9, + "tail": true, + "type": "TemplateElement", + "value": Object { + "cooked": "a big component with text", + "raw": "a big component with text", + }, + }, + ], + "range": Array [ + 8, + 35, + ], + "start": 8, + "type": "TemplateLiteral", + }, + "range": Array [ + 0, + 36, + ], + "start": 0, + "type": "MemberExpression", + }, + "loc": SourceLocation { + "end": Position { + "column": 1, + "line": 59, + }, + "start": Position { + "column": 0, + "line": 1, + }, + }, + "operator": "=", + "range": Array [ + 0, + 327, + ], + "right": Node { + "_babelType": "TemplateLiteral", + "end": 327, + "expressions": Array [], + "loc": SourceLocation { + "end": Position { + "column": 1, + "line": 59, + }, + "start": Position { + "column": 39, + "line": 1, + }, + }, + "quasis": Array [ + Node { + "_babelType": "TemplateElement", + "end": 326, + "loc": SourceLocation { + "end": Position { + "column": 1, + "line": 59, + }, + "start": Position { + "column": 39, + "line": 1, + }, + }, + "range": Array [ + 39, + 327, + ], + "start": 40, + "tail": true, + "type": "TemplateElement", + "value": Object { + "cooked": " +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +", + "raw": " +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +", + }, + }, + ], + "range": Array [ + 39, + 327, + ], + "start": 39, + "type": "TemplateLiteral", + }, + "start": 0, + "type": "AssignmentExpression", + }, + "loc": SourceLocation { + "end": Position { + "column": 2, + "line": 59, + }, + "start": Position { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 328, + ], + "start": 0, + "type": "ExpressionStatement", + }, + }, +] +`; + +exports[`no-large-snapshots ExpressionStatement function should report if file is not whitelisted 1`] = ` +Array [ + Object { + "data": Object { + "lineCount": 58, + "lineLimit": 50, + }, + "messageId": "tooLongSnapshots", + "node": Node { + "_babelType": "ExpressionStatement", + "end": 320, + "expression": Node { + "_babelType": "AssignmentExpression", + "end": 319, + "left": Node { + "_babelType": "MemberExpression", + "computed": true, + "end": 28, + "loc": SourceLocation { + "end": Position { + "column": 28, + "line": 1, + }, + "start": Position { + "column": 0, + "line": 1, + }, + }, + "object": Node { + "_babelType": "Identifier", + "end": 7, + "loc": SourceLocation { + "end": Position { + "column": 7, + "line": 1, + }, + "identifierName": "exports", + "start": Position { + "column": 0, + "line": 1, + }, + }, + "name": "exports", + "range": Array [ + 0, + 7, + ], + "start": 0, + "type": "Identifier", + }, + "property": Node { + "_babelType": "TemplateLiteral", + "end": 27, + "expressions": Array [], + "loc": SourceLocation { + "end": Position { + "column": 27, + "line": 1, + }, + "start": Position { + "column": 8, + "line": 1, + }, + }, + "quasis": Array [ + Node { + "_babelType": "TemplateElement", + "end": 26, + "loc": SourceLocation { + "end": Position { + "column": 27, + "line": 1, + }, + "start": Position { + "column": 8, + "line": 1, + }, + }, + "range": Array [ + 8, + 27, + ], + "start": 9, + "tail": true, + "type": "TemplateElement", + "value": Object { + "cooked": "a big component 1", + "raw": "a big component 1", + }, + }, + ], + "range": Array [ + 8, + 27, + ], + "start": 8, + "type": "TemplateLiteral", + }, + "range": Array [ + 0, + 28, + ], + "start": 0, + "type": "MemberExpression", + }, + "loc": SourceLocation { + "end": Position { + "column": 1, + "line": 59, + }, + "start": Position { + "column": 0, + "line": 1, + }, + }, + "operator": "=", + "range": Array [ + 0, + 319, + ], + "right": Node { + "_babelType": "TemplateLiteral", + "end": 319, + "expressions": Array [], + "loc": SourceLocation { + "end": Position { + "column": 1, + "line": 59, + }, + "start": Position { + "column": 31, + "line": 1, + }, + }, + "quasis": Array [ + Node { + "_babelType": "TemplateElement", + "end": 318, + "loc": SourceLocation { + "end": Position { + "column": 1, + "line": 59, + }, + "start": Position { + "column": 31, + "line": 1, + }, + }, + "range": Array [ + 31, + 319, + ], + "start": 32, + "tail": true, + "type": "TemplateElement", + "value": Object { + "cooked": " +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +", + "raw": " +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +line +", + }, + }, + ], + "range": Array [ + 31, + 319, + ], + "start": 31, + "type": "TemplateLiteral", + }, + "start": 0, + "type": "AssignmentExpression", + }, + "loc": SourceLocation { + "end": Position { + "column": 2, + "line": 59, + }, + "start": Position { + "column": 0, + "line": 1, + }, + }, + "range": Array [ + 0, + 320, + ], + "start": 0, + "type": "ExpressionStatement", + }, + }, +] +`; + exports[`no-large-snapshots ExpressionStatement function should report if maxSize is zero 1`] = ` Array [ Object { diff --git a/src/rules/__tests__/no-large-snapshots.test.js b/src/rules/__tests__/no-large-snapshots.test.js index dc431cefb..f8cd2c200 100644 --- a/src/rules/__tests__/no-large-snapshots.test.js +++ b/src/rules/__tests__/no-large-snapshots.test.js @@ -3,12 +3,16 @@ const { RuleTester } = require('eslint'); const rule = require('../no-large-snapshots'); const noLargeSnapshots = rule.create; +const { parse } = require('babel-eslint'); const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2015, }, }); +// lines - 1 to account for the starting newline we always add. +const generateSnapshotNode = ({ lines, title = 'a big component 1' }) => + parse(`exports[\`${title}\`] = \`\n${'line\n'.repeat(lines - 1)}\`;`).body[0]; ruleTester.run('no-large-snapshots', rule, { valid: [ @@ -67,7 +71,7 @@ describe('no-large-snapshots', () => { it('should return an object with an ExpressionStatement function for snapshot files', () => { const mockContext = { - getFilename: () => 'mock-component.jsx.snap', + getFilename: () => '/mock-component.jsx.snap', options: [], }; @@ -82,7 +86,7 @@ describe('no-large-snapshots', () => { it('should report if node has more than 50 lines of code and no sizeThreshold option is passed', () => { const mockReport = jest.fn(); const mockContext = { - getFilename: () => 'mock-component.jsx.snap', + getFilename: () => '/mock-component.jsx.snap', options: [], report: mockReport, }; @@ -105,7 +109,7 @@ describe('no-large-snapshots', () => { it('should report if node has more lines of code than number given in sizeThreshold option', () => { const mockReport = jest.fn(); const mockContext = { - getFilename: () => 'mock-component.jsx.snap', + getFilename: () => '/mock-component.jsx.snap', options: [{ maxSize: 70 }], report: mockReport, }; @@ -128,7 +132,7 @@ describe('no-large-snapshots', () => { it('should report if maxSize is zero', () => { const mockReport = jest.fn(); const mockContext = { - getFilename: () => 'mock-component.jsx.snap', + getFilename: () => '/mock-component.jsx.snap', options: [{ maxSize: 0 }], report: mockReport, }; @@ -151,7 +155,7 @@ describe('no-large-snapshots', () => { it('should not report if node has fewer lines of code than limit', () => { const mockReport = jest.fn(); const mockContext = { - getFilename: () => 'mock-component.jsx.snap', + getFilename: () => '/mock-component.jsx.snap', options: [], report: mockReport, }; @@ -169,5 +173,123 @@ describe('no-large-snapshots', () => { expect(mockReport).not.toHaveBeenCalled(); }); + + it('should not report whitelisted large snapshots', () => { + const mockReport = jest.fn(); + const mockContext = { + getFilename: () => '/mock-component.jsx.snap', + options: [ + { + whitelistedSnapshots: { + '/mock-component.jsx.snap': ['a big component 1'], + }, + }, + ], + report: mockReport, + }; + + const snapshotNode = generateSnapshotNode({ lines: 58 }); + + noLargeSnapshots(mockContext).ExpressionStatement(snapshotNode); + + expect(mockReport).not.toHaveBeenCalled(); + }); + + it('should report if file is not whitelisted', () => { + const mockReport = jest.fn(); + const mockContext = { + getFilename: () => '/mock-component.jsx.snap', + options: [ + { + whitelistedSnapshots: { + '/other-mock-component.jsx.snap': [/a big component \d+/], + }, + }, + ], + report: mockReport, + }; + + const snapshotNode = generateSnapshotNode({ lines: 58 }); + + noLargeSnapshots(mockContext).ExpressionStatement(snapshotNode); + + expect(mockReport).toHaveBeenCalledTimes(1); + expect(mockReport.mock.calls[0]).toMatchSnapshot(); + }); + + it('should not report whitelisted large snapshots based on regexp', () => { + const mockReport = jest.fn(); + const mockContext = { + getFilename: () => '/mock-component.jsx.snap', + options: [ + { + whitelistedSnapshots: { + '/mock-component.jsx.snap': [/a big component \d+/], + }, + }, + ], + report: mockReport, + }; + + const snapshotNode = generateSnapshotNode({ lines: 58 }); + + noLargeSnapshots(mockContext).ExpressionStatement(snapshotNode); + + expect(mockReport).not.toHaveBeenCalled(); + + const otherSnapshotNode = generateSnapshotNode({ + lines: 58, + title: 'a big component with text', + }); + + noLargeSnapshots(mockContext).ExpressionStatement(otherSnapshotNode); + + expect(mockReport).toHaveBeenCalledTimes(1); + expect(mockReport.mock.calls[0]).toMatchSnapshot(); + }); + + it('should throw exeption if relative paths are passed as whitelist keys', () => { + const mockReport = jest.fn(); + const mockContext = { + getFilename: () => '/mock-component.jsx.snap', + options: [ + { + whitelistedSnapshots: { + 'mock-component.jsx.snap': [/a big component \d+/], + }, + }, + ], + report: mockReport, + }; + + const snapshotNode = generateSnapshotNode({ lines: 58 }); + + expect(() => + noLargeSnapshots(mockContext).ExpressionStatement(snapshotNode), + ).toThrow( + 'All paths for whitelistedSnapshots must be absolute. You can use JS config and `path.resolve`', + ); + }); + + it('should not throw exeption if absolute paths are passed as whitelist keys', () => { + const mockReport = jest.fn(); + const mockContext = { + getFilename: () => '/mock-component.jsx.snap', + options: [ + { + whitelistedSnapshots: { + '/mock-component.jsx.snap': [/a big component \d+/], + }, + }, + ], + report: mockReport, + }; + + const snapshotNode = generateSnapshotNode({ lines: 58 }); + + expect(() => + noLargeSnapshots(mockContext).ExpressionStatement(snapshotNode), + ).not.toThrow(); + }); }); }); diff --git a/src/rules/no-large-snapshots.js b/src/rules/no-large-snapshots.js index 240e7c17a..64e0a7d55 100644 --- a/src/rules/no-large-snapshots.js +++ b/src/rules/no-large-snapshots.js @@ -1,6 +1,8 @@ 'use strict'; -const { getDocsUrl } = require('./util'); +const { getDocsUrl, getStringValue } = require('./util'); + +const path = require('path'); const reportOnViolation = (context, node) => { const lineLimit = @@ -10,8 +12,40 @@ const reportOnViolation = (context, node) => { const startLine = node.loc.start.line; const endLine = node.loc.end.line; const lineCount = endLine - startLine; + const whitelistedSnapshots = + context.options && + context.options[0] && + context.options[0].whitelistedSnapshots; + + const allPathsAreAbsolute = Object.keys(whitelistedSnapshots || {}).every( + path.isAbsolute, + ); + + if (!allPathsAreAbsolute) { + throw new Error( + 'All paths for whitelistedSnapshots must be absolute. You can use JS config and `path.resolve`', + ); + } + + let isWhitelisted = false; - if (lineCount > lineLimit) { + if (whitelistedSnapshots) { + const fileName = context.getFilename(); + const whitelistedSnapshotsInFile = whitelistedSnapshots[fileName]; + + if (whitelistedSnapshotsInFile) { + const snapshotName = getStringValue(node.expression.left.property); + isWhitelisted = whitelistedSnapshotsInFile.some(name => { + if (name.test && typeof name.test === 'function') { + return name.test(snapshotName); + } else { + return name === snapshotName; + } + }); + } + } + + if (!isWhitelisted && lineCount > lineLimit) { context.report({ messageId: lineLimit === 0 ? 'noSnapshot' : 'tooLongSnapshots', data: { lineLimit, lineCount }, @@ -37,6 +71,12 @@ module.exports = { maxSize: { type: 'number', }, + whitelistedSnapshots: { + type: 'object', + patternProperties: { + '.*': { type: 'array' }, + }, + }, }, additionalProperties: false, }, diff --git a/yarn.lock b/yarn.lock index 02da79333..63d74c346 100644 --- a/yarn.lock +++ b/yarn.lock @@ -230,7 +230,7 @@ esutils "^2.0.2" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.4.3", "@babel/parser@^7.4.4", "@babel/parser@^7.5.0": +"@babel/parser@^7.0.0", "@babel/parser@^7.1.0", "@babel/parser@^7.4.3", "@babel/parser@^7.4.4", "@babel/parser@^7.5.0": version "7.5.0" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.5.0.tgz#3e0713dff89ad6ae37faec3b29dcfc5c979770b7" integrity sha512-I5nW8AhGpOXGCCNYGc+p7ExQIBxRFnS2fd/d862bNOKvmoEPjYPcfIjsfdy0ujagYOIYPczKgD9l3FsgTkAzKA== @@ -638,7 +638,7 @@ "@babel/parser" "^7.4.4" "@babel/types" "^7.4.4" -"@babel/traverse@^7.1.0", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.4", "@babel/traverse@^7.5.0": +"@babel/traverse@^7.0.0", "@babel/traverse@^7.1.0", "@babel/traverse@^7.4.3", "@babel/traverse@^7.4.4", "@babel/traverse@^7.5.0": version "7.5.0" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.5.0.tgz#4216d6586854ef5c3c4592dab56ec7eb78485485" integrity sha512-SnA9aLbyOCcnnbQEGwdfBggnc142h/rbqqsXcaATj2hZcegCl903pUD/lfpsNBlBSuWow/YDfRyJuWi2EPR5cg== @@ -1262,6 +1262,18 @@ aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" integrity sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ== +babel-eslint@^10.0.2: + version "10.0.2" + resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.0.2.tgz#182d5ac204579ff0881684b040560fdcc1558456" + integrity sha512-UdsurWPtgiPgpJ06ryUnuaSXC2s0WoSZnQmEpbAH65XZSdwowgN5MvyP7e88nW07FYXv72erVtpBkxyDVKhH1Q== + dependencies: + "@babel/code-frame" "^7.0.0" + "@babel/parser" "^7.0.0" + "@babel/traverse" "^7.0.0" + "@babel/types" "^7.0.0" + eslint-scope "3.7.1" + eslint-visitor-keys "^1.0.0" + babel-jest@^24.8.0: version "24.8.0" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-24.8.0.tgz#5c15ff2b28e20b0f45df43fe6b7f2aae93dba589" @@ -2095,6 +2107,14 @@ eslint-plugin-prettier@^3.0.0: dependencies: prettier-linter-helpers "^1.0.0" +eslint-scope@3.7.1: + version "3.7.1" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-3.7.1.tgz#3d63c3edfda02e06e01a452ad88caacc7cdcb6e8" + integrity sha1-PWPD7f2gLgbgGkUq2IyqzHzctug= + dependencies: + esrecurse "^4.1.0" + estraverse "^4.1.1" + eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848"