diff --git a/.eslintrc.js b/.eslintrc.js index 126d7b57a..f51af6acc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -3,13 +3,20 @@ const { globals } = require('./').environments.globals; module.exports = { + parser: '@typescript-eslint/parser', extends: [ - 'eslint:recommended', 'plugin:eslint-plugin/recommended', 'plugin:node/recommended', + 'plugin:@typescript-eslint/eslint-recommended', 'prettier', ], - plugins: ['eslint-plugin', 'node', 'prettier', 'import'], + plugins: [ + 'eslint-plugin', + 'node', + 'prettier', + 'import', + '@typescript-eslint', + ], parserOptions: { ecmaVersion: 2018, }, @@ -35,7 +42,7 @@ module.exports = { }, overrides: [ { - files: ['*.test.js'], + files: ['*.test.js', '*.test.ts'], globals, }, { diff --git a/.gitignore b/.gitignore index f41745234..92bf74dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ coverage/ lib/ +*.log diff --git a/.travis.yml b/.travis.yml index 09246bfd0..e7fe39b27 100644 --- a/.travis.yml +++ b/.travis.yml @@ -13,6 +13,7 @@ script: - yarn commitlint --from="$TRAVIS_BRANCH" --to="$TRAVIS_COMMIT" - yarn commitlint --from=$TRAVIS_COMMIT - yarn prettylint +- yarn typecheck - yarn test --coverage --maxWorkers 2 after_script: greenkeeper-lockfile-upload jobs: diff --git a/babel.config.js b/babel.config.js index 87920c859..ba2c691af 100644 --- a/babel.config.js +++ b/babel.config.js @@ -1,5 +1,8 @@ 'use strict'; module.exports = { - presets: [['@babel/preset-env', { targets: { node: 6 } }]], + presets: [ + '@babel/preset-typescript', + ['@babel/preset-env', { targets: { node: 6 } }], + ], }; diff --git a/package.json b/package.json index 4a1b1b9da..e2d48ee7c 100644 --- a/package.json +++ b/package.json @@ -27,19 +27,30 @@ }, "scripts": { "prepare": "yarn build && yarn postbuild", - "lint": "eslint . --ignore-pattern '!.eslintrc.js'", + "lint": "eslint . --ignore-pattern '!.eslintrc.js' --ext js,ts", "prettylint": "prettylint docs/**/*.md README.md package.json", "prepublishOnly": "yarn build", + "pretest": "yarn build", "test": "jest", - "build": "babel src --out-dir lib", - "postbuild": "rimraf lib/**/__tests__/**" + "build": "babel --extensions .js,.ts src --out-dir lib", + "postbuild": "rimraf lib/**/__tests__/**", + "typecheck": "tsc -p ." + }, + "dependencies": { + "@typescript-eslint/experimental-utils": "^1.13.0" }, "devDependencies": { "@babel/cli": "^7.4.4", "@babel/core": "^7.4.4", "@babel/preset-env": "^7.4.4", + "@babel/preset-typescript": "^7.3.3", "@commitlint/cli": "^6.0.0", "@commitlint/config-conventional": "^6.0.0", + "@types/eslint": "^4.16.6", + "@types/jest": "^24.0.15", + "@types/node": "^12.6.6", + "@typescript-eslint/eslint-plugin": "^1.13.0", + "@typescript-eslint/parser": "^1.13.0", "babel-eslint": "^10.0.2", "babel-jest": "^24.8.0", "eslint": "^5.1.0", @@ -54,7 +65,8 @@ "lint-staged": "^8.0.4", "prettier": "^1.10.2", "prettylint": "^1.0.0", - "rimraf": "^2.6.3" + "rimraf": "^2.6.3", + "typescript": "^3.5.3" }, "prettier": { "proseWrap": "always", @@ -62,7 +74,7 @@ "trailingComma": "all" }, "lint-staged": { - "*.js": [ + "*.{js,ts}": [ "eslint --fix", "git add" ], diff --git a/src/index.js b/src/index.js index 22c2b0c61..3c0fb75c6 100644 --- a/src/index.js +++ b/src/index.js @@ -13,8 +13,16 @@ function importDefault(moduleName) { } const rules = readdirSync(join(__dirname, 'rules')) - .filter(rule => rule !== '__tests__' && rule !== 'util.js') - .map(rule => basename(rule, '.js')) + .filter( + rule => + rule !== '__tests__' && + rule !== 'util.js' && + rule !== 'tsUtils.ts' && + rule !== 'tsUtils.js', + ) + .map(rule => + rule.endsWith('js') ? basename(rule, '.js') : basename(rule, '.ts'), + ) .reduce( (acc, curr) => Object.assign(acc, { [curr]: importDefault(`./rules/${curr}`) }), diff --git a/src/rules/__tests__/consistent-test-it.test.js b/src/rules/__tests__/consistent-test-it.test.ts similarity index 99% rename from src/rules/__tests__/consistent-test-it.test.js rename to src/rules/__tests__/consistent-test-it.test.ts index ca842d97a..50eeecfbb 100644 --- a/src/rules/__tests__/consistent-test-it.test.js +++ b/src/rules/__tests__/consistent-test-it.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../consistent-test-it'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 6, }, diff --git a/src/rules/__tests__/expect-expect.test.js b/src/rules/__tests__/expect-expect.test.ts similarity index 83% rename from src/rules/__tests__/expect-expect.test.js rename to src/rules/__tests__/expect-expect.test.ts index eec3be3cc..c748795fb 100644 --- a/src/rules/__tests__/expect-expect.test.js +++ b/src/rules/__tests__/expect-expect.test.ts @@ -1,7 +1,10 @@ -import { RuleTester } from 'eslint'; +import { + AST_NODE_TYPES, + TSESLint, +} from '@typescript-eslint/experimental-utils'; import rule from '../expect-expect'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 6, }, @@ -37,7 +40,7 @@ ruleTester.run('expect-expect', rule, { errors: [ { messageId: 'noAssertions', - type: 'CallExpression', + type: AST_NODE_TYPES.CallExpression, }, ], }, @@ -46,7 +49,7 @@ ruleTester.run('expect-expect', rule, { errors: [ { messageId: 'noAssertions', - type: 'CallExpression', + type: AST_NODE_TYPES.CallExpression, }, ], }, @@ -55,7 +58,7 @@ ruleTester.run('expect-expect', rule, { errors: [ { messageId: 'noAssertions', - type: 'CallExpression', + type: AST_NODE_TYPES.CallExpression, }, ], }, @@ -65,7 +68,7 @@ ruleTester.run('expect-expect', rule, { errors: [ { messageId: 'noAssertions', - type: 'CallExpression', + type: AST_NODE_TYPES.CallExpression, }, ], }, @@ -75,7 +78,7 @@ ruleTester.run('expect-expect', rule, { errors: [ { messageId: 'noAssertions', - type: 'CallExpression', + type: AST_NODE_TYPES.CallExpression, }, ], }, diff --git a/src/rules/__tests__/lowercase-name.test.js b/src/rules/__tests__/lowercase-name.test.ts similarity index 79% rename from src/rules/__tests__/lowercase-name.test.js rename to src/rules/__tests__/lowercase-name.test.ts index 4a4ac9dc2..68d3d8eb9 100644 --- a/src/rules/__tests__/lowercase-name.test.js +++ b/src/rules/__tests__/lowercase-name.test.ts @@ -1,7 +1,8 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../lowercase-name'; +import { DescribeAlias, TestCaseName } from '../tsUtils'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 6, }, @@ -54,7 +55,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'it' }, + data: { method: TestCaseName.it }, column: 1, line: 1, }, @@ -66,7 +67,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'it' }, + data: { method: TestCaseName.it }, column: 1, line: 1, }, @@ -78,7 +79,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'it' }, + data: { method: TestCaseName.it }, column: 1, line: 1, }, @@ -90,7 +91,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'test' }, + data: { method: TestCaseName.test }, column: 1, line: 1, }, @@ -102,7 +103,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'test' }, + data: { method: TestCaseName.test }, column: 1, line: 1, }, @@ -114,7 +115,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'test' }, + data: { method: TestCaseName.test }, column: 1, line: 1, }, @@ -126,7 +127,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'describe' }, + data: { method: DescribeAlias.describe }, column: 1, line: 1, }, @@ -138,7 +139,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'describe' }, + data: { method: DescribeAlias.describe }, column: 1, line: 1, }, @@ -150,7 +151,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'describe' }, + data: { method: DescribeAlias.describe }, column: 1, line: 1, }, @@ -162,7 +163,7 @@ ruleTester.run('lowercase-name', rule, { errors: [ { messageId: 'unexpectedLowercase', - data: { method: 'describe' }, + data: { method: DescribeAlias.describe }, column: 1, line: 1, }, @@ -175,15 +176,15 @@ ruleTester.run('lowercase-name with ignore=describe', rule, { valid: [ { code: "describe('Foo', function () {})", - options: [{ ignore: ['describe'] }], + options: [{ ignore: [DescribeAlias.describe] }], }, { code: 'describe("Foo", function () {})', - options: [{ ignore: ['describe'] }], + options: [{ ignore: [DescribeAlias.describe] }], }, { code: 'describe(`Foo`, function () {})', - options: [{ ignore: ['describe'] }], + options: [{ ignore: [DescribeAlias.describe] }], }, ], invalid: [], @@ -193,15 +194,15 @@ ruleTester.run('lowercase-name with ignore=test', rule, { valid: [ { code: "test('Foo', function () {})", - options: [{ ignore: ['test'] }], + options: [{ ignore: [TestCaseName.test] }], }, { code: 'test("Foo", function () {})', - options: [{ ignore: ['test'] }], + options: [{ ignore: [TestCaseName.test] }], }, { code: 'test(`Foo`, function () {})', - options: [{ ignore: ['test'] }], + options: [{ ignore: [TestCaseName.test] }], }, ], invalid: [], @@ -211,15 +212,15 @@ ruleTester.run('lowercase-name with ignore=it', rule, { valid: [ { code: "it('Foo', function () {})", - options: [{ ignore: ['it'] }], + options: [{ ignore: [TestCaseName.it] }], }, { code: 'it("Foo", function () {})', - options: [{ ignore: ['it'] }], + options: [{ ignore: [TestCaseName.it] }], }, { code: 'it(`Foo`, function () {})', - options: [{ ignore: ['it'] }], + options: [{ ignore: [TestCaseName.it] }], }, ], invalid: [], diff --git a/src/rules/__tests__/no-commented-out-tests.test.js b/src/rules/__tests__/no-commented-out-tests.test.ts similarity index 95% rename from src/rules/__tests__/no-commented-out-tests.test.js rename to src/rules/__tests__/no-commented-out-tests.test.ts index a69024203..ebe8da83c 100644 --- a/src/rules/__tests__/no-commented-out-tests.test.js +++ b/src/rules/__tests__/no-commented-out-tests.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-commented-out-tests'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { sourceType: 'module', }, @@ -24,6 +24,7 @@ ruleTester.run('no-commented-out-tests', rule, { 'testSomething()', '// latest(dates)', '// TODO: unify with Git implementation from Shipit (?)', + '#!/usr/bin/env node', [ 'import { pending } from "actions"', '', @@ -141,8 +142,8 @@ ruleTester.run('no-commented-out-tests', rule, { { code: ` foo() - /* - describe("has title but no callback", () => {}) + /* + describe("has title but no callback", () => {}) */ bar()`, errors: [{ messageId: 'commentedTests', column: 7, line: 3 }], diff --git a/src/rules/__tests__/no-disabled-tests.test.js b/src/rules/__tests__/no-disabled-tests.test.ts similarity index 91% rename from src/rules/__tests__/no-disabled-tests.test.js rename to src/rules/__tests__/no-disabled-tests.test.ts index 0ed3929a6..49fea2bbc 100644 --- a/src/rules/__tests__/no-disabled-tests.test.js +++ b/src/rules/__tests__/no-disabled-tests.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-disabled-tests'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { sourceType: 'module', }, @@ -15,6 +15,7 @@ ruleTester.run('no-disabled-tests', rule, { 'it.only("foo", function () {})', 'test("foo", function () {})', 'test.only("foo", function () {})', + 'describe[`${"skip"}`]("foo", function () {})', 'var appliedSkip = describe.skip; appliedSkip.apply(describe)', 'var calledSkip = it.skip; calledSkip.call(it)', '({ f: function () {} }).f()', @@ -57,6 +58,10 @@ ruleTester.run('no-disabled-tests', rule, { code: 'describe.skip("foo", function () {})', errors: [{ messageId: 'skippedTestSuite', column: 1, line: 1 }], }, + { + code: 'describe[`skip`]("foo", function () {})', + errors: [{ messageId: 'skippedTestSuite', column: 1, line: 1 }], + }, { code: 'describe["skip"]("foo", function () {})', errors: [{ messageId: 'skippedTestSuite', column: 1, line: 1 }], diff --git a/src/rules/__tests__/no-duplicate-hooks.test.js b/src/rules/__tests__/no-duplicate-hooks.test.ts similarity index 98% rename from src/rules/__tests__/no-duplicate-hooks.test.js rename to src/rules/__tests__/no-duplicate-hooks.test.ts index 402ecff7b..ceac3d8b7 100644 --- a/src/rules/__tests__/no-duplicate-hooks.test.js +++ b/src/rules/__tests__/no-duplicate-hooks.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-duplicate-hooks'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 6, }, diff --git a/src/rules/__tests__/no-export.test.js b/src/rules/__tests__/no-export.test.ts similarity index 93% rename from src/rules/__tests__/no-export.test.js rename to src/rules/__tests__/no-export.test.ts index cc42dc71d..f39ca1cce 100644 --- a/src/rules/__tests__/no-export.test.js +++ b/src/rules/__tests__/no-export.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-export'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 2015, sourceType: 'module', diff --git a/src/rules/__tests__/no-focused-tests.test.js b/src/rules/__tests__/no-focused-tests.test.ts similarity index 93% rename from src/rules/__tests__/no-focused-tests.test.js rename to src/rules/__tests__/no-focused-tests.test.ts index 45d030de5..d97be69df 100644 --- a/src/rules/__tests__/no-focused-tests.test.js +++ b/src/rules/__tests__/no-focused-tests.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-focused-tests'; -const ruleTester = new RuleTester(); +const ruleTester = new TSESLint.RuleTester(); ruleTester.run('no-focused-tests', rule, { valid: [ diff --git a/src/rules/__tests__/no-hooks.test.js b/src/rules/__tests__/no-hooks.test.js deleted file mode 100644 index d9d9ef6b9..000000000 --- a/src/rules/__tests__/no-hooks.test.js +++ /dev/null @@ -1,51 +0,0 @@ -import { RuleTester } from 'eslint'; -import rule from '../no-hooks'; - -const ruleTester = new RuleTester({ - parserOptions: { - ecmaVersion: 6, - }, -}); - -ruleTester.run('no-hooks', rule, { - valid: [ - 'test("foo")', - 'describe("foo", () => { it("bar") })', - 'test("foo", () => { expect(subject.beforeEach()).toBe(true) })', - { - code: 'afterEach(() => {}); afterAll(() => {});', - options: [{ allow: ['afterEach', 'afterAll'] }], - }, - ], - invalid: [ - { - code: 'beforeAll(() => {})', - errors: [ - { messageId: 'unexpectedHook', data: { hookName: 'beforeAll' } }, - ], - }, - { - code: 'beforeEach(() => {})', - errors: [ - { messageId: 'unexpectedHook', data: { hookName: 'beforeEach' } }, - ], - }, - { - code: 'afterAll(() => {})', - errors: [{ messageId: 'unexpectedHook', data: { hookName: 'afterAll' } }], - }, - { - code: 'afterEach(() => {})', - errors: [ - { messageId: 'unexpectedHook', data: { hookName: 'afterEach' } }, - ], - }, - { - code: 'beforeEach(() => {}); afterEach(() => { jest.resetModules() });', - options: [{ allow: ['afterEach'] }], - errors: [ - { messageId: 'unexpectedHook', data: { hookName: 'beforeEach' } }, - ], - }, - ], -}); diff --git a/src/rules/__tests__/no-hooks.test.ts b/src/rules/__tests__/no-hooks.test.ts new file mode 100644 index 000000000..c12156197 --- /dev/null +++ b/src/rules/__tests__/no-hooks.test.ts @@ -0,0 +1,60 @@ +import { TSESLint } from '@typescript-eslint/experimental-utils'; +import rule from '../no-hooks'; +import { HookName } from '../tsUtils'; + +const ruleTester = new TSESLint.RuleTester({ + parserOptions: { + ecmaVersion: 6, + }, +}); + +ruleTester.run('no-hooks', rule, { + valid: [ + 'test("foo")', + 'describe("foo", () => { it("bar") })', + 'test("foo", () => { expect(subject.beforeEach()).toBe(true) })', + { + code: 'afterEach(() => {}); afterAll(() => {});', + options: [{ allow: [HookName.afterEach, HookName.afterAll] }], + }, + ], + invalid: [ + { + code: 'beforeAll(() => {})', + errors: [ + { messageId: 'unexpectedHook', data: { hookName: HookName.beforeAll } }, + ], + }, + { + code: 'beforeEach(() => {})', + errors: [ + { + messageId: 'unexpectedHook', + data: { hookName: HookName.beforeEach }, + }, + ], + }, + { + code: 'afterAll(() => {})', + errors: [ + { messageId: 'unexpectedHook', data: { hookName: HookName.afterAll } }, + ], + }, + { + code: 'afterEach(() => {})', + errors: [ + { messageId: 'unexpectedHook', data: { hookName: HookName.afterEach } }, + ], + }, + { + code: 'beforeEach(() => {}); afterEach(() => { jest.resetModules() });', + options: [{ allow: [HookName.afterEach] }], + errors: [ + { + messageId: 'unexpectedHook', + data: { hookName: HookName.beforeEach }, + }, + ], + }, + ], +}); diff --git a/src/rules/__tests__/no-if.js b/src/rules/__tests__/no-if.test.ts similarity index 98% rename from src/rules/__tests__/no-if.js rename to src/rules/__tests__/no-if.test.ts index 680ce8c55..f6e59fee4 100644 --- a/src/rules/__tests__/no-if.js +++ b/src/rules/__tests__/no-if.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-if'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 6, }, diff --git a/src/rules/__tests__/no-jasmine-globals.test.js b/src/rules/__tests__/no-jasmine-globals.test.ts similarity index 94% rename from src/rules/__tests__/no-jasmine-globals.test.js rename to src/rules/__tests__/no-jasmine-globals.test.ts index 2d73156ca..254c62d30 100644 --- a/src/rules/__tests__/no-jasmine-globals.test.js +++ b/src/rules/__tests__/no-jasmine-globals.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-jasmine-globals'; -const ruleTester = new RuleTester(); +const ruleTester = new TSESLint.RuleTester(); ruleTester.run('no-jasmine-globals', rule, { valid: [ @@ -53,6 +53,10 @@ ruleTester.run('no-jasmine-globals', rule, { errors: [{ messageId: 'illegalJasmine', column: 1, line: 1 }], output: 'jest.setTimeout(5000);', }, + { + code: 'jasmine.DEFAULT_TIMEOUT_INTERVAL = function() {}', + errors: [{ messageId: 'illegalJasmine', column: 1, line: 1 }], + }, { code: 'jasmine.addMatchers(matchers)', errors: [ diff --git a/src/rules/__tests__/no-jest-import.test.js b/src/rules/__tests__/no-jest-import.test.ts similarity index 90% rename from src/rules/__tests__/no-jest-import.test.js rename to src/rules/__tests__/no-jest-import.test.ts index 428063493..dda4c8bed 100644 --- a/src/rules/__tests__/no-jest-import.test.js +++ b/src/rules/__tests__/no-jest-import.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-jest-import'; -const ruleTester = new RuleTester(); +const ruleTester = new TSESLint.RuleTester(); ruleTester.run('no-jest-import', rule, { valid: [ diff --git a/src/rules/__tests__/no-mocks-import.test.js b/src/rules/__tests__/no-mocks-import.test.ts similarity index 92% rename from src/rules/__tests__/no-mocks-import.test.js rename to src/rules/__tests__/no-mocks-import.test.ts index 6e064cd6f..7d7d2ebb4 100644 --- a/src/rules/__tests__/no-mocks-import.test.js +++ b/src/rules/__tests__/no-mocks-import.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-mocks-import'; -const ruleTester = new RuleTester(); +const ruleTester = new TSESLint.RuleTester(); ruleTester.run('no-mocks-import', rule, { valid: [ diff --git a/src/rules/__tests__/no-test-callback.test.js b/src/rules/__tests__/no-test-callback.test.ts similarity index 95% rename from src/rules/__tests__/no-test-callback.test.js rename to src/rules/__tests__/no-test-callback.test.ts index 70573537c..129640dea 100644 --- a/src/rules/__tests__/no-test-callback.test.js +++ b/src/rules/__tests__/no-test-callback.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-test-callback'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 8, }, diff --git a/src/rules/__tests__/no-test-prefixes.test.js b/src/rules/__tests__/no-test-prefixes.test.ts similarity index 93% rename from src/rules/__tests__/no-test-prefixes.test.js rename to src/rules/__tests__/no-test-prefixes.test.ts index c99cf269b..cb1900dcd 100644 --- a/src/rules/__tests__/no-test-prefixes.test.js +++ b/src/rules/__tests__/no-test-prefixes.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-test-prefixes'; -const ruleTester = new RuleTester(); +const ruleTester = new TSESLint.RuleTester(); ruleTester.run('no-test-prefixes', rule, { valid: [ @@ -15,6 +15,7 @@ ruleTester.run('no-test-prefixes', rule, { 'it.skip("foo", function () {})', 'test.skip("foo", function () {})', 'foo()', + '[1,2,3].forEach()', ], invalid: [ { diff --git a/src/rules/__tests__/no-test-return-statement.test.js b/src/rules/__tests__/no-test-return-statement.test.ts similarity index 83% rename from src/rules/__tests__/no-test-return-statement.test.js rename to src/rules/__tests__/no-test-return-statement.test.ts index 451ef4391..fb593be04 100644 --- a/src/rules/__tests__/no-test-return-statement.test.js +++ b/src/rules/__tests__/no-test-return-statement.test.ts @@ -1,7 +1,9 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../no-test-return-statement'; -const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 2015 } }); +const ruleTester = new TSESLint.RuleTester({ + parserOptions: { ecmaVersion: 2015 }, +}); ruleTester.run('no-test-prefixes', rule, { valid: [ diff --git a/src/rules/__tests__/prefer-inline-snapshots.test.js b/src/rules/__tests__/prefer-inline-snapshots.test.ts similarity index 85% rename from src/rules/__tests__/prefer-inline-snapshots.test.js rename to src/rules/__tests__/prefer-inline-snapshots.test.ts index 5b4069720..e7f0ccadf 100644 --- a/src/rules/__tests__/prefer-inline-snapshots.test.js +++ b/src/rules/__tests__/prefer-inline-snapshots.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../prefer-inline-snapshots'; -const ruleTester = new RuleTester(); +const ruleTester = new TSESLint.RuleTester(); ruleTester.run('prefer-inline-snapshots', rule, { valid: [ diff --git a/src/rules/__tests__/prefer-spy-on.test.js b/src/rules/__tests__/prefer-spy-on.test.ts similarity index 57% rename from src/rules/__tests__/prefer-spy-on.test.js rename to src/rules/__tests__/prefer-spy-on.test.ts index 44692e335..2bf82f79e 100644 --- a/src/rules/__tests__/prefer-spy-on.test.js +++ b/src/rules/__tests__/prefer-spy-on.test.ts @@ -1,7 +1,10 @@ -import { RuleTester } from 'eslint'; +import { + AST_NODE_TYPES, + TSESLint, +} from '@typescript-eslint/experimental-utils'; import rule from '../prefer-spy-on'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 6, }, @@ -22,44 +25,84 @@ ruleTester.run('prefer-spy-on', rule, { invalid: [ { code: 'obj.a = jest.fn(); const test = 10;', - errors: [{ messageId: 'useJestSpyOn', type: 'AssignmentExpression' }], + errors: [ + { + messageId: 'useJestSpyOn', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], output: "jest.spyOn(obj, 'a'); const test = 10;", }, { code: "Date['now'] = jest['fn']()", - errors: [{ messageId: 'useJestSpyOn', type: 'AssignmentExpression' }], + errors: [ + { + messageId: 'useJestSpyOn', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], output: "jest.spyOn(Date, 'now')", }, { code: 'window[`${name}`] = jest[`fn`]()', - errors: [{ messageId: 'useJestSpyOn', type: 'AssignmentExpression' }], + errors: [ + { + messageId: 'useJestSpyOn', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], output: 'jest.spyOn(window, `${name}`)', }, { code: "obj['prop' + 1] = jest['fn']()", - errors: [{ messageId: 'useJestSpyOn', type: 'AssignmentExpression' }], + errors: [ + { + messageId: 'useJestSpyOn', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], output: "jest.spyOn(obj, 'prop' + 1)", }, { code: 'obj.one.two = jest.fn(); const test = 10;', - errors: [{ messageId: 'useJestSpyOn', type: 'AssignmentExpression' }], + errors: [ + { + messageId: 'useJestSpyOn', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], output: "jest.spyOn(obj.one, 'two'); const test = 10;", }, { code: 'obj.a = jest.fn(() => 10)', - errors: [{ messageId: 'useJestSpyOn', type: 'AssignmentExpression' }], + errors: [ + { + messageId: 'useJestSpyOn', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], output: "jest.spyOn(obj, 'a').mockImplementation(() => 10)", }, { code: "obj.a.b = jest.fn(() => ({})).mockReturnValue('default').mockReturnValueOnce('first call'); test();", - errors: [{ messageId: 'useJestSpyOn', type: 'AssignmentExpression' }], + errors: [ + { + messageId: 'useJestSpyOn', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], output: "jest.spyOn(obj.a, 'b').mockImplementation(() => ({})).mockReturnValue('default').mockReturnValueOnce('first call'); test();", }, { code: 'window.fetch = jest.fn(() => ({})).one.two().three().four', - errors: [{ messageId: 'useJestSpyOn', type: 'AssignmentExpression' }], + errors: [ + { + messageId: 'useJestSpyOn', + type: AST_NODE_TYPES.AssignmentExpression, + }, + ], output: "jest.spyOn(window, 'fetch').mockImplementation(() => ({})).one.two().three().four", }, diff --git a/src/rules/__tests__/prefer-strict-equal.test.js b/src/rules/__tests__/prefer-strict-equal.test.ts similarity index 79% rename from src/rules/__tests__/prefer-strict-equal.test.js rename to src/rules/__tests__/prefer-strict-equal.test.ts index bd4624e28..4b6bb4b1e 100644 --- a/src/rules/__tests__/prefer-strict-equal.test.js +++ b/src/rules/__tests__/prefer-strict-equal.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../prefer-strict-equal'; -const ruleTester = new RuleTester(); +const ruleTester = new TSESLint.RuleTester(); ruleTester.run('prefer-strict-equal', rule, { valid: [ diff --git a/src/rules/__tests__/valid-describe.test.js b/src/rules/__tests__/valid-describe.test.ts similarity index 97% rename from src/rules/__tests__/valid-describe.test.js rename to src/rules/__tests__/valid-describe.test.ts index 211d1537b..adfbad592 100644 --- a/src/rules/__tests__/valid-describe.test.js +++ b/src/rules/__tests__/valid-describe.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../valid-describe'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 8, }, diff --git a/src/rules/consistent-test-it.js b/src/rules/consistent-test-it.ts similarity index 78% rename from src/rules/consistent-test-it.js rename to src/rules/consistent-test-it.ts index 94a52f717..9b0294f36 100644 --- a/src/rules/consistent-test-it.js +++ b/src/rules/consistent-test-it.ts @@ -1,9 +1,13 @@ -import { getDocsUrl, getNodeName, isDescribe, isTestCase } from './util'; +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; +import { createRule, getNodeName, isDescribe, isTestCase } from './tsUtils'; -export default { +export default createRule({ + name: __filename, meta: { docs: { - url: getDocsUrl(__filename), + category: 'Best Practices', + description: 'Have control over `test` and `it` usages', + recommended: false, }, fixable: 'code', messages: { @@ -26,7 +30,14 @@ export default { additionalProperties: false, }, ], + type: 'suggestion', }, + defaultOptions: [ + { fn: 'test', withinDescribe: 'it' } as { + fn?: 'it' | 'test'; + withinDescribe?: 'it' | 'test'; + }, + ], create(context) { const configObj = context.options[0] || {}; const testKeyword = configObj.fn || 'test'; @@ -39,6 +50,10 @@ export default { CallExpression(node) { const nodeName = getNodeName(node.callee); + if (!nodeName) { + return; + } + if (isDescribe(node)) { describeNestingLevel++; } @@ -56,7 +71,7 @@ export default { data: { testKeyword, oppositeTestKeyword }, fix(fixer) { const nodeToReplace = - node.callee.type === 'MemberExpression' + node.callee.type === AST_NODE_TYPES.MemberExpression ? node.callee.object : node.callee; @@ -81,7 +96,7 @@ export default { data: { testKeywordWithinDescribe, oppositeTestKeyword }, fix(fixer) { const nodeToReplace = - node.callee.type === 'MemberExpression' + node.callee.type === AST_NODE_TYPES.MemberExpression ? node.callee.object : node.callee; @@ -101,9 +116,9 @@ export default { }, }; }, -}; +}); -function getPreferredNodeName(nodeName, preferredTestKeyword) { +function getPreferredNodeName(nodeName: string, preferredTestKeyword: string) { switch (nodeName) { case 'fit': return 'test.only'; @@ -114,7 +129,7 @@ function getPreferredNodeName(nodeName, preferredTestKeyword) { } } -function getOppositeTestKeyword(test) { +function getOppositeTestKeyword(test: string) { if (test === 'test') { return 'it'; } diff --git a/src/rules/expect-expect.js b/src/rules/expect-expect.ts similarity index 58% rename from src/rules/expect-expect.js rename to src/rules/expect-expect.ts index b35bf7c22..36468000f 100644 --- a/src/rules/expect-expect.js +++ b/src/rules/expect-expect.ts @@ -3,12 +3,19 @@ * MIT license, Remco Haszing. */ -import { getDocsUrl, getNodeName } from './util'; +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { createRule, getNodeName } from './tsUtils'; -export default { +export default createRule({ + name: __filename, meta: { docs: { - url: getDocsUrl(__filename), + category: 'Best Practices', + description: 'Enforce assertion to be made in a test body', + recommended: false, }, messages: { noAssertions: 'Test has no assertions', @@ -25,24 +32,25 @@ export default { additionalProperties: false, }, ], + type: 'suggestion', }, - create(context) { - const unchecked = []; - const assertFunctionNames = new Set( - context.options[0] && context.options[0].assertFunctionNames - ? context.options[0].assertFunctionNames - : ['expect'], - ); + defaultOptions: [{ assertFunctionNames: ['expect'] }], + create(context, [{ assertFunctionNames }]) { + const unchecked: TSESTree.CallExpression[] = []; return { CallExpression(node) { const name = getNodeName(node.callee); if (name === 'it' || name === 'test') { unchecked.push(node); - } else if (assertFunctionNames.has(name)) { + } else if (name && assertFunctionNames.includes(name)) { // Return early in case of nested `it` statements. for (const ancestor of context.getAncestors()) { - const index = unchecked.indexOf(ancestor); + const index = + ancestor.type === AST_NODE_TYPES.CallExpression + ? unchecked.indexOf(ancestor) + : -1; + if (index !== -1) { unchecked.splice(index, 1); break; @@ -57,4 +65,4 @@ export default { }, }; }, -}; +}); diff --git a/src/rules/lowercase-name.js b/src/rules/lowercase-name.js deleted file mode 100644 index 0ff7a5c54..000000000 --- a/src/rules/lowercase-name.js +++ /dev/null @@ -1,113 +0,0 @@ -import { getDocsUrl } from './util'; - -const isItTestOrDescribeFunction = node => { - return ( - node.type === 'CallExpression' && - node.callee && - (node.callee.name === 'it' || - node.callee.name === 'test' || - node.callee.name === 'describe') - ); -}; - -const isItDescription = node => { - return ( - node.arguments && - node.arguments[0] && - (node.arguments[0].type === 'Literal' || - node.arguments[0].type === 'TemplateLiteral') - ); -}; - -const testDescription = node => { - const [firstArgument] = node.arguments; - const { type } = firstArgument; - - if (type === 'Literal') { - return firstArgument.value; - } - - // `isItDescription` guarantees this is `type === 'TemplateLiteral'` - return firstArgument.quasis[0].value.raw; -}; - -const descriptionBeginsWithLowerCase = node => { - if (isItTestOrDescribeFunction(node) && isItDescription(node)) { - const description = testDescription(node); - if (!description[0]) { - return false; - } - - if (description[0] !== description[0].toLowerCase()) { - return node.callee.name; - } - } - return false; -}; - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - unexpectedLowercase: '`{{ method }}`s should begin with lowercase', - }, - schema: [ - { - type: 'object', - properties: { - ignore: { - type: 'array', - items: { - enum: ['describe', 'test', 'it'], - }, - additionalItems: false, - }, - }, - additionalProperties: false, - }, - ], - fixable: 'code', - }, - create(context) { - const ignore = (context.options[0] && context.options[0].ignore) || []; - const ignoredFunctionNames = ignore.reduce((accumulator, value) => { - accumulator[value] = true; - return accumulator; - }, Object.create(null)); - - const isIgnoredFunctionName = node => - ignoredFunctionNames[node.callee.name]; - - return { - CallExpression(node) { - const erroneousMethod = descriptionBeginsWithLowerCase(node); - - if (erroneousMethod && !isIgnoredFunctionName(node)) { - context.report({ - messageId: 'unexpectedLowercase', - data: { method: erroneousMethod }, - node, - fix(fixer) { - const [firstArg] = node.arguments; - const description = testDescription(node); - - const rangeIgnoringQuotes = [ - firstArg.range[0] + 1, - firstArg.range[1] - 1, - ]; - const newDescription = - description.substring(0, 1).toLowerCase() + - description.substring(1); - - return [ - fixer.replaceTextRange(rangeIgnoringQuotes, newDescription), - ]; - }, - }); - } - }, - }; - }, -}; diff --git a/src/rules/lowercase-name.ts b/src/rules/lowercase-name.ts new file mode 100644 index 000000000..718360aaa --- /dev/null +++ b/src/rules/lowercase-name.ts @@ -0,0 +1,151 @@ +import { + AST_NODE_TYPES, + TSESLint, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { + DescribeAlias, + JestFunctionCallExpressionWithIdentifierCallee, + JestFunctionName, + TestCaseName, + createRule, + isDescribe, + isTestCase, +} from './tsUtils'; + +type ArgumentLiteral = TSESTree.Literal | TSESTree.TemplateLiteral; + +interface FirstArgumentStringCallExpression extends TSESTree.CallExpression { + arguments: [ArgumentLiteral]; +} + +type CallExpressionWithCorrectCalleeAndArguments = JestFunctionCallExpressionWithIdentifierCallee< + TestCaseName.it | TestCaseName.test | DescribeAlias.describe +> & + FirstArgumentStringCallExpression; + +const hasStringAsFirstArgument = ( + node: TSESTree.CallExpression, +): node is FirstArgumentStringCallExpression => + node.arguments && + node.arguments[0] && + (node.arguments[0].type === AST_NODE_TYPES.Literal || + node.arguments[0].type === AST_NODE_TYPES.TemplateLiteral); + +const isJestFunctionWithLiteralArg = ( + node: TSESTree.CallExpression, +): node is CallExpressionWithCorrectCalleeAndArguments => + (isTestCase(node) || isDescribe(node)) && + node.callee.type === AST_NODE_TYPES.Identifier && + hasStringAsFirstArgument(node); + +const testDescription = (argument: ArgumentLiteral): string | null => { + if (argument.type === AST_NODE_TYPES.Literal) { + const { value } = argument; + + if (typeof value === 'string') { + return value; + } + return null; + } + + return argument.quasis[0].value.raw; +}; + +const jestFunctionName = ( + node: CallExpressionWithCorrectCalleeAndArguments, +) => { + const description = testDescription(node.arguments[0]); + if (description === null) { + return null; + } + + const firstCharacter = description.charAt(0); + + if (!firstCharacter) { + return null; + } + + if (firstCharacter !== firstCharacter.toLowerCase()) { + return node.callee.name; + } + + return null; +}; + +export default createRule({ + name: __filename, + meta: { + type: 'suggestion', + docs: { + description: + 'Enforce `it`, `test` and `describe` to have descriptions that begin with a lowercase letter. This provides more readable test failures.', + category: 'Best Practices', + recommended: false, + }, + fixable: 'code', + messages: { + unexpectedLowercase: '`{{ method }}`s should begin with lowercase', + }, + schema: [ + { + type: 'object', + properties: { + ignore: { + type: 'array', + items: { enum: ['describe', 'test', 'it'] }, + additionalItems: false, + }, + }, + additionalProperties: false, + }, + ], + } as const, + defaultOptions: [{ ignore: [] } as { ignore: readonly JestFunctionName[] }], + create(context, [{ ignore }]) { + const ignoredFunctionNames = ignore.reduce< + Record + >((accumulator, value) => { + accumulator[value] = true; + return accumulator; + }, Object.create(null)); + + const isIgnoredFunctionName = ( + node: CallExpressionWithCorrectCalleeAndArguments, + ) => ignoredFunctionNames[node.callee.name]; + + return { + CallExpression(node) { + if (!isJestFunctionWithLiteralArg(node)) { + return; + } + const erroneousMethod = jestFunctionName(node); + + if (erroneousMethod && !isIgnoredFunctionName(node)) { + context.report({ + messageId: 'unexpectedLowercase', + data: { method: erroneousMethod }, + node, + fix(fixer) { + const [firstArg] = node.arguments; + // guaranteed by jestFunctionName + const description = testDescription(firstArg)!; + + const rangeIgnoringQuotes: TSESLint.AST.Range = [ + firstArg.range[0] + 1, + firstArg.range[1] - 1, + ]; + const newDescription = + description.substring(0, 1).toLowerCase() + + description.substring(1); + + return [ + fixer.replaceTextRange(rangeIgnoringQuotes, newDescription), + ]; + }, + }); + } + }, + }; + }, +}); diff --git a/src/rules/no-commented-out-tests.js b/src/rules/no-commented-out-tests.js deleted file mode 100644 index f5b3132ee..000000000 --- a/src/rules/no-commented-out-tests.js +++ /dev/null @@ -1,38 +0,0 @@ -import { getDocsUrl } from './util'; - -function hasTests(node) { - return /^\s*(x|f)?(test|it|describe)(\.\w+|\[['"]\w+['"]\])?\s*\(/m.test( - node.value, - ); -} - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - commentedTests: 'Some tests seem to be commented', - }, - schema: [], - }, - create(context) { - const sourceCode = context.getSourceCode(); - - function checkNode(node) { - if (!hasTests(node)) { - return; - } - - context.report({ messageId: 'commentedTests', node }); - } - - return { - Program() { - const comments = sourceCode.getAllComments(); - - comments.filter(token => token.type !== 'Shebang').forEach(checkNode); - }, - }; - }, -}; diff --git a/src/rules/no-commented-out-tests.ts b/src/rules/no-commented-out-tests.ts new file mode 100644 index 000000000..69c3a34cc --- /dev/null +++ b/src/rules/no-commented-out-tests.ts @@ -0,0 +1,45 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { createRule } from './tsUtils'; + +function hasTests(node: TSESTree.Comment) { + return /^\s*(x|f)?(test|it|describe)(\.\w+|\[['"]\w+['"]\])?\s*\(/m.test( + node.value, + ); +} + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: + "This rule raises a warning about commented out tests. It's similar to no-disabled-tests rule.", + recommended: false, + }, + messages: { + commentedTests: 'Some tests seem to be commented', + }, + schema: [], + type: 'suggestion', + } as const, + defaultOptions: [], + create(context) { + const sourceCode = context.getSourceCode(); + + function checkNode(node: TSESTree.Comment) { + if (!hasTests(node)) { + return; + } + + context.report({ messageId: 'commentedTests', node }); + } + + return { + Program() { + const comments = sourceCode.getAllComments(); + + comments.forEach(checkNode); + }, + }; + }, +}); diff --git a/src/rules/no-disabled-tests.js b/src/rules/no-disabled-tests.ts similarity index 87% rename from src/rules/no-disabled-tests.js rename to src/rules/no-disabled-tests.ts index 38fa7ec80..1937d7fcc 100644 --- a/src/rules/no-disabled-tests.js +++ b/src/rules/no-disabled-tests.ts @@ -1,9 +1,13 @@ -import { getDocsUrl, getNodeName, scopeHasLocalReference } from './util'; +import { createRule } from './tsUtils'; +import { getNodeName, scopeHasLocalReference } from './tsUtils'; -export default { +export default createRule({ + name: __filename, meta: { docs: { - url: getDocsUrl(__filename), + category: 'Best Practices', + description: 'Disallow disabled tests', + recommended: false, }, messages: { missingFunction: 'Test is missing function argument', @@ -16,7 +20,9 @@ export default { disabledTest: 'Disabled test', }, schema: [], + type: 'suggestion', }, + defaultOptions: [], create(context) { let suiteDepth = 0; let testDepth = 0; @@ -72,4 +78,4 @@ export default { }, }; }, -}; +}); diff --git a/src/rules/no-duplicate-hooks.js b/src/rules/no-duplicate-hooks.ts similarity index 75% rename from src/rules/no-duplicate-hooks.js rename to src/rules/no-duplicate-hooks.ts index 9ec7b7383..dbfe24afd 100644 --- a/src/rules/no-duplicate-hooks.js +++ b/src/rules/no-duplicate-hooks.ts @@ -1,4 +1,4 @@ -import { getDocsUrl, isDescribe, isHook } from './util'; +import { createRule, isDescribe, isHook } from './tsUtils'; const newHookContext = () => ({ beforeAll: 0, @@ -7,15 +7,21 @@ const newHookContext = () => ({ afterEach: 0, }); -export default { +export default createRule({ + name: __filename, meta: { docs: { - url: getDocsUrl(__filename), + category: 'Best Practices', + description: 'Disallow duplicate setup and teardown hooks', + recommended: false, }, messages: { noDuplicateHook: 'Duplicate {{hook}} in describe block', }, + schema: [], + type: 'suggestion', }, + defaultOptions: [], create(context) { const hookContexts = [newHookContext()]; return { @@ -43,4 +49,4 @@ export default { }, }; }, -}; +}); diff --git a/src/rules/no-export.js b/src/rules/no-export.js deleted file mode 100644 index 763e8ed76..000000000 --- a/src/rules/no-export.js +++ /dev/null @@ -1,47 +0,0 @@ -import { getDocsUrl, isTestCase } from './util'; - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - unexpectedExport: `Do not export from a test file.`, - }, - schema: [], - }, - create(context) { - const exportNodes = []; - let hasTestCase = false; - - return { - 'Program:exit'() { - if (hasTestCase && exportNodes.length > 0) { - for (let node of exportNodes) { - context.report({ node, messageId: 'unexpectedExport' }); - } - } - }, - - CallExpression(node) { - if (isTestCase(node)) { - hasTestCase = true; - } - }, - 'ExportNamedDeclaration, ExportDefaultDeclaration'(node) { - exportNodes.push(node); - }, - 'AssignmentExpression > MemberExpression'(node) { - let { object, property } = node; - - if (object.type === 'MemberExpression') { - ({ object, property } = object); - } - - if (object.name === 'module' && /^exports?$/.test(property.name)) { - exportNodes.push(node); - } - }, - }; - }, -}; diff --git a/src/rules/no-export.ts b/src/rules/no-export.ts new file mode 100644 index 000000000..9925fd7c7 --- /dev/null +++ b/src/rules/no-export.ts @@ -0,0 +1,72 @@ +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { createRule, isTestCase } from './tsUtils'; + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: + 'Prevents exports from test files. If a file has at least 1 test in it, then this rule will prevent exports.', + recommended: false, + }, + messages: { + unexpectedExport: `Do not export from a test file.`, + }, + type: 'suggestion', + schema: [], + }, + defaultOptions: [], + create(context) { + const exportNodes: Array< + | TSESTree.ExportNamedDeclaration + | TSESTree.ExportDefaultDeclaration + | TSESTree.MemberExpression + > = []; + let hasTestCase = false; + + return { + 'Program:exit'() { + if (hasTestCase && exportNodes.length > 0) { + for (const node of exportNodes) { + context.report({ node, messageId: 'unexpectedExport' }); + } + } + }, + + CallExpression(node) { + if (isTestCase(node)) { + hasTestCase = true; + } + }, + 'ExportNamedDeclaration, ExportDefaultDeclaration'( + node: + | TSESTree.ExportNamedDeclaration + | TSESTree.ExportDefaultDeclaration, + ) { + exportNodes.push(node); + }, + 'AssignmentExpression > MemberExpression'( + node: TSESTree.MemberExpression, + ) { + let { object, property } = node; + + if (object.type === AST_NODE_TYPES.MemberExpression) { + ({ object, property } = object); + } + + if ( + 'name' in object && + object.name === 'module' && + property.type === AST_NODE_TYPES.Identifier && + /^exports?$/.test(property.name) + ) { + exportNodes.push(node); + } + }, + }; + }, +}); diff --git a/src/rules/no-focused-tests.js b/src/rules/no-focused-tests.js deleted file mode 100644 index 96d982a2c..000000000 --- a/src/rules/no-focused-tests.js +++ /dev/null @@ -1,63 +0,0 @@ -import { getDocsUrl } from './util'; - -const testFunctions = new Set(['describe', 'it', 'test']); - -const matchesTestFunction = object => object && testFunctions.has(object.name); - -const isCallToFocusedTestFunction = object => - object && - object.name[0] === 'f' && - testFunctions.has(object.name.substring(1)); - -const isPropertyNamedOnly = property => - property && (property.name === 'only' || property.value === 'only'); - -const isCallToTestOnlyFunction = callee => - matchesTestFunction(callee.object) && isPropertyNamedOnly(callee.property); - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - focusedTest: 'Unexpected focused test.', - }, - schema: [], - }, - create: context => ({ - CallExpression(node) { - const { callee } = node; - - if (callee.type === 'MemberExpression') { - if ( - callee.object.type === 'Identifier' && - isCallToFocusedTestFunction(callee.object) - ) { - context.report({ messageId: 'focusedTest', node: callee.object }); - return; - } - - if ( - callee.object.type === 'MemberExpression' && - isCallToTestOnlyFunction(callee.object) - ) { - context.report({ - messageId: 'focusedTest', - node: callee.object.property, - }); - return; - } - - if (isCallToTestOnlyFunction(callee)) { - context.report({ messageId: 'focusedTest', node: callee.property }); - return; - } - } - - if (callee.type === 'Identifier' && isCallToFocusedTestFunction(callee)) { - context.report({ messageId: 'focusedTest', node: callee }); - } - }, - }), -}; diff --git a/src/rules/no-focused-tests.ts b/src/rules/no-focused-tests.ts new file mode 100644 index 000000000..cb72c04a5 --- /dev/null +++ b/src/rules/no-focused-tests.ts @@ -0,0 +1,85 @@ +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { DescribeAlias, TestCaseName, createRule } from './tsUtils'; + +const testFunctions = new Set(['describe', 'it', 'test']); + +const matchesTestFunction = ( + object: TSESTree.LeftHandSideExpression | undefined, +) => + object && + 'name' in object && + (object.name in TestCaseName || object.name in DescribeAlias); + +const isCallToFocusedTestFunction = (object: TSESTree.Identifier | undefined) => + object && + object.name[0] === 'f' && + testFunctions.has(object.name.substring(1)); + +const isPropertyNamedOnly = ( + property: TSESTree.Expression | TSESTree.Identifier | undefined, +) => + property && + (('name' in property && property.name === 'only') || + ('value' in property && property.value === 'only')); + +const isCallToTestOnlyFunction = (callee: TSESTree.MemberExpression) => + matchesTestFunction(callee.object) && isPropertyNamedOnly(callee.property); + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: 'Disallow focused tests', + recommended: false, + }, + messages: { + focusedTest: 'Unexpected focused test.', + }, + fixable: 'code', + schema: [], + type: 'suggestion', + }, + defaultOptions: [], + create: context => ({ + CallExpression(node) { + const { callee } = node; + + if (callee.type === AST_NODE_TYPES.MemberExpression) { + if ( + callee.object.type === AST_NODE_TYPES.Identifier && + isCallToFocusedTestFunction(callee.object) + ) { + context.report({ messageId: 'focusedTest', node: callee.object }); + return; + } + + if ( + callee.object.type === AST_NODE_TYPES.MemberExpression && + isCallToTestOnlyFunction(callee.object) + ) { + context.report({ + messageId: 'focusedTest', + node: callee.object.property, + }); + return; + } + + if (isCallToTestOnlyFunction(callee)) { + context.report({ messageId: 'focusedTest', node: callee.property }); + return; + } + } + + if ( + callee.type === AST_NODE_TYPES.Identifier && + isCallToFocusedTestFunction(callee) + ) { + context.report({ messageId: 'focusedTest', node: callee }); + } + }, + }), +}); diff --git a/src/rules/no-hooks.js b/src/rules/no-hooks.js deleted file mode 100644 index 29dca2df1..000000000 --- a/src/rules/no-hooks.js +++ /dev/null @@ -1,46 +0,0 @@ -import { getDocsUrl, isHook } from './util'; - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - unexpectedHook: "Unexpected '{{ hookName }}' hook", - }, - }, - schema: [ - { - type: 'object', - properties: { - allow: { - type: 'array', - contains: ['beforeAll', 'beforeEach', 'afterAll', 'afterEach'], - }, - }, - additionalProperties: false, - }, - ], - create(context) { - const whitelistedHookNames = ( - context.options[0] || { allow: [] } - ).allow.reduce((hashMap, value) => { - hashMap[value] = true; - return hashMap; - }, Object.create(null)); - - const isWhitelisted = node => whitelistedHookNames[node.callee.name]; - - return { - CallExpression(node) { - if (isHook(node) && !isWhitelisted(node)) { - context.report({ - node, - messageId: 'unexpectedHook', - data: { hookName: node.callee.name }, - }); - } - }, - }; - }, -}; diff --git a/src/rules/no-hooks.ts b/src/rules/no-hooks.ts new file mode 100644 index 000000000..7f9e330b4 --- /dev/null +++ b/src/rules/no-hooks.ts @@ -0,0 +1,50 @@ +import { HookName, createRule, isHook } from './tsUtils'; + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: 'Disallow setup and teardown hooks', + recommended: false, + }, + messages: { + unexpectedHook: "Unexpected '{{ hookName }}' hook", + }, + schema: [ + { + type: 'object', + properties: { + allow: { + type: 'array', + contains: ['beforeAll', 'beforeEach', 'afterAll', 'afterEach'], + }, + }, + additionalProperties: false, + }, + ], + type: 'suggestion', + }, + defaultOptions: [{ allow: [] } as { allow: readonly HookName[] }], + create(context, [{ allow }]) { + const whitelistedHookNames = allow.reduce((hashMap, value) => { + hashMap[value] = true; + return hashMap; + }, Object.create(null)); + + const isWhitelisted = (node: { callee: { name: string } }) => + whitelistedHookNames[node.callee.name]; + + return { + CallExpression(node) { + if (isHook(node) && !isWhitelisted(node)) { + context.report({ + node, + messageId: 'unexpectedHook', + data: { hookName: node.callee.name }, + }); + } + }, + }; + }, +}); diff --git a/src/rules/no-if.js b/src/rules/no-if.ts similarity index 58% rename from src/rules/no-if.js rename to src/rules/no-if.ts index 320729711..f08daa375 100644 --- a/src/rules/no-if.js +++ b/src/rules/no-if.ts @@ -1,28 +1,44 @@ -import { getDocsUrl, isTestCase } from './util'; +import { TestCaseName, createRule, getNodeName, isTestCase } from './tsUtils'; +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; -const isTestArrowFunction = node => - node !== undefined && - node.type === 'ArrowFunctionExpression' && - isTestCase(node.parent); +const testCaseNames = new Set([ + ...Object.keys(TestCaseName), + 'it.only', + 'it.skip', + 'test.only', + 'test.skip', +]); -export default { +const isTestArrowFunction = (node: TSESTree.ArrowFunctionExpression) => + node.parent !== undefined && + node.parent.type === AST_NODE_TYPES.CallExpression && + testCaseNames.has(getNodeName(node.parent.callee)); + +export default createRule({ + name: __filename, meta: { docs: { description: 'Disallow conditional logic', category: 'Best Practices', recommended: false, - uri: getDocsUrl('jest/no-if'), }, messages: { noIf: 'Tests should not contain if statements.', noConditional: 'Tests should not contain conditional statements.', }, + schema: [], + type: 'suggestion', }, - + defaultOptions: [], create(context) { - const stack = []; + const stack: Array = []; - function validate(node) { + function validate( + node: TSESTree.ConditionalExpression | TSESTree.IfStatement, + ) { const lastElementInStack = stack[stack.length - 1]; if (stack.length === 0 || lastElementInStack === false) { @@ -30,7 +46,9 @@ export default { } const messageId = - node.type === 'ConditionalExpression' ? 'noConditional' : 'noIf'; + node.type === AST_NODE_TYPES.ConditionalExpression + ? 'noConditional' + : 'noIf'; context.report({ messageId, @@ -67,4 +85,4 @@ export default { }, }; }, -}; +}); diff --git a/src/rules/no-jasmine-globals.js b/src/rules/no-jasmine-globals.ts similarity index 68% rename from src/rules/no-jasmine-globals.js rename to src/rules/no-jasmine-globals.ts index e3a617dce..5cc6a191d 100644 --- a/src/rules/no-jasmine-globals.js +++ b/src/rules/no-jasmine-globals.ts @@ -1,11 +1,14 @@ -import { getDocsUrl, getNodeName, scopeHasLocalReference } from './util'; +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; +import { createRule, getNodeName, scopeHasLocalReference } from './tsUtils'; -export default { +export default createRule({ + name: __filename, meta: { docs: { - url: getDocsUrl(__filename), + category: 'Best Practices', + description: 'Disallow Jasmine globals', + recommended: 'error', }, - fixable: 'code', messages: { illegalGlobal: 'Illegal usage of global `{{ global }}`, prefer `{{ replacement }}`', @@ -17,16 +20,21 @@ export default { 'Illegal usage of `pending`, prefer explicitly skipping a test using `test.skip`', illegalJasmine: 'Illegal usage of jasmine global', }, + fixable: 'code', schema: [], + type: 'suggestion', }, + defaultOptions: [], create(context) { return { CallExpression(node) { - const calleeName = getNodeName(node.callee); + const { callee } = node; + const calleeName = getNodeName(callee); if (!calleeName) { return; } + if ( calleeName === 'spyOn' || calleeName === 'spyOnProperty' || @@ -57,7 +65,10 @@ export default { return; } - if (calleeName.startsWith('jasmine.')) { + if ( + callee.type === AST_NODE_TYPES.MemberExpression && + calleeName.startsWith('jasmine.') + ) { const functionName = calleeName.replace('jasmine.', ''); if ( @@ -68,9 +79,7 @@ export default { functionName === 'stringMatching' ) { context.report({ - fix(fixer) { - return [fixer.replaceText(node.callee.object, 'expect')]; - }, + fix: fixer => [fixer.replaceText(callee.object, 'expect')], node, messageId: 'illegalMethod', data: { @@ -87,7 +96,7 @@ export default { messageId: 'illegalMethod', data: { method: calleeName, - replacement: `expect.extend`, + replacement: 'expect.extend', }, }); return; @@ -109,22 +118,29 @@ export default { } }, MemberExpression(node) { - if (node.object.name === 'jasmine') { - if (node.parent.type === 'AssignmentExpression') { - if (node.property.name === 'DEFAULT_TIMEOUT_INTERVAL') { - context.report({ - fix(fixer) { - return [ + if ('name' in node.object && node.object.name === 'jasmine') { + const { parent, property } = node; + + if (parent && parent.type === AST_NODE_TYPES.AssignmentExpression) { + if ( + 'name' in property && + property.name === 'DEFAULT_TIMEOUT_INTERVAL' + ) { + const { right } = parent; + + if (right.type === AST_NODE_TYPES.Literal) { + context.report({ + fix: fixer => [ fixer.replaceText( - node.parent, - `jest.setTimeout(${node.parent.right.value})`, + parent, + `jest.setTimeout(${right.value})`, ), - ]; - }, - node, - messageId: 'illegalJasmine', - }); - return; + ], + node, + messageId: 'illegalJasmine', + }); + return; + } } context.report({ node, messageId: 'illegalJasmine' }); @@ -133,4 +149,4 @@ export default { }, }; }, -}; +}); diff --git a/src/rules/no-jest-import.js b/src/rules/no-jest-import.js deleted file mode 100644 index da983377b..000000000 --- a/src/rules/no-jest-import.js +++ /dev/null @@ -1,26 +0,0 @@ -import { getDocsUrl } from './util'; - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - unexpectedImport: `Jest is automatically in scope. Do not import "jest", as Jest doesn't export anything.`, - }, - schema: [], - }, - create(context) { - return { - 'ImportDeclaration[source.value="jest"]'(node) { - context.report({ node, messageId: 'unexpectedImport' }); - }, - 'CallExpression[callee.name="require"][arguments.0.value="jest"]'(node) { - context.report({ - loc: node.arguments[0].loc, - messageId: 'unexpectedImport', - }); - }, - }; - }, -}; diff --git a/src/rules/no-jest-import.ts b/src/rules/no-jest-import.ts new file mode 100644 index 000000000..20a50f7e5 --- /dev/null +++ b/src/rules/no-jest-import.ts @@ -0,0 +1,38 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { createRule } from './tsUtils'; + +export default createRule({ + name: __filename, + meta: { + type: 'problem', + docs: { + description: + "The `jest` object is automatically in scope within every test file. The methods in the `jest` object help create mocks and let you control Jest's overall behavior. It is therefore completely unnecessary to import in `jest`, as Jest doesn't export anything in the first place.", + category: 'Best Practices', + recommended: 'error', + }, + messages: { + unexpectedImport: `Jest is automatically in scope. Do not import "jest", as Jest doesn't export anything.`, + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + 'ImportDeclaration[source.value="jest"]'( + node: TSESTree.ImportDeclaration, + ) { + context.report({ node, messageId: 'unexpectedImport' }); + }, + 'CallExpression[callee.name="require"][arguments.0.value="jest"]'( + node: TSESTree.CallExpression, + ) { + context.report({ + loc: node.arguments[0].loc, + messageId: 'unexpectedImport', + node, + }); + }, + }; + }, +}); diff --git a/src/rules/no-mocks-import.js b/src/rules/no-mocks-import.js deleted file mode 100644 index 923ea4b26..000000000 --- a/src/rules/no-mocks-import.js +++ /dev/null @@ -1,40 +0,0 @@ -import { posix } from 'path'; - -import { getDocsUrl } from './util'; - -const mocksDirName = '__mocks__'; - -const isMockPath = path => path.split(posix.sep).includes(mocksDirName); - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - noManualImport: `Mocks should not be manually imported from a ${mocksDirName} directory. Instead use jest.mock and import from the original module path.`, - }, - schema: [], - }, - create(context) { - return { - ImportDeclaration(node) { - if (isMockPath(node.source.value)) { - context.report({ node, messageId: 'noManualImport' }); - } - }, - 'CallExpression[callee.name="require"]'(node) { - if ( - node.arguments.length && - node.arguments[0].value && - isMockPath(node.arguments[0].value) - ) { - context.report({ - loc: node.arguments[0].loc, - messageId: 'noManualImport', - }); - } - }, - }; - }, -}; diff --git a/src/rules/no-mocks-import.ts b/src/rules/no-mocks-import.ts new file mode 100644 index 000000000..03bef2b1e --- /dev/null +++ b/src/rules/no-mocks-import.ts @@ -0,0 +1,49 @@ +import { TSESTree } from '@typescript-eslint/experimental-utils'; +import { posix } from 'path'; +import { createRule, isLiteralNode } from './tsUtils'; + +const mocksDirName = '__mocks__'; + +const isMockPath = (path: string) => + path.split(posix.sep).includes(mocksDirName); + +const isMockImportLiteral = (expression?: TSESTree.Expression): boolean => + expression !== undefined && + isLiteralNode(expression) && + typeof expression.value === 'string' && + isMockPath(expression.value); + +export default createRule({ + name: __filename, + meta: { + type: 'problem', + docs: { + description: + 'When using `jest.mock`, your tests (just like the code being tested) should import from `./x`, not `./__mocks__/x`. Not following this rule can lead to confusion, because you will have multiple instances of the mocked module', + category: 'Best Practices', + recommended: 'error', + }, + messages: { + noManualImport: `Mocks should not be manually imported from a ${mocksDirName} directory. Instead use jest.mock and import from the original module path.`, + }, + schema: [], + }, + defaultOptions: [], + create(context) { + return { + ImportDeclaration(node: TSESTree.ImportDeclaration) { + if (isMockImportLiteral(node.source)) { + context.report({ node, messageId: 'noManualImport' }); + } + }, + 'CallExpression[callee.name="require"]'(node: TSESTree.CallExpression) { + if (isMockImportLiteral(node.arguments[0])) { + context.report({ + node: node.arguments[0], + messageId: 'noManualImport', + }); + } + }, + }; + }, +}); diff --git a/src/rules/no-test-callback.js b/src/rules/no-test-callback.ts similarity index 65% rename from src/rules/no-test-callback.js rename to src/rules/no-test-callback.ts index 3370c9ed7..250b61a66 100644 --- a/src/rules/no-test-callback.js +++ b/src/rules/no-test-callback.ts @@ -1,16 +1,21 @@ -import { getDocsUrl, isTestCase } from './util'; +import { createRule, isFunction, isTestCase } from './tsUtils'; -export default { +export default createRule({ + name: __filename, meta: { docs: { - url: getDocsUrl(__filename), + category: 'Best Practices', + description: 'Avoid using a callback in asynchronous tests', + recommended: false, }, messages: { illegalTestCallback: 'Illegal usage of test callback', }, fixable: 'code', schema: [], + type: 'suggestion', }, + defaultOptions: [], create(context) { return { CallExpression(node) { @@ -20,24 +25,44 @@ export default { const [, callback] = node.arguments; - if ( - !/^(Arrow)?FunctionExpression$/.test(callback.type) || - callback.params.length !== 1 - ) { + if (!isFunction(callback) || callback.params.length !== 1) { return; } const [argument] = callback.params; + context.report({ node: argument, messageId: 'illegalTestCallback', fix(fixer) { - const sourceCode = context.getSourceCode(); const { body } = callback; + + /* istanbul ignore if */ + if (!body) { + throw new Error( + `Unexpected null when attempting to fix ${context.getFilename()} - please file a github issue at https://github.com/jest-community/eslint-plugin-jest`, + ); + } + + const sourceCode = context.getSourceCode(); const firstBodyToken = sourceCode.getFirstToken(body); const lastBodyToken = sourceCode.getLastToken(body); const tokenBeforeArgument = sourceCode.getTokenBefore(argument); const tokenAfterArgument = sourceCode.getTokenAfter(argument); + + /* istanbul ignore if */ + if ( + !('name' in argument) || + !firstBodyToken || + !lastBodyToken || + !tokenBeforeArgument || + !tokenAfterArgument + ) { + throw new Error( + `Unexpected null when attempting to fix ${context.getFilename()} - please file a github issue at https://github.com/jest-community/eslint-plugin-jest`, + ); + } + const argumentInParens = tokenBeforeArgument.value === '(' && tokenAfterArgument.value === ')'; @@ -78,4 +103,4 @@ export default { }, }; }, -}; +}); diff --git a/src/rules/no-test-prefixes.js b/src/rules/no-test-prefixes.ts similarity index 66% rename from src/rules/no-test-prefixes.js rename to src/rules/no-test-prefixes.ts index acb0bd3db..7da5bfea6 100644 --- a/src/rules/no-test-prefixes.js +++ b/src/rules/no-test-prefixes.ts @@ -1,22 +1,27 @@ -import { getDocsUrl, getNodeName, isDescribe, isTestCase } from './util'; +import { createRule, getNodeName, isDescribe, isTestCase } from './tsUtils'; -export default { +export default createRule({ + name: __filename, meta: { docs: { - url: getDocsUrl(__filename), + category: 'Best Practices', + description: 'Use `.only` and `.skip` over `f` and `x`', + recommended: 'error', }, messages: { usePreferredName: 'Use "{{ preferredNodeName }}" instead', }, fixable: 'code', schema: [], + type: 'suggestion', }, + defaultOptions: [], create(context) { return { CallExpression(node) { const nodeName = getNodeName(node.callee); - if (!isDescribe(node) && !isTestCase(node)) return; + if (!nodeName || (!isDescribe(node) && !isTestCase(node))) return; const preferredNodeName = getPreferredNodeName(nodeName); @@ -33,9 +38,9 @@ export default { }, }; }, -}; +}); -function getPreferredNodeName(nodeName) { +function getPreferredNodeName(nodeName: string) { const firstChar = nodeName.charAt(0); if (firstChar === 'f') { diff --git a/src/rules/no-test-return-statement.js b/src/rules/no-test-return-statement.ts similarity index 51% rename from src/rules/no-test-return-statement.js rename to src/rules/no-test-return-statement.ts index 33201b454..708557114 100644 --- a/src/rules/no-test-return-statement.js +++ b/src/rules/no-test-return-statement.ts @@ -1,29 +1,38 @@ -import { getDocsUrl, isFunction, isTestCase } from './util'; +import { createRule, isFunction, isTestCase } from './tsUtils'; +import { TSESTree } from '@typescript-eslint/experimental-utils'; const RETURN_STATEMENT = 'ReturnStatement'; const BLOCK_STATEMENT = 'BlockStatement'; -const getBody = args => { +const getBody = (args: TSESTree.Expression[]) => { + const [, secondArg] = args; + if ( - args.length > 1 && - isFunction(args[1]) && - args[1].body.type === BLOCK_STATEMENT + secondArg && + isFunction(secondArg) && + secondArg.body && + secondArg.body.type === BLOCK_STATEMENT ) { - return args[1].body.body; + return secondArg.body.body; } return []; }; -export default { +export default createRule({ + name: __filename, meta: { docs: { - url: getDocsUrl(__filename), + category: 'Best Practices', + description: 'Disallow explicitly returning from tests', + recommended: false, }, messages: { noReturnValue: 'Jest tests should not return a value.', }, schema: [], + type: 'suggestion', }, + defaultOptions: [], create(context) { return { CallExpression(node) { @@ -36,4 +45,4 @@ export default { }, }; }, -}; +}); diff --git a/src/rules/prefer-inline-snapshots.js b/src/rules/prefer-inline-snapshots.js deleted file mode 100644 index 33112fc5d..000000000 --- a/src/rules/prefer-inline-snapshots.js +++ /dev/null @@ -1,49 +0,0 @@ -import { getDocsUrl } from './util'; - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - toMatch: 'Use toMatchInlineSnapshot() instead', - toMatchError: 'Use toThrowErrorMatchingInlineSnapshot() instead', - }, - fixable: 'code', - schema: [], - }, - create(context) { - return { - CallExpression(node) { - const propertyName = node.callee.property && node.callee.property.name; - if (propertyName === 'toMatchSnapshot') { - context.report({ - fix(fixer) { - return [ - fixer.replaceText( - node.callee.property, - 'toMatchInlineSnapshot', - ), - ]; - }, - messageId: 'toMatch', - node: node.callee.property, - }); - } else if (propertyName === 'toThrowErrorMatchingSnapshot') { - context.report({ - fix(fixer) { - return [ - fixer.replaceText( - node.callee.property, - 'toThrowErrorMatchingInlineSnapshot', - ), - ]; - }, - messageId: 'toMatchError', - node: node.callee.property, - }); - } - }, - }; - }, -}; diff --git a/src/rules/prefer-inline-snapshots.ts b/src/rules/prefer-inline-snapshots.ts new file mode 100644 index 000000000..2edc82285 --- /dev/null +++ b/src/rules/prefer-inline-snapshots.ts @@ -0,0 +1,60 @@ +import { AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; +import { createRule } from './tsUtils'; + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: 'Suggest using inline snapshots', + recommended: false, + }, + messages: { + toMatch: 'Use toMatchInlineSnapshot() instead', + toMatchError: 'Use toThrowErrorMatchingInlineSnapshot() instead', + }, + fixable: 'code', + schema: [], + type: 'suggestion', + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + const { callee } = node; + + if ( + callee.type !== AST_NODE_TYPES.MemberExpression || + callee.property.type !== AST_NODE_TYPES.Identifier + ) { + return; + } + + if (callee.property.name === 'toMatchSnapshot') { + context.report({ + fix(fixer) { + return [ + fixer.replaceText(callee.property, 'toMatchInlineSnapshot'), + ]; + }, + messageId: 'toMatch', + node: callee.property, + }); + } else if (callee.property.name === 'toThrowErrorMatchingSnapshot') { + context.report({ + fix(fixer) { + return [ + fixer.replaceText( + callee.property, + 'toThrowErrorMatchingInlineSnapshot', + ), + ]; + }, + messageId: 'toMatchError', + node: callee.property, + }); + } + }, + }; + }, +}); diff --git a/src/rules/prefer-spy-on.js b/src/rules/prefer-spy-on.js deleted file mode 100644 index 0b5c2f23b..000000000 --- a/src/rules/prefer-spy-on.js +++ /dev/null @@ -1,71 +0,0 @@ -import { getDocsUrl, getNodeName } from './util'; - -const getJestFnCall = node => { - if ( - (node.type !== 'CallExpression' && node.type !== 'MemberExpression') || - (node.callee && node.callee.type !== 'MemberExpression') - ) { - return null; - } - - const obj = node.callee ? node.callee.object : node.object; - - if (obj.type === 'Identifier') { - return node.type === 'CallExpression' && - getNodeName(node.callee) === 'jest.fn' - ? node - : null; - } - - return getJestFnCall(obj); -}; - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - useJestSpyOn: 'Use jest.spyOn() instead.', - }, - fixable: 'code', - schema: [], - }, - create(context) { - return { - AssignmentExpression(node) { - if (node.left.type !== 'MemberExpression') return; - - const jestFnCall = getJestFnCall(node.right); - - if (!jestFnCall) return; - - context.report({ - node, - messageId: 'useJestSpyOn', - fix(fixer) { - const leftPropQuote = - node.left.property.type === 'Identifier' ? "'" : ''; - const [arg] = jestFnCall.arguments; - const argSource = arg && context.getSourceCode().getText(arg); - const mockImplementation = argSource - ? `.mockImplementation(${argSource})` - : ''; - - return [ - fixer.insertTextBefore(node.left, `jest.spyOn(`), - fixer.replaceTextRange( - [node.left.object.range[1], node.left.property.range[0]], - `, ${leftPropQuote}`, - ), - fixer.replaceTextRange( - [node.left.property.range[1], jestFnCall.range[1]], - `${leftPropQuote})${mockImplementation}`, - ), - ]; - }, - }); - }, - }; - }, -}; diff --git a/src/rules/prefer-spy-on.ts b/src/rules/prefer-spy-on.ts new file mode 100644 index 000000000..5a6fff6a4 --- /dev/null +++ b/src/rules/prefer-spy-on.ts @@ -0,0 +1,100 @@ +import { + AST_NODE_TYPES, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { createRule, getNodeName } from './tsUtils'; + +const findNodeObject = ( + node: TSESTree.CallExpression | TSESTree.MemberExpression, +): TSESTree.LeftHandSideExpression | null => { + if ('object' in node) { + return node.object; + } + + if (node.callee.type === AST_NODE_TYPES.MemberExpression) { + return node.callee.object; + } + + return null; +}; + +const getJestFnCall = (node: TSESTree.Node): TSESTree.CallExpression | null => { + if ( + node.type !== AST_NODE_TYPES.CallExpression && + node.type !== AST_NODE_TYPES.MemberExpression + ) { + return null; + } + + const obj = findNodeObject(node); + + if (!obj) { + return null; + } + + if (obj.type === AST_NODE_TYPES.Identifier) { + return node.type === AST_NODE_TYPES.CallExpression && + getNodeName(node.callee) === 'jest.fn' + ? node + : null; + } + + return getJestFnCall(obj); +}; + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: 'Suggest using `jest.spyOn()`', + recommended: false, + }, + messages: { + useJestSpyOn: 'Use jest.spyOn() instead.', + }, + fixable: 'code', + schema: [], + type: 'suggestion', + }, + defaultOptions: [], + create(context) { + return { + AssignmentExpression(node) { + const { left, right } = node; + + if (left.type !== AST_NODE_TYPES.MemberExpression) return; + + const jestFnCall = getJestFnCall(right); + + if (!jestFnCall) return; + + context.report({ + node, + messageId: 'useJestSpyOn', + fix(fixer) { + const leftPropQuote = + left.property.type === AST_NODE_TYPES.Identifier ? "'" : ''; + const [arg] = jestFnCall.arguments; + const argSource = arg && context.getSourceCode().getText(arg); + const mockImplementation = argSource + ? `.mockImplementation(${argSource})` + : ''; + + return [ + fixer.insertTextBefore(left, `jest.spyOn(`), + fixer.replaceTextRange( + [left.object.range[1], left.property.range[0]], + `, ${leftPropQuote}`, + ), + fixer.replaceTextRange( + [left.property.range[1], jestFnCall.range[1]], + `${leftPropQuote})${mockImplementation}`, + ), + ]; + }, + }); + }, + }; + }, +}); diff --git a/src/rules/prefer-strict-equal.js b/src/rules/prefer-strict-equal.js deleted file mode 100644 index 6aaa23ea6..000000000 --- a/src/rules/prefer-strict-equal.js +++ /dev/null @@ -1,35 +0,0 @@ -import { expectCase, getDocsUrl, method } from './util'; - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - useToStrictEqual: 'Use toStrictEqual() instead', - }, - fixable: 'code', - schema: [], - }, - create(context) { - return { - CallExpression(node) { - if (!expectCase(node)) { - return; - } - - const propertyName = method(node) && method(node).name; - - if (propertyName === 'toEqual') { - context.report({ - fix(fixer) { - return [fixer.replaceText(method(node), 'toStrictEqual')]; - }, - messageId: 'useToStrictEqual', - node: method(node), - }); - } - }, - }; - }, -}; diff --git a/src/rules/prefer-strict-equal.ts b/src/rules/prefer-strict-equal.ts new file mode 100644 index 000000000..c5d115102 --- /dev/null +++ b/src/rules/prefer-strict-equal.ts @@ -0,0 +1,40 @@ +import { createRule, isExpectCallWithParent } from './tsUtils'; + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: 'Suggest using toStrictEqual()', + recommended: false, + }, + messages: { + useToStrictEqual: 'Use toStrictEqual() instead', + }, + fixable: 'code', + schema: [], + type: 'suggestion', + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + if (!isExpectCallWithParent(node)) { + return; + } + + const methodNode = node.parent.property; + + if (methodNode && methodNode.name === 'toEqual') { + context.report({ + fix(fixer) { + return [fixer.replaceText(methodNode, 'toStrictEqual')]; + }, + messageId: 'useToStrictEqual', + node: methodNode, + }); + } + }, + }; + }, +}); diff --git a/src/rules/tsUtils.ts b/src/rules/tsUtils.ts new file mode 100644 index 000000000..01ff96164 --- /dev/null +++ b/src/rules/tsUtils.ts @@ -0,0 +1,234 @@ +// TODO: rename to utils.ts when TS migration is complete +import { basename } from 'path'; +import { + AST_NODE_TYPES, + ESLintUtils, + TSESLint, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { version } from '../../package.json'; + +const REPO_URL = 'https://github.com/jest-community/eslint-plugin-jest'; + +export const createRule = ESLintUtils.RuleCreator(name => { + const ruleName = basename(name, '.ts'); + return `${REPO_URL}/blob/v${version}/docs/rules/${ruleName}.md`; +}); + +interface JestExpectIdentifier extends TSESTree.Identifier { + name: 'expect'; +} + +/** + * Checks if the given `node` is considered a {@link JestExpectIdentifier}. + * + * A `node` is considered to be as such if it is of type `Identifier`, + * and `name`d `"expect"`. + * + * @param {Node} node + * + * @return {node is JestExpectIdentifier} + */ +const isExpectIdentifier = ( + node: TSESTree.Node, +): node is JestExpectIdentifier => + node.type === AST_NODE_TYPES.Identifier && node.name === 'expect'; + +// represents "expect()" specifically +interface JestExpectCallExpression extends TSESTree.CallExpression { + callee: JestExpectIdentifier; +} + +// represents expect usage like "expect().toBe" & "expect().not.toBe" +interface JestExpectCallMemberExpression extends TSESTree.MemberExpression { + object: JestExpectCallMemberExpression | JestExpectCallExpression; + property: TSESTree.Identifier; +} + +// represents expect usage like "expect.anything" & "expect.hasAssertions" +interface JestExpectNamespaceMemberExpression + extends TSESTree.MemberExpression { + object: JestExpectIdentifier; + property: TSESTree.Identifier; +} + +/** + * Checks if the given `node` is a {@link JestExpectCallExpression}. + * + * @param {Node} node + * + * @return {node is JestExpectCallExpression} + */ +const isExpectCall = (node: TSESTree.Node): node is JestExpectCallExpression => + node.type === AST_NODE_TYPES.CallExpression && + isExpectIdentifier(node.callee); + +interface JestExpectCallWithParent extends JestExpectCallExpression { + parent: JestExpectCallMemberExpression; +} + +export const isExpectCallWithParent = ( + node: TSESTree.Node, +): node is JestExpectCallWithParent => + isExpectCall(node) && + node.parent !== undefined && + node.parent.type === AST_NODE_TYPES.MemberExpression && + node.parent.property.type === AST_NODE_TYPES.Identifier; + +export enum DescribeAlias { + 'describe' = 'describe', + 'fdescribe' = 'fdescribe', + 'xdescribe' = 'xdescribe', +} + +export enum TestCaseName { + 'fit' = 'fit', + 'it' = 'it', + 'test' = 'test', + 'xit' = 'xit', + 'xtest' = 'xtest', +} + +export enum HookName { + 'beforeAll' = 'beforeAll', + 'beforeEach' = 'beforeEach', + 'afterAll' = 'afterAll', + 'afterEach' = 'afterEach', +} + +export type JestFunctionName = DescribeAlias | TestCaseName | HookName; + +export interface JestFunctionIdentifier + extends TSESTree.Identifier { + name: FunctionName; +} + +export interface JestFunctionMemberExpression< + FunctionName extends JestFunctionName +> extends TSESTree.MemberExpression { + object: JestFunctionIdentifier; +} + +export interface JestFunctionCallExpressionWithMemberExpressionCallee< + FunctionName extends JestFunctionName +> extends TSESTree.CallExpression { + callee: JestFunctionMemberExpression; +} + +export interface JestFunctionCallExpressionWithIdentifierCallee< + FunctionName extends JestFunctionName +> extends TSESTree.CallExpression { + callee: JestFunctionIdentifier; +} + +export type JestFunctionCallExpression< + FunctionName extends JestFunctionName = JestFunctionName +> = + | JestFunctionCallExpressionWithMemberExpressionCallee + | JestFunctionCallExpressionWithIdentifierCallee; + +export const getNodeName = (node: TSESTree.Node): string | null => { + function joinNames(a?: string | null, b?: string | null): string | null { + return a && b ? `${a}.${b}` : null; + } + + switch (node.type) { + case AST_NODE_TYPES.Identifier: + return node.name; + case AST_NODE_TYPES.Literal: + return `${node.value}`; + case AST_NODE_TYPES.TemplateLiteral: + if (node.expressions.length === 0) return node.quasis[0].value.cooked; + break; + case AST_NODE_TYPES.MemberExpression: + return joinNames(getNodeName(node.object), getNodeName(node.property)); + } + + return null; +}; + +export type FunctionExpression = + | TSESTree.ArrowFunctionExpression + | TSESTree.FunctionExpression; + +export const isFunction = (node: TSESTree.Node): node is FunctionExpression => + node.type === AST_NODE_TYPES.FunctionExpression || + node.type === AST_NODE_TYPES.ArrowFunctionExpression; + +export const isHook = ( + node: TSESTree.CallExpression, +): node is JestFunctionCallExpressionWithIdentifierCallee => { + return ( + node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name in HookName + ); +}; + +export const isTestCase = ( + node: TSESTree.CallExpression, +): node is JestFunctionCallExpression => { + return ( + (node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name in TestCaseName) || + (node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.object.type === AST_NODE_TYPES.Identifier && + node.callee.object.name in TestCaseName) + ); +}; + +export const isDescribe = ( + node: TSESTree.CallExpression, +): node is JestFunctionCallExpression => { + return ( + (node.callee.type === AST_NODE_TYPES.Identifier && + node.callee.name in DescribeAlias) || + (node.callee.type === AST_NODE_TYPES.MemberExpression && + node.callee.object.type === AST_NODE_TYPES.Identifier && + node.callee.object.name in DescribeAlias) + ); +}; + +export const isLiteralNode = (node: { + type: AST_NODE_TYPES; +}): node is TSESTree.Literal => node.type === AST_NODE_TYPES.Literal; + +const collectReferences = (scope: TSESLint.Scope.Scope) => { + const locals = new Set(); + const unresolved = new Set(); + + let currentScope: TSESLint.Scope.Scope | null = scope; + + while (currentScope !== null) { + for (const ref of currentScope.variables) { + const isReferenceDefined = ref.defs.some(def => { + return def.type !== 'ImplicitGlobalVariable'; + }); + + if (isReferenceDefined) { + locals.add(ref.name); + } + } + + for (const ref of currentScope.through) { + unresolved.add(ref.identifier.name); + } + + currentScope = currentScope.upper; + } + + return { locals, unresolved }; +}; + +export const scopeHasLocalReference = ( + scope: TSESLint.Scope.Scope, + referenceName: string, +) => { + const references = collectReferences(scope); + return ( + // referenceName was found as a local variable or function declaration. + references.locals.has(referenceName) || + // referenceName was not found as an unresolved reference, + // meaning it is likely not an implicit global reference. + !references.unresolved.has(referenceName) + ); +}; diff --git a/src/rules/util.js b/src/rules/util.js index 29827a204..33bbce217 100644 --- a/src/rules/util.js +++ b/src/rules/util.js @@ -101,13 +101,6 @@ const describeAliases = new Set(['describe', 'fdescribe', 'xdescribe']); const testCaseNames = new Set(['fit', 'it', 'test', 'xit', 'xtest']); -const testHookNames = new Set([ - 'beforeAll', - 'beforeEach', - 'afterAll', - 'afterEach', -]); - export const getNodeName = node => { function joinNames(a, b) { return a && b ? `${a}.${b}` : null; @@ -116,11 +109,6 @@ export const getNodeName = node => { switch (node && node.type) { case 'Identifier': return node.name; - case 'Literal': - return node.value; - case 'TemplateLiteral': - if (node.expressions.length === 0) return node.quasis[0].value.cooked; - break; case 'MemberExpression': return joinNames(getNodeName(node.object), getNodeName(node.property)); } @@ -128,12 +116,6 @@ export const getNodeName = node => { return null; }; -export const isHook = node => - node && - node.type === 'CallExpression' && - node.callee.type === 'Identifier' && - testHookNames.has(node.callee.name); - export const isTestCase = node => node && node.type === 'CallExpression' && @@ -184,44 +166,6 @@ export const getDocsUrl = filename => { return `${REPO_URL}/blob/v${version}/docs/rules/${ruleName}.md`; }; -const collectReferences = scope => { - const locals = new Set(); - const unresolved = new Set(); - - let currentScope = scope; - - while (currentScope !== null) { - for (const ref of currentScope.variables) { - const isReferenceDefined = ref.defs.some(def => { - return def.type !== 'ImplicitGlobalVariable'; - }); - - if (isReferenceDefined) { - locals.add(ref.name); - } - } - - for (const ref of currentScope.through) { - unresolved.add(ref.identifier.name); - } - - currentScope = currentScope.upper; - } - - return { locals, unresolved }; -}; - -export const scopeHasLocalReference = (scope, referenceName) => { - const references = collectReferences(scope); - return ( - // referenceName was found as a local variable or function declaration. - references.locals.has(referenceName) || - // referenceName was not found as an unresolved reference, - // meaning it is likely not an implicit global reference. - !references.unresolved.has(referenceName) - ); -}; - export function composeFixers(node) { return (...fixers) => { return fixerApi => { diff --git a/src/rules/valid-describe.js b/src/rules/valid-describe.js deleted file mode 100644 index 0fa9c7cdd..000000000 --- a/src/rules/valid-describe.js +++ /dev/null @@ -1,103 +0,0 @@ -import { getDocsUrl, isDescribe, isFunction } from './util'; - -const isAsync = node => node.async; - -const isString = node => - (node.type === 'Literal' && typeof node.value === 'string') || - node.type === 'TemplateLiteral'; - -const hasParams = node => node.params.length > 0; - -const paramsLocation = params => { - const [first] = params; - const last = params[params.length - 1]; - return { - start: { - line: first.loc.start.line, - column: first.loc.start.column, - }, - end: { - line: last.loc.end.line, - column: last.loc.end.column, - }, - }; -}; - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - nameAndCallback: 'Describe requires name and callback arguments', - firstArgumentMustBeName: 'First argument must be name', - secondArgumentMustBeFunction: 'Second argument must be function', - noAsyncDescribeCallback: 'No async describe callback', - unexpectedDescribeArgument: 'Unexpected argument(s) in describe callback', - unexpectedReturnInDescribe: - 'Unexpected return statement in describe callback', - }, - schema: [], - }, - create(context) { - return { - CallExpression(node) { - if (isDescribe(node)) { - if (node.arguments.length === 0) { - return context.report({ - messageId: 'nameAndCallback', - loc: node.loc, - }); - } - - const [name] = node.arguments; - const [, callbackFunction] = node.arguments; - if (!isString(name)) { - context.report({ - messageId: 'firstArgumentMustBeName', - loc: paramsLocation(node.arguments), - }); - } - if (callbackFunction === undefined) { - context.report({ - messageId: 'nameAndCallback', - loc: paramsLocation(node.arguments), - }); - - return; - } - if (!isFunction(callbackFunction)) { - context.report({ - messageId: 'secondArgumentMustBeFunction', - loc: paramsLocation(node.arguments), - }); - - return; - } - if (isAsync(callbackFunction)) { - context.report({ - messageId: 'noAsyncDescribeCallback', - node: callbackFunction, - }); - } - if (hasParams(callbackFunction)) { - context.report({ - messageId: 'unexpectedDescribeArgument', - loc: paramsLocation(callbackFunction.params), - }); - } - if (callbackFunction.body.type === 'BlockStatement') { - callbackFunction.body.body.forEach(node => { - if (node.type === 'ReturnStatement') { - context.report({ - messageId: 'unexpectedReturnInDescribe', - node, - }); - } - }); - } - } - }, - }; - }, -}; diff --git a/src/rules/valid-describe.ts b/src/rules/valid-describe.ts new file mode 100644 index 000000000..b1f4c8b34 --- /dev/null +++ b/src/rules/valid-describe.ts @@ -0,0 +1,114 @@ +import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree'; +import { + FunctionExpression, + createRule, + isDescribe, + isFunction, +} from './tsUtils'; + +const isAsync = (node: FunctionExpression): boolean => node.async; + +const isString = (node: TSESTree.Node): boolean => + (node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string') || + node.type === AST_NODE_TYPES.TemplateLiteral; + +const hasParams = (node: FunctionExpression): boolean => node.params.length > 0; + +const paramsLocation = ( + params: TSESTree.Expression[] | TSESTree.Parameter[], +) => { + const [first] = params; + const last = params[params.length - 1]; + return { + start: first.loc.start, + end: last.loc.end, + }; +}; + +export default createRule({ + name: __filename, + meta: { + type: 'problem', + docs: { + description: + 'Using an improper `describe()` callback function can lead to unexpected test errors.', + category: 'Possible Errors', + recommended: 'warn', + }, + messages: { + nameAndCallback: 'Describe requires name and callback arguments', + firstArgumentMustBeName: 'First argument must be name', + secondArgumentMustBeFunction: 'Second argument must be function', + noAsyncDescribeCallback: 'No async describe callback', + unexpectedDescribeArgument: 'Unexpected argument(s) in describe callback', + unexpectedReturnInDescribe: + 'Unexpected return statement in describe callback', + }, + schema: [], + } as const, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + if (isDescribe(node)) { + if (node.arguments.length === 0) { + return context.report({ + messageId: 'nameAndCallback', + loc: node.loc, + }); + } + + const [name] = node.arguments; + const [, callbackFunction] = node.arguments; + if (!isString(name)) { + context.report({ + messageId: 'firstArgumentMustBeName', + loc: paramsLocation(node.arguments), + }); + } + if (!callbackFunction) { + context.report({ + messageId: 'nameAndCallback', + loc: paramsLocation(node.arguments), + }); + + return; + } + if (isFunction(callbackFunction)) { + if (isAsync(callbackFunction)) { + context.report({ + messageId: 'noAsyncDescribeCallback', + node: callbackFunction, + }); + } + if (hasParams(callbackFunction)) { + context.report({ + messageId: 'unexpectedDescribeArgument', + loc: paramsLocation(callbackFunction.params), + }); + } + if ( + callbackFunction.body && + callbackFunction.body.type === AST_NODE_TYPES.BlockStatement + ) { + callbackFunction.body.body.forEach(node => { + if (node.type === 'ReturnStatement') { + context.report({ + messageId: 'unexpectedReturnInDescribe', + node, + }); + } + }); + } + } else { + context.report({ + messageId: 'secondArgumentMustBeFunction', + loc: paramsLocation(node.arguments), + }); + return; + } + } + }, + }; + }, +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 000000000..f84026bfc --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "es5", + "module": "commonjs", + "noEmit": true, + "strict": true, + "moduleResolution": "node", + "esModuleInterop": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/yarn.lock b/yarn.lock index 9214f4cbf..209f0135e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -81,6 +81,18 @@ "@babel/traverse" "^7.4.4" "@babel/types" "^7.4.4" +"@babel/helper-create-class-features-plugin@^7.5.0": + version "7.5.0" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.5.0.tgz#02edb97f512d44ba23b3227f1bf2ed43454edac5" + integrity sha512-EAoMc3hE5vE5LNhMqDOwB1usHvmRjCDAnH8CD4PVkX9/Yr3W/tcz8xE8QvdZxfsFBDICwZnF2UTHIqslRpvxmA== + dependencies: + "@babel/helper-function-name" "^7.1.0" + "@babel/helper-member-expression-to-functions" "^7.0.0" + "@babel/helper-optimise-call-expression" "^7.0.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/helper-replace-supers" "^7.4.4" + "@babel/helper-split-export-declaration" "^7.4.4" + "@babel/helper-define-map@^7.4.4": version "7.4.4" resolved "https://registry.yarnpkg.com/@babel/helper-define-map/-/helper-define-map-7.4.4.tgz#6969d1f570b46bdc900d1eba8e5d59c48ba2c12a" @@ -320,6 +332,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" +"@babel/plugin-syntax-typescript@^7.2.0": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.3.3.tgz#a7cc3f66119a9f7ebe2de5383cce193473d65991" + integrity sha512-dGwbSMA1YhVS8+31CnPR7LB4pcbrzcV99wQzby4uAfrkZPYZlQ7ImwdpzLqi6Z6IL02b8IAL379CaMwo0x5Lag== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-transform-arrow-functions@^7.2.0": version "7.2.0" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.2.0.tgz#9aeafbe4d6ffc6563bf8f8372091628f00779550" @@ -557,6 +576,15 @@ dependencies: "@babel/helper-plugin-utils" "^7.0.0" +"@babel/plugin-transform-typescript@^7.3.2": + version "7.5.2" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.5.2.tgz#ea7da440d29b8ccdb1bd02e18f6cfdc7ce6c16f5" + integrity sha512-r4zJOMbKY5puETm8+cIpaa0RQZG/sSASW1u0pj8qYklcERgVIbxVbP2wyJA7zI1//h7lEagQmXi9IL9iI5rfsA== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.5.0" + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-syntax-typescript" "^7.2.0" + "@babel/plugin-transform-unicode-regex@^7.4.4": version "7.4.4" resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.4.4.tgz#ab4634bb4f14d36728bf5978322b35587787970f" @@ -622,6 +650,14 @@ js-levenshtein "^1.1.3" semver "^5.5.0" +"@babel/preset-typescript@^7.3.3": + version "7.3.3" + resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.3.3.tgz#88669911053fa16b2b276ea2ede2ca603b3f307a" + integrity sha512-mzMVuIP4lqtn4du2ynEfdO0+RYcslwrZiJHXu4MGaC1ctJiW2fyaeDrtjJGs7R/KebZ1sgowcIoWf4uRpEfKEg== + dependencies: + "@babel/helper-plugin-utils" "^7.0.0" + "@babel/plugin-transform-typescript" "^7.3.2" + "@babel/runtime@^7.0.0": version "7.5.4" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.5.4.tgz#cb7d1ad7c6d65676e66b47186577930465b5271b" @@ -1002,6 +1038,24 @@ dependencies: "@babel/types" "^7.3.0" +"@types/eslint-visitor-keys@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d" + integrity sha512-OCutwjDZ4aFS6PB1UZ988C4YgwlBHJd6wCeQqaLdmadZ/7e+w79+hbMUFC1QXDNCmdyoRfAFdm0RypzwR+Qpag== + +"@types/eslint@^4.16.6": + version "4.16.6" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-4.16.6.tgz#96d4ecddbea618ab0b55eaf0dffedf387129b06c" + integrity sha512-GL7tGJig55FeclpOytU7nCCqtR143jBoC7AUdH0DO9xBSIFiNNUFCY/S3KNWsHeQJuU3hjw/OC1+kRTFNXqUZQ== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*": + version "0.0.39" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.39.tgz#e177e699ee1b8c22d23174caaa7422644389509f" + integrity sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0": version "2.0.1" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.1.tgz#42995b446db9a48a11a07ec083499a860e9138ff" @@ -1022,6 +1076,28 @@ "@types/istanbul-lib-coverage" "*" "@types/istanbul-lib-report" "*" +"@types/jest-diff@*": + version "20.0.1" + resolved "https://registry.yarnpkg.com/@types/jest-diff/-/jest-diff-20.0.1.tgz#35cc15b9c4f30a18ef21852e255fdb02f6d59b89" + integrity sha512-yALhelO3i0hqZwhjtcr6dYyaLoCHbAMshwtj6cGxTvHZAKXHsYGdff6E8EPw3xLKY0ELUTQ69Q1rQiJENnccMA== + +"@types/jest@^24.0.15": + version "24.0.15" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-24.0.15.tgz#6c42d5af7fe3b44ffff7cc65de7bf741e8fa427f" + integrity sha512-MU1HIvWUme74stAoc3mgAi+aMlgKOudgEvQDIm1v4RkrDudBh1T+NFp5sftpBAdXdx1J0PbdpJ+M2EsSOi1djA== + dependencies: + "@types/jest-diff" "*" + +"@types/json-schema@*", "@types/json-schema@^7.0.3": + version "7.0.3" + resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.3.tgz#bdfd69d61e464dcc81b25159c270d75a73c1a636" + integrity sha512-Il2DtDVRGDcqjDtE+rF8iqg1CArehSK84HZJCT7AMITlyXRBpuPhqGLDQMowraqqu1coEaimg4ZOqggt6L6L+A== + +"@types/node@^12.6.6": + version "12.6.6" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.6.tgz#831587377c35bb28fa33b6fe5f849a26a3f4a412" + integrity sha512-SMgj3x28MkJyHdWaMv/g/ca3LYDi5gR7O8mX0VKazvFOnmlDXctSEdd/8jfSqozjKFK1R9If1QZWkafX7yQTpA== + "@types/stack-utils@^1.0.1": version "1.0.1" resolved "https://registry.yarnpkg.com/@types/stack-utils/-/stack-utils-1.0.1.tgz#0a851d3bd96498fa25c33ab7278ed3bd65f06c3e" @@ -1032,6 +1108,44 @@ resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.12.tgz#45dd1d0638e8c8f153e87d296907659296873916" integrity sha512-SOhuU4wNBxhhTHxYaiG5NY4HBhDIDnJF60GU+2LqHAdKKer86//e4yg69aENCtQ04n0ovz+tq2YPME5t5yp4pw== +"@typescript-eslint/eslint-plugin@^1.13.0": + version "1.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-1.13.0.tgz#22fed9b16ddfeb402fd7bcde56307820f6ebc49f" + integrity sha512-WQHCozMnuNADiqMtsNzp96FNox5sOVpU8Xt4meaT4em8lOG1SrOv92/mUbEHQVh90sldKSfcOc/I0FOb/14G1g== + dependencies: + "@typescript-eslint/experimental-utils" "1.13.0" + eslint-utils "^1.3.1" + functional-red-black-tree "^1.0.1" + regexpp "^2.0.1" + tsutils "^3.7.0" + +"@typescript-eslint/experimental-utils@1.13.0", "@typescript-eslint/experimental-utils@^1.13.0": + version "1.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/experimental-utils/-/experimental-utils-1.13.0.tgz#b08c60d780c0067de2fb44b04b432f540138301e" + integrity sha512-zmpS6SyqG4ZF64ffaJ6uah6tWWWgZ8m+c54XXgwFtUv0jNz8aJAVx8chMCvnk7yl6xwn8d+d96+tWp7fXzTuDg== + dependencies: + "@types/json-schema" "^7.0.3" + "@typescript-eslint/typescript-estree" "1.13.0" + eslint-scope "^4.0.0" + +"@typescript-eslint/parser@^1.13.0": + version "1.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-1.13.0.tgz#61ac7811ea52791c47dc9fd4dd4a184fae9ac355" + integrity sha512-ITMBs52PCPgLb2nGPoeT4iU3HdQZHcPaZVw+7CsFagRJHUhyeTgorEwHXhFf3e7Evzi8oujKNpHc8TONth8AdQ== + dependencies: + "@types/eslint-visitor-keys" "^1.0.0" + "@typescript-eslint/experimental-utils" "1.13.0" + "@typescript-eslint/typescript-estree" "1.13.0" + eslint-visitor-keys "^1.0.0" + +"@typescript-eslint/typescript-estree@1.13.0": + version "1.13.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-1.13.0.tgz#8140f17d0f60c03619798f1d628b8434913dc32e" + integrity sha512-b5rCmd2e6DCC6tCTN9GSUAuxdYwCM/k/2wdjHGrIRGPSJotWMCe/dGpi66u42bhuh8q3QBzqM4TMA1GUUCJvdw== + dependencies: + lodash.unescape "4.0.1" + semver "5.5.0" + JSONStream@^1.0.4: version "1.3.5" resolved "https://registry.yarnpkg.com/JSONStream/-/JSONStream-1.3.5.tgz#3208c1f08d3a4d99261ab64f92302bc15e111ca0" @@ -1078,7 +1192,7 @@ acorn@^6.0.1, acorn@^6.0.7: resolved "https://registry.yarnpkg.com/acorn/-/acorn-6.2.0.tgz#67f0da2fc339d6cfb5d6fb244fd449f33cd8bbe3" integrity sha512-8oe72N3WPMjA+2zVG71Ia0nXZ8DpQH+QyyHO+p06jT8eg8FGG3FbcUIi8KziHlAfheJQZeoqbvq1mQSQHXKYLw== -ajv@^6.5.5, ajv@^6.9.1: +ajv@^6.10.2, ajv@^6.5.5, ajv@^6.9.1: version "6.10.2" resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.10.2.tgz#d3cea04d6b017b2894ad69040fec8b623eb4bd52" integrity sha512-TXtUUEYHuaTEbLZWIKUr5pmBuhDLy+8KYtPYdcV8qC+pOZL+NKqYwvWSRrVXHn+ZmRRAu8vJTAznH7Oag6RVRw== @@ -1805,9 +1919,9 @@ cssom@0.3.x, "cssom@>= 0.3.2 < 0.4.0": integrity sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg== cssstyle@^1.0.0: - version "1.2.2" - resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-1.2.2.tgz#427ea4d585b18624f6fdbf9de7a2a1a3ba713077" - integrity sha512-43wY3kl1CVQSvL7wUY1qXkxVGkStjpkDmVjiIKX8R97uhajy8Bybay78uOtqvh7Q5GK75dNPfW0geWjE6qQQow== + version "1.4.0" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-1.4.0.tgz#9d31328229d3c565c61e586b02041a28fccdccf1" + integrity sha512-GBrLZYZ4X4x6/QEoBnIrqb8B/f5l4+8me2dkom/j1Gtbxy0kBv6OGzKuAsGM75bkGwGAFkt56Iwg28S3XTZgSA== dependencies: cssom "0.3.x" @@ -2004,9 +2118,9 @@ ecc-jsbn@~0.1.1: safer-buffer "^2.1.0" electron-to-chromium@^1.3.191: - version "1.3.191" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.191.tgz#c451b422cd8b2eab84dedabab5abcae1eaefb6f0" - integrity sha512-jasjtY5RUy/TOyiUYM2fb4BDaPZfm6CXRFeJDMfFsXYADGxUN49RBqtgB7EL2RmJXeIRUk9lM1U6A5yk2YJMPQ== + version "1.3.193" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.193.tgz#de9b89959288070bffb14557daf8cf9f75b2caf8" + integrity sha512-WX01CG1UoPtTUFaKKwMn+u8nJ63loP6hNxePWtk1pN8ibWMyX1q6TiWPsz1ABBKXezvmaIdtP+0BwzjC1wyCaw== elegant-spinner@^1.0.1: version "1.0.1" @@ -2169,7 +2283,7 @@ eslint-scope@3.7.1: esrecurse "^4.1.0" estraverse "^4.1.1" -eslint-scope@^4.0.3: +eslint-scope@^4.0.0, eslint-scope@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-4.0.3.tgz#ca03833310f6889a3264781aa82e63eb9cfe7848" integrity sha512-p7VutNr1O/QrxysMo3E45FjYDTeXBy0iTltPFNSqKAIfjDSXC+4dj+qfyuD8bfAXrW/y6lW3O76VaYNPKfpKrg== @@ -3959,12 +4073,17 @@ lodash.topairs@4.3.0: resolved "https://registry.yarnpkg.com/lodash.topairs/-/lodash.topairs-4.3.0.tgz#3b6deaa37d60fb116713c46c5f17ea190ec48d64" integrity sha1-O23qo31g+xFnE8RsXxfqGQ7EjWQ= +lodash.unescape@4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.unescape/-/lodash.unescape-4.0.1.tgz#bf2249886ce514cda112fae9218cdc065211fc9c" + integrity sha1-vyJJiGzlFM2hEvrpIYzcBlIR/Jw= + lodash.upperfirst@4.3.1: version "4.3.1" resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha1-E2Xt9DFIBIHvDRxolXpe2Z1J984= -lodash@^4.17.11, lodash@^4.17.12, lodash@^4.2.1: +lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.2.1: version "4.17.14" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.14.tgz#9ce487ae66c96254fe20b599f21b6816028078ba" integrity sha512-mmKYbW3GLuJeX+iGP+Y7Gp1AiGHGbXHCOh/jZmrawMmsE7MS4znI3RL2FsjbqOyMayHInjOeykW7PEajUk1/xw== @@ -5696,12 +5815,12 @@ synchronous-promise@^2.0.6: integrity sha512-LO95GIW16x69LuND1nuuwM4pjgFGupg7pZ/4lU86AmchPKrhk0o2tpMU2unXRrqo81iAFe1YJ0nAGEVwsrZAgg== table@^5.2.3: - version "5.4.1" - resolved "https://registry.yarnpkg.com/table/-/table-5.4.1.tgz#0691ae2ebe8259858efb63e550b6d5f9300171e8" - integrity sha512-E6CK1/pZe2N75rGZQotFOdmzWQ1AILtgYbMAbAjvms0S1l5IDB47zG3nCnFGB/w+7nB3vKofbLXCH7HPBo864w== + version "5.4.4" + resolved "https://registry.yarnpkg.com/table/-/table-5.4.4.tgz#6e0f88fdae3692793d1077fd172a4667afe986a6" + integrity sha512-IIfEAUx5QlODLblLrGTTLJA7Tk0iLSGBvgY8essPRVNGHAzThujww1YqHLs6h3HfTg55h++RzLHH5Xw/rfv+mg== dependencies: - ajv "^6.9.1" - lodash "^4.17.11" + ajv "^6.10.2" + lodash "^4.17.14" slice-ansi "^2.1.0" string-width "^3.0.0" @@ -5846,11 +5965,18 @@ trim-right@^1.0.1: resolved "https://registry.yarnpkg.com/trim-right/-/trim-right-1.0.1.tgz#cb2e1203067e0c8de1f614094b9fe45704ea6003" integrity sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM= -tslib@^1.8.0, tslib@^1.9.0: +tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== +tsutils@^3.7.0: + version "3.14.0" + resolved "https://registry.yarnpkg.com/tsutils/-/tsutils-3.14.0.tgz#bf8d5a7bae5369331fa0f2b0a5a10bd7f7396c77" + integrity sha512-SmzGbB0l+8I0QwsPgjooFRaRvHLBLNYM8SeQ0k6rtNDru5sCGeLJcZdwilNndN+GysuFjF5EIYgN8GfFG6UeUw== + dependencies: + tslib "^1.8.1" + tunnel-agent@^0.6.0: version "0.6.0" resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" @@ -5870,6 +5996,11 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" +typescript@^3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977" + integrity sha512-ACzBtm/PhXBDId6a6sDJfroT2pOWt/oOnk4/dElG5G33ZL776N3Y6/6bKZJBFpd+b05F3Ct9qDjMeJmRWtE2/g== + uglify-js@^3.1.4: version "3.6.0" resolved "https://registry.yarnpkg.com/uglify-js/-/uglify-js-3.6.0.tgz#704681345c53a8b2079fb6cec294b05ead242ff5"