Skip to content

Commit a325684

Browse files
committed
Initial implementation of "@rushstack/hoist-jest-mock" rule
1 parent 8448438 commit a325684

File tree

5 files changed

+235
-3
lines changed

5 files changed

+235
-3
lines changed
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import { ESLintUtils } from '@typescript-eslint/experimental-utils';
5+
import { hoistJestMock } from './hoist-jest-mock';
6+
7+
const { RuleTester } = ESLintUtils;
8+
const ruleTester = new RuleTester({
9+
parser: '@typescript-eslint/parser'
10+
});
11+
12+
// These are the same cases considered by ts-jest's hoist-jest.spec.ts
13+
const CODE_WITH_HOISTING = [
14+
/* 001 */ "const foo = 'foo'",
15+
/* 002 */ 'console.log(foo)',
16+
/* 003 */ 'jest.enableAutomock()',
17+
/* 004 */ 'jest.disableAutomock()',
18+
/* 005 */ "jest.mock('./foo')",
19+
/* 006 */ "jest.mock('./foo/bar', () => 'bar')",
20+
/* 007 */ "jest.unmock('./bar/foo').dontMock('./bar/bar')",
21+
/* 008 */ "jest.deepUnmock('./foo')",
22+
/* 009 */ "jest.mock('./foo').mock('./bar')",
23+
/* 010 */ 'const func = () => {',
24+
/* 011 */ " const bar = 'bar'",
25+
/* 012 */ ' console.log(bar)',
26+
/* 013 */ " jest.unmock('./foo')",
27+
/* 014 */ " jest.mock('./bar')",
28+
/* 015 */ " jest.mock('./bar/foo', () => 'foo')",
29+
/* 016 */ " jest.unmock('./foo/bar')",
30+
/* 017 */ " jest.unmock('./bar/foo').dontMock('./bar/bar')",
31+
/* 018 */ " jest.deepUnmock('./bar')",
32+
/* 019 */ " jest.mock('./foo').mock('./bar')",
33+
/* 020 */ '}',
34+
/* 021 */ 'const func2 = () => {',
35+
/* 022 */ " const bar = 'bar'",
36+
/* 023 */ ' console.log(bar)',
37+
/* 024 */ " jest.mock('./bar')",
38+
/* 025 */ " jest.unmock('./foo/bar')",
39+
/* 026 */ " jest.mock('./bar/foo', () => 'foo')",
40+
/* 027 */ " jest.unmock('./foo')",
41+
/* 028 */ " jest.unmock('./bar/foo').dontMock('./bar/bar')",
42+
/* 029 */ " jest.deepUnmock('./bar')",
43+
/* 030 */ " jest.mock('./foo').mock('./bar')",
44+
/* 031 */ '}'
45+
].join('\n');
46+
47+
ruleTester.run('hoist-jest-mock', hoistJestMock, {
48+
invalid: [
49+
{
50+
code: CODE_WITH_HOISTING,
51+
errors: [
52+
{ messageId: 'error-unhoisted-jest-mock', line: 3 },
53+
{ messageId: 'error-unhoisted-jest-mock', line: 4 },
54+
{ messageId: 'error-unhoisted-jest-mock', line: 5 },
55+
{ messageId: 'error-unhoisted-jest-mock', line: 6 },
56+
{ messageId: 'error-unhoisted-jest-mock', line: 7 },
57+
{ messageId: 'error-unhoisted-jest-mock', line: 8 },
58+
{ messageId: 'error-unhoisted-jest-mock', line: 9 },
59+
60+
{ messageId: 'error-unhoisted-jest-mock', line: 13 },
61+
{ messageId: 'error-unhoisted-jest-mock', line: 14 },
62+
{ messageId: 'error-unhoisted-jest-mock', line: 15 },
63+
{ messageId: 'error-unhoisted-jest-mock', line: 16 },
64+
{ messageId: 'error-unhoisted-jest-mock', line: 17 },
65+
{ messageId: 'error-unhoisted-jest-mock', line: 18 },
66+
{ messageId: 'error-unhoisted-jest-mock', line: 19 },
67+
68+
{ messageId: 'error-unhoisted-jest-mock', line: 24 },
69+
{ messageId: 'error-unhoisted-jest-mock', line: 25 },
70+
{ messageId: 'error-unhoisted-jest-mock', line: 26 },
71+
{ messageId: 'error-unhoisted-jest-mock', line: 27 },
72+
{ messageId: 'error-unhoisted-jest-mock', line: 28 },
73+
{ messageId: 'error-unhoisted-jest-mock', line: 29 },
74+
{ messageId: 'error-unhoisted-jest-mock', line: 30 }
75+
]
76+
}
77+
],
78+
valid: []
79+
});
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import type { TSESLint, TSESTree } from '@typescript-eslint/experimental-utils';
5+
import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils';
6+
7+
import { matchTree } from './matchTree';
8+
import * as hoistJestMockPatterns from './hoistJestMockPatterns';
9+
10+
type MessageIds = 'error-unhoisted-jest-mock';
11+
type Options = [];
12+
13+
// Jest APIs that need to be hoisted
14+
// Based on HOIST_METHODS from ts-jest
15+
const HOIST_METHODS = ['mock', 'unmock', 'enableAutomock', 'disableAutomock', 'deepUnmock'];
16+
17+
const hoistJestMock: TSESLint.RuleModule<MessageIds, Options> = {
18+
meta: {
19+
type: 'problem',
20+
messages: {
21+
'error-unhoisted-jest-mock':
22+
"Jest's mocks must be installed before the associated module is imported. Move this line to the top of its block."
23+
},
24+
schema: [
25+
{
26+
type: 'object',
27+
additionalProperties: false
28+
}
29+
],
30+
docs: {
31+
description: 'Require Jest mock calls to be manually hoisted to the top of their scope.',
32+
category: 'Possible Errors',
33+
recommended: 'error',
34+
url: 'https://www.npmjs.com/package/@rushstack/eslint-plugin'
35+
}
36+
},
37+
38+
create: (context: TSESLint.RuleContext<MessageIds, Options>) => {
39+
function isHoistableJestCall(node: TSESTree.Node | undefined): boolean {
40+
if (node === undefined) {
41+
return false;
42+
}
43+
44+
const captures: hoistJestMockPatterns.IJestCallExpression = {};
45+
46+
if (matchTree(node, hoistJestMockPatterns.jestCallExpression, captures)) {
47+
if (captures.methodName && HOIST_METHODS.indexOf(captures.methodName) >= 0) {
48+
return true;
49+
}
50+
}
51+
52+
// Recurse into some common expression-combining syntaxes
53+
switch (node.type) {
54+
case AST_NODE_TYPES.CallExpression:
55+
return isHoistableJestCall(node.callee);
56+
case AST_NODE_TYPES.MemberExpression:
57+
return isHoistableJestCall(node.object);
58+
case AST_NODE_TYPES.LogicalExpression:
59+
return isHoistableJestCall(node.left) || isHoistableJestCall(node.right);
60+
}
61+
62+
return false;
63+
}
64+
65+
function isHoistableJestStatement(node: TSESTree.Node): boolean {
66+
switch (node.type) {
67+
case AST_NODE_TYPES.ExpressionStatement:
68+
return isHoistableJestCall(node.expression);
69+
}
70+
return false;
71+
}
72+
73+
return {
74+
'TSModuleBlock, BlockStatement, Program': (
75+
node: TSESTree.TSModuleBlock | TSESTree.BlockStatement | TSESTree.Program
76+
): void => {
77+
let encounteredRegularStatements: boolean = false;
78+
79+
for (const statement of node.body) {
80+
if (isHoistableJestStatement(statement)) {
81+
// Are we still at the start of the block?
82+
if (encounteredRegularStatements) {
83+
context.report({ node: statement, messageId: 'error-unhoisted-jest-mock' });
84+
}
85+
} else {
86+
// We encountered a non-hoistable statement, so any further children that we visit
87+
// must also be non-hoistable
88+
encounteredRegularStatements = true;
89+
}
90+
}
91+
}
92+
};
93+
}
94+
};
95+
96+
export { hoistJestMock };
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
2+
// See LICENSE in the project root for license information.
3+
4+
import { matchTreeArg } from './matchTree';
5+
6+
export interface IJestCallExpression {
7+
// Example: "mock" from "jest.mock('./thing')"
8+
methodName?: string;
9+
}
10+
11+
// Matches a statement expression like this:
12+
// jest.mock('./thing')
13+
//
14+
// Tree:
15+
// {
16+
// type: 'CallExpression',
17+
// callee: {
18+
// type: 'MemberExpression',
19+
// object: {
20+
// type: 'Identifier',
21+
// name: 'jest'
22+
// },
23+
// property: {
24+
// type: 'Identifier',
25+
// name: 'mock'
26+
// }
27+
// },
28+
// arguments: [
29+
// {
30+
// type: 'Literal',
31+
// value: './thing'
32+
// }
33+
// ]
34+
// };
35+
export const jestCallExpression = {
36+
type: 'CallExpression',
37+
callee: {
38+
type: 'MemberExpression',
39+
object: {
40+
type: 'Identifier',
41+
name: 'jest'
42+
},
43+
property: {
44+
type: 'Identifier',
45+
name: matchTreeArg('methodName')
46+
}
47+
}
48+
};

