Skip to content

Commit

Permalink
fix: expectExpect rule to handle more use cases
Browse files Browse the repository at this point in the history
\### Rationale
Due to the lack of type-checking and not utilizing recursion to review
the function tree, there were some missing cases where the rule would
fail. Added more test cases which highlight code sequences that do
fail on the old implementation.

Also, made strict adjustments to jest threshold related to this file's
error handling. So if this changes, it will have to be determined
of a better implementation.
  • Loading branch information
codejedi365 committed Sep 29, 2021
1 parent 3b3bdb7 commit 83db8f6
Show file tree
Hide file tree
Showing 3 changed files with 252 additions and 48 deletions.
4 changes: 4 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ module.exports = {
functions: 100,
lines: 90,
statements: 90
},
"./lib/rules/expect-expect.ts": { // allow error handling
lines: -3,
statements: -5
}
},
testPathIgnorePatterns: ["<rootDir>/tests/fixtures/"],
Expand Down
193 changes: 161 additions & 32 deletions lib/rules/expect-expect.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,108 @@
/**
* @fileoverview Don't allow debug() to be committed to the repository.
* @fileoverview Don't allow debug() to be committed to the repository.
* @author Ben Monro
* @author codejedi365
*/
"use strict";

import {
CallExpression,
MemberExpression,
Identifier,
AST_NODE_TYPES,
BaseNode
} from "@typescript-eslint/types/dist/ast-spec";
import { createRule } from "../create-rule";

type FunctionName = string;
type ObjectName = string;

const testFnAttributes = [
// Derived List from TestFn class of testcafe@1.16.0/ts-defs/index.d.ts
// - only extracted attributes which return the testFn object (this)
// which are possible modifiers to a test call before the test callback
// is defined
"only",
"skip",
"disablePageCaching",
"disablePageReloads"
];

function isMemberExpression(node: BaseNode): node is MemberExpression {
return node.type === AST_NODE_TYPES.MemberExpression;
}

function isIdentifier(node: BaseNode): node is Identifier {
return node.type === AST_NODE_TYPES.Identifier;
}

function isCallExpression(node: BaseNode): node is CallExpression {
return node.type === AST_NODE_TYPES.CallExpression;
}

function digForIdentifierName(startNode: BaseNode): string {
function checkTypeForRecursion(
node: BaseNode
): node is CallExpression | MemberExpression | Identifier {
return (
isIdentifier(node) ||
isMemberExpression(node) ||
isCallExpression(node)
);
}
function deriveFnName(
node: CallExpression | MemberExpression | Identifier
): string {
let nextNode: BaseNode = node;

if (isCallExpression(node)) {
nextNode = node.callee;
} else if (isMemberExpression(node)) {
nextNode = node.object;
} else if (isIdentifier(node)) {
return node.name;
}

if (!checkTypeForRecursion(nextNode)) throw new Error();
return deriveFnName(nextNode);
}

// Start Point
try {
if (!checkTypeForRecursion(startNode)) throw new Error();
return deriveFnName(startNode);
} catch (e) {
throw new Error("Could not derive function name from callee.");
}
}

function deriveFunctionName(fnCall: CallExpression): string {
const startNode =
isMemberExpression(fnCall.callee) &&
isIdentifier(fnCall.callee.property)
? fnCall.callee.property
: fnCall.callee;

return digForIdentifierName(startNode);
}

/**
* Must detect symbol names in the following syntatical situations
* 1. stand-alone function call (identifier only)
* 2. object class method call (MemberExpression)
* 3. n+ deep object attributes (Recursive MemberExpressions)
* 4. when expression Is on a method chain (Recursive CallExpressions)
* @param fnCall
* @returns top level symbol for name of object
*/
function deriveObjectName(fnCall: CallExpression): string {
return digForIdentifierName(fnCall.callee);
}

function determineCodeLocation(
node: CallExpression
): [FunctionName, ObjectName] {
return [deriveFunctionName(node), deriveObjectName(node)];
}

