Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 },
});

Expand Down
80 changes: 0 additions & 80 deletions src/rules/prefer-todo.js

This file was deleted.

119 changes: 119 additions & 0 deletions src/rules/prefer-todo.ts
Original file line number Diff line number Diff line change
@@ -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<TestCaseName>,
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<TestCaseName> =>
isTestCase(node) &&
(['it', 'test', 'it.skip', 'test.skip'] as Array<string | null>).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)],
});
}
},
};
},
});
31 changes: 29 additions & 2 deletions src/rules/tsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,13 @@ export type JestFunctionCallExpression<
| JestFunctionCallExpressionWithMemberExpressionCallee<FunctionName>
| JestFunctionCallExpressionWithIdentifierCallee<FunctionName>;

export const getNodeName = (node: TSESTree.Node): string | null => {
export function getNodeName(
node:
| JestFunctionMemberExpression<JestFunctionName>
| JestFunctionIdentifier<JestFunctionName>,
): 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;
}
Expand All @@ -145,7 +151,7 @@ export const getNodeName = (node: TSESTree.Node): string | null => {
}

return null;
};
}

export type FunctionExpression =
| TSESTree.ArrowFunctionExpression
Expand Down Expand Up @@ -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();
Expand Down
23 changes: 0 additions & 23 deletions src/rules/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' &&
Expand Down Expand Up @@ -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)], []);
};
};
}