From 6cbaa0fb56e576749b4c2bf5fb069dc38efec2da Mon Sep 17 00:00:00 2001 From: Gareth Jones Date: Tue, 23 Jul 2019 00:06:10 +1000 Subject: [PATCH] chore(prefer-todo): migrate to TS (#335) --- ...refer-todo.test.js => prefer-todo.test.ts} | 4 +- src/rules/prefer-todo.js | 80 ------------ src/rules/prefer-todo.ts | 119 ++++++++++++++++++ src/rules/tsUtils.ts | 31 ++++- src/rules/util.js | 23 ---- 5 files changed, 150 insertions(+), 107 deletions(-) rename src/rules/__tests__/{prefer-todo.test.js => prefer-todo.test.ts} (92%) delete mode 100644 src/rules/prefer-todo.js create mode 100644 src/rules/prefer-todo.ts diff --git a/src/rules/__tests__/prefer-todo.test.js b/src/rules/__tests__/prefer-todo.test.ts similarity index 92% rename from src/rules/__tests__/prefer-todo.test.js rename to src/rules/__tests__/prefer-todo.test.ts index 8dc087b33..cd1401b0d 100644 --- a/src/rules/__tests__/prefer-todo.test.js +++ b/src/rules/__tests__/prefer-todo.test.ts @@ -1,7 +1,7 @@ -import { RuleTester } from 'eslint'; +import { TSESLint } from '@typescript-eslint/experimental-utils'; import rule from '../prefer-todo'; -const ruleTester = new RuleTester({ +const ruleTester = new TSESLint.RuleTester({ parserOptions: { ecmaVersion: 2015 }, }); diff --git a/src/rules/prefer-todo.js b/src/rules/prefer-todo.js deleted file mode 100644 index 48f466566..000000000 --- a/src/rules/prefer-todo.js +++ /dev/null @@ -1,80 +0,0 @@ -import { - composeFixers, - getDocsUrl, - getNodeName, - isFunction, - isString, -} from './util'; - -function isOnlyTestTitle(node) { - return node.arguments.length === 1; -} - -function isFunctionBodyEmpty(node) { - return node.body.body && !node.body.body.length; -} - -function isTestBodyEmpty(node) { - const fn = node.arguments[1]; // eslint-disable-line prefer-destructuring - return fn && isFunction(fn) && isFunctionBodyEmpty(fn); -} - -function addTodo(node, fixer) { - const testName = getNodeName(node.callee) - .split('.') - .shift(); - return fixer.replaceText(node.callee, `${testName}.todo`); -} - -function removeSecondArg({ arguments: [first, second] }, fixer) { - return fixer.removeRange([first.range[1], second.range[1]]); -} - -function isFirstArgString({ arguments: [firstArg] }) { - return firstArg && isString(firstArg); -} - -const isTestCase = node => - node && - node.type === 'CallExpression' && - ['it', 'test', 'it.skip', 'test.skip'].includes(getNodeName(node.callee)); - -export default { - meta: { - docs: { - url: getDocsUrl(__filename), - }, - messages: { - todoOverEmpty: 'Prefer todo test case over empty test case', - todoOverUnimplemented: - 'Prefer todo test case over unimplemented test case', - }, - fixable: 'code', - schema: [], - }, - create(context) { - return { - CallExpression(node) { - if (isTestCase(node) && isFirstArgString(node)) { - const combineFixers = composeFixers(node); - - if (isTestBodyEmpty(node)) { - context.report({ - messageId: 'todoOverEmpty', - node, - fix: combineFixers(removeSecondArg, addTodo), - }); - } - - if (isOnlyTestTitle(node)) { - context.report({ - messageId: 'todoOverUnimplemented', - node, - fix: combineFixers(addTodo), - }); - } - } - }, - }; - }, -}; diff --git a/src/rules/prefer-todo.ts b/src/rules/prefer-todo.ts new file mode 100644 index 000000000..829a14c41 --- /dev/null +++ b/src/rules/prefer-todo.ts @@ -0,0 +1,119 @@ +import { + AST_NODE_TYPES, + TSESLint, + TSESTree, +} from '@typescript-eslint/experimental-utils'; +import { + FunctionExpression, + JestFunctionCallExpression, + StringLiteral, + TestCaseName, + createRule, + getNodeName, + isFunction, + isStringNode, + isTestCase, +} from './tsUtils'; + +function isOnlyTestTitle(node: TSESTree.CallExpression) { + return node.arguments.length === 1; +} + +function isFunctionBodyEmpty(node: FunctionExpression) { + /* istanbul ignore next https://github.com/typescript-eslint/typescript-eslint/issues/734 */ + if (!node.body) { + throw new Error( + `Unexpected null while performing prefer-todo - please file a github issue at https://github.com/jest-community/eslint-plugin-jest`, + ); + } + + return ( + node.body.type === AST_NODE_TYPES.BlockStatement && + node.body.body && + !node.body.body.length + ); +} + +function isTestBodyEmpty(node: TSESTree.CallExpression) { + const [, fn] = node.arguments; + return fn && isFunction(fn) && isFunctionBodyEmpty(fn); +} + +function addTodo( + node: JestFunctionCallExpression, + fixer: TSESLint.RuleFixer, +) { + const testName = getNodeName(node.callee) + .split('.') + .shift(); + return fixer.replaceText(node.callee, `${testName}.todo`); +} + +interface CallExpressionWithStringArgument extends TSESTree.CallExpression { + arguments: [StringLiteral | TSESTree.TemplateLiteral]; +} + +function isFirstArgString( + node: TSESTree.CallExpression, +): node is CallExpressionWithStringArgument { + return node.arguments[0] && isStringNode(node.arguments[0]); +} + +const isTargetedTestCase = ( + node: TSESTree.CallExpression, +): node is JestFunctionCallExpression => + isTestCase(node) && + (['it', 'test', 'it.skip', 'test.skip'] as Array).includes( + getNodeName(node.callee), + ); + +export default createRule({ + name: __filename, + meta: { + docs: { + category: 'Best Practices', + description: 'Suggest using `test.todo`', + recommended: false, + }, + messages: { + todoOverEmpty: 'Prefer todo test case over empty test case', + todoOverUnimplemented: + 'Prefer todo test case over unimplemented test case', + }, + fixable: 'code', + schema: [], + type: 'layout', + }, + defaultOptions: [], + create(context) { + return { + CallExpression(node) { + if (!isTargetedTestCase(node) || !isFirstArgString(node)) { + return; + } + + if (isTestBodyEmpty(node)) { + context.report({ + messageId: 'todoOverEmpty', + node, + fix: fixer => [ + fixer.removeRange([ + node.arguments[0].range[1], + node.arguments[1].range[1], + ]), + addTodo(node, fixer), + ], + }); + } + + if (isOnlyTestTitle(node)) { + context.report({ + messageId: 'todoOverUnimplemented', + node, + fix: fixer => [addTodo(node, fixer)], + }); + } + }, + }; + }, +}); diff --git a/src/rules/tsUtils.ts b/src/rules/tsUtils.ts index 01ff96164..5f28d7141 100644 --- a/src/rules/tsUtils.ts +++ b/src/rules/tsUtils.ts @@ -127,7 +127,13 @@ export type JestFunctionCallExpression< | JestFunctionCallExpressionWithMemberExpressionCallee | JestFunctionCallExpressionWithIdentifierCallee; -export const getNodeName = (node: TSESTree.Node): string | null => { +export function getNodeName( + node: + | JestFunctionMemberExpression + | JestFunctionIdentifier, +): string; +export function getNodeName(node: TSESTree.Node): string | null; +export function getNodeName(node: TSESTree.Node): string | null { function joinNames(a?: string | null, b?: string | null): string | null { return a && b ? `${a}.${b}` : null; } @@ -145,7 +151,7 @@ export const getNodeName = (node: TSESTree.Node): string | null => { } return null; -}; +} export type FunctionExpression = | TSESTree.ArrowFunctionExpression @@ -192,6 +198,27 @@ export const isLiteralNode = (node: { type: AST_NODE_TYPES; }): node is TSESTree.Literal => node.type === AST_NODE_TYPES.Literal; +export interface StringLiteral extends TSESTree.Literal { + value: string; +} + +export type StringNode = StringLiteral | TSESTree.TemplateLiteral; + +export const isStringLiteral = (node: TSESTree.Node): node is StringLiteral => + node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string'; + +export const isTemplateLiteral = ( + node: TSESTree.Node, +): node is TSESTree.TemplateLiteral => + node && node.type === AST_NODE_TYPES.TemplateLiteral; + +export const isStringNode = (node: TSESTree.Node): node is StringNode => + isStringLiteral(node) || isTemplateLiteral(node); + +/* istanbul ignore next we'll need this later */ +export const getStringValue = (arg: StringNode): string => + isTemplateLiteral(arg) ? arg.quasis[0].value.raw : arg.value; + const collectReferences = (scope: TSESLint.Scope.Scope) => { const locals = new Set(); const unresolved = new Set(); diff --git a/src/rules/util.js b/src/rules/util.js index 33bbce217..61d7fbf25 100644 --- a/src/rules/util.js +++ b/src/rules/util.js @@ -101,21 +101,6 @@ const describeAliases = new Set(['describe', 'fdescribe', 'xdescribe']); const testCaseNames = new Set(['fit', 'it', 'test', 'xit', 'xtest']); -export const getNodeName = node => { - function joinNames(a, b) { - return a && b ? `${a}.${b}` : null; - } - - switch (node && node.type) { - case 'Identifier': - return node.name; - case 'MemberExpression': - return joinNames(getNodeName(node.object), getNodeName(node.property)); - } - - return null; -}; - export const isTestCase = node => node && node.type === 'CallExpression' && @@ -165,11 +150,3 @@ export const getDocsUrl = filename => { return `${REPO_URL}/blob/v${version}/docs/rules/${ruleName}.md`; }; - -export function composeFixers(node) { - return (...fixers) => { - return fixerApi => { - return fixers.reduce((all, fixer) => [...all, fixer(node, fixerApi)], []); - }; - }; -}