//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
Expand All @@ -14,9 +111,9 @@ export default createRule({
name: __filename,
defaultOptions: [],
meta: {
type:"problem",
type: "problem",
messages: {
missingExpect: 'Please ensure your test has at least one expect'
missingExpect: "Please ensure your test has at least one expect"
},
docs: {
description: "Ensure tests have at least one expect",
Expand All @@ -25,35 +122,67 @@ export default createRule({
},
schema: []
},
create: function(context) {

let hasExpect = false;
let isInsideTest = false;
create(context) {
let hasExpect = false;
let isInsideTest = false;
let ignoreExpects = false;
return {
"CallExpression"(node: any) {
const name = node.callee.name || node.callee.property?.name;
const objectName = node.callee.object?.name || node.callee.callee?.object?.object?.name || node.parent.callee?.callee?.object?.name;
if (name === "test" || objectName === "test") {
isInsideTest = true;
"CallExpression": (node: CallExpression) => {
if (isInsideTest && hasExpect) return; // Short circuit, already found

let fnName;
let objectName;
try {
[fnName, objectName] = determineCodeLocation(node);
} catch (e) {
// ABORT: Failed to evaluate rule effectively
// since I cannot derive values to determine location in the code
return;
}
if (isInsideTest && name === "expect") {
hasExpect = true;

if (isInsideTest) {
if (ignoreExpects) return;
if (fnName === "expect") {
hasExpect = true;
return;
}
if (objectName === "test") {
// only happens in chained methods with internal callbacks
// like test.before(() => {})("my test", async () => {})
// prevents any registering of an expect in the before() callback
ignoreExpects = true;
}
return;
}
},

"CallExpression:exit"(node: any) {
const name = node.callee.name || node.callee.property?.name;

const objectName = node.callee.object?.name || node.callee.callee?.object?.object?.name || node.parent.callee?.callee.object.name;
if (name === "test" || objectName === "test") {
if (!hasExpect) {
context.report({ node, messageId: "missingExpect" });
}
hasExpect = false;
isInsideTest = false;
// Determine if inside/chained to a test() function
if (objectName !== "test") return;
if (fnName === "test" || testFnAttributes.includes(fnName)) {
isInsideTest = true;
}
}
}
},

"CallExpression:exit": (node: CallExpression) => {
if (!isInsideTest) return; // Short circuit

let fnName;
let objectName;
try {
[fnName, objectName] = determineCodeLocation(node);
} catch (e) {
// ABORT: Failed to evaluate rule effectively
// since I cannot derive values to determine location in the code
return;
}
if (objectName !== "test") return;
if (fnName === "test" || testFnAttributes.includes(fnName)) {
if (!hasExpect) {
context.report({ node, messageId: "missingExpect" });
}
hasExpect = false;
isInsideTest = false;
}
ignoreExpects = false;
}
};
}
}
);
});
103 changes: 87 additions & 16 deletions tests/lib/rules/expect-expect.ts
Original file line number Diff line number Diff line change
@@ -1,54 +1,125 @@
/**
* @fileoverview Don&#39;t allow debug() to be committed to the repository.
* @fileoverview Don&#39;t allow debug() to be committed to the repository.
* @author Ben Monro
* @author codejedi365
*/
"use strict";

//------------------------------------------------------------------------------
// Requirements
//------------------------------------------------------------------------------
import resolveFrom from "resolve-from";
import { TSESLint } from "@typescript-eslint/experimental-utils";
import rule from "../../../lib/rules/expect-expect";
import {RuleTester} from 'eslint';


import resolveFrom from 'resolve-from';
import { TSESLint } from '@typescript-eslint/experimental-utils';

//------------------------------------------------------------------------------
// Tests
//------------------------------------------------------------------------------
let ruleTester = new TSESLint.RuleTester({ parser: resolveFrom(require.resolve('eslint'), 'espree'),
parserOptions: { ecmaVersion: 8 } });
const ruleTester = new TSESLint.RuleTester({
parser: resolveFrom(require.resolve("eslint"), "espree"),
parserOptions: { ecmaVersion: 8 }
});

ruleTester.run("expect-expect", rule, {
valid: [
`test("foo", async t => { await t.expect(foo).eql(bar)})`,
`test.skip("foo", async t => { await t.expect(foo).eql(bar)})`
`test("foo", async t => { await t.expect(foo).eql(bar) })`,
`test.skip("foo", async t => { await t.expect(foo).eql(bar) })`,
`test.page("./foo")("foo", async t => { await t.expect(foo).eql(bar) })`,
`test.only.page("./foo")("foo", async t => { await t.expect(foo).eql(bar) })`,
// Chained expect
`test("foo", async t => {
await t
.click(button)
.expect(foo)
.eql(bar)
})`,
// More than 1 function on t
`test("foo", async t => {
await t.click(button)
await t.expect(foo).eql(bar)
})`,
// Multiple expects
`test("foo", async t => {
await t.click(button)
await t.expect(foo).eql(bar)
await t.expect(true).ok()
})`,
// chained function with callback parameter
`test.before(async t => {
await t.useRole(adminRole).wait(1000);
})("foo", async t => {
await t.click(button);
await t.expect(foo).eql(bar);
})`,
// Multiple tests
`fixture("My Fixture")
.page("https://example.com");
test("test1", async t => {
await t.useRole(adminRole);
await t.expect(foo).eql(bar);
});
test("test2", async t => {
await t.click(button);
await t.expect(foo).eql(bar);
});`
],
invalid: [
{
code: `test("foo", async t => {
await t.click(button)
})`,
errors:[{messageId: "missingExpect"}]
errors: [{ messageId: "missingExpect" }]
},
{
code: `test.skip("foo", async t => {
await t.click(button)
})`,
errors:[{messageId: "missingExpect"}]
errors: [{ messageId: "missingExpect" }]
},
{
code: `test.page("./foo")("foo", async t => {
await t.click(button)
})`,
errors:[{messageId: "missingExpect"}]
errors: [{ messageId: "missingExpect" }]
},
{
code: `test.skip.page("./foo")("foo", async t => {
await t.click(button)
})`,
errors:[{messageId: "missingExpect"}]
errors: [{ messageId: "missingExpect" }]
},
{
code: `test.before(async t => {
await t.useRole(adminRole).wait(1000);
await t.expect(Login).ok();
})("foo", async t => {
await t.click(button);
})`,
errors: [{ messageId: "missingExpect" }]
},
{
code: `test("foo", async t => {
await t
.useRole(adminRole)
.click(button)
.wait(1000)
})`,
errors: [{ messageId: "missingExpect" }]
},
{
// Missing one expect across 2 tests
code: `fixture("My Fixture")
.page("https://example.com");
test("test1", async t => {
await t.useRole(adminRole).wait(500);
await t.expect(foo).eql(bar);
});
test("test2", async t => {
await t.click(button);
});`,
errors: [{ messageId: "missingExpect" }]
}
]
})
});

0 comments on commit 83db8f6

Please sign in to comment.