stack/eslint-plugin/src/index.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33

44
import { TSESLint } from '@typescript-eslint/experimental-utils';
55

6-
import { noNullRule } from './no-null';
6+
import { hoistJestMock } from './hoist-jest-mock';
77
import { noNewNullRule } from './no-new-null';
8+
import { noNullRule } from './no-null';
89
import { noUntypedUnderscoreRule } from './no-untyped-underscore';
910

1011
interface IPlugin {
@@ -13,9 +14,16 @@ interface IPlugin {
1314

1415
const plugin: IPlugin = {
1516
rules: {
16-
// NOTE: The actual ESLint rule name will be "@rushstack/no-null".
17-
'no-null': noNullRule,
17+
// Full name: "@rushstack/hoist-jest-mock"
18+
'hoist-jest-mock': hoistJestMock,
19+
20+
// Full name: "@rushstack/no-new-null"
1821
'no-new-null': noNewNullRule,
22+
23+
// Full name: "@rushstack/no-null"
24+
'no-null': noNullRule,
25+
26+
// Full name: "@rushstack/no-untyped-underscore"
1927
'no-untyped-underscore': noUntypedUnderscoreRule
2028
}
2129
};

stack/eslint-plugin/src/no-new-null.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
22
// See LICENSE in the project root for license information.
3+
34
import { ESLintUtils } from '@typescript-eslint/experimental-utils';
45
import { noNewNullRule } from './no-new-null';
56

0 commit comments

Comments
 (0)