Skip to content

Commit

Permalink
Merge pull request #24 from codejedi365/fix-expectExpect
Browse files Browse the repository at this point in the history
Fix: `expect-expect` edge cases
  • Loading branch information
codejedi365 committed Sep 29, 2021
2 parents 8c1a4bc + 83db8f6 commit 0733b95
Show file tree
Hide file tree
Showing 3 changed files with 255 additions and 51 deletions.
10 changes: 7 additions & 3 deletions jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ module.exports = {
preset: "ts-jest",
coverageThreshold: {
global: {
branches: 100,
branches: 90,
functions: 100,
lines: 100,
statements: 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 0733b95

Please sign in to comment.