Skip to content

Commit

Permalink
feat: add enforce close testing module rule
Browse files Browse the repository at this point in the history
closes #1
  • Loading branch information
thiagomini committed Dec 2, 2023
1 parent dd8eae3 commit 0495f2d
Show file tree
Hide file tree
Showing 9 changed files with 412 additions and 4 deletions.
2 changes: 2 additions & 0 deletions .yarnrc.yml
@@ -1 +1,3 @@
nodeLinker: node-modules

yarnPath: .yarn/releases/yarn-4.0.2.cjs
4 changes: 2 additions & 2 deletions package.json
Expand Up @@ -10,7 +10,7 @@
},
"scripts": {
"tsc": "tsc",
"test": "echo \"Error: no test specified\" && exit 1",
"test": "tsx --import ./tests/global.setup.js --test tests/**/*.spec.ts",
"format": "prettier --write .",
"lint": "eslint . --ext .ts --max-warnings 0"
},
Expand All @@ -33,4 +33,4 @@
"tsx": "^4.6.2",
"typescript": "^5.3.2"
}
}
}
57 changes: 57 additions & 0 deletions src/ast-traverser.util.ts
@@ -0,0 +1,57 @@
import type { TSESTree } from '@typescript-eslint/utils';
import { AST_NODE_TYPES } from '@typescript-eslint/utils';

export function getAllParentNodesOfType<TNode extends TSESTree.Node>(
node: TSESTree.Node,
type: AST_NODE_TYPES
): TNode[] {
if (node.parent?.type === type) {
return [node.parent as TNode].concat(
getAllParentNodesOfType<TNode>(node.parent, type)
);
} else if (node?.parent) {
return getAllParentNodesOfType<TNode>(node.parent, type);
} else {
return [];
}
}

export function firstParentNodeOfType<TNode extends TSESTree.Node>(
node: TSESTree.Node,
type: AST_NODE_TYPES
): TNode | undefined {
if (node.parent?.type === type) {
return node.parent as TNode;
} else if (node?.parent) {
return firstParentNodeOfType<TNode>(node.parent, type);
} else {
return undefined;
}
}

export function getAllParentCallExpressions(
node: TSESTree.Node
): TSESTree.CallExpression[] {
return getAllParentNodesOfType<TSESTree.CallExpression>(
node,
AST_NODE_TYPES.CallExpression
);
}

export function getAllParentAssignmentExpressions(
node: TSESTree.Node
): TSESTree.AssignmentExpression[] {
return getAllParentNodesOfType<TSESTree.AssignmentExpression>(
node,
AST_NODE_TYPES.AssignmentExpression
);
}

export function firstAssignmentExpressionInParentChain(
node: TSESTree.Node
): TSESTree.AssignmentExpression | undefined {
return firstParentNodeOfType<TSESTree.AssignmentExpression>(
node,
AST_NODE_TYPES.AssignmentExpression
);
}
163 changes: 163 additions & 0 deletions src/rules/enforce-close-testing-module.rule.ts
@@ -0,0 +1,163 @@
import type { TSESTree } from '@typescript-eslint/utils';
import {
AST_NODE_TYPES,
ESLintUtils,
ASTUtils,
} from '@typescript-eslint/utils';
import * as traverser from '../ast-traverser.util';

type TestBeforeHooks = 'beforeAll' | 'beforeEach';
type TestAfterHooks = 'afterAll' | 'afterEach';
type HookType = 'all' | 'each';

const createRule = ESLintUtils.RuleCreator(
(name) => `https://eslint.org/docs/latest/rules/${name}`
);

function typeOfHook(hookName: TestBeforeHooks | TestAfterHooks): HookType {
return hookName.includes('All') ? 'all' : 'each';
}

export default createRule({
name: 'enforce-close-testing-module',
meta: {
type: 'problem',
docs: {
description: 'Ensure NestJS testing modules are closed properly',
recommended: 'recommended',
},
fixable: undefined,
schema: [], // no options
messages: {
testModuleNotClosed:
'A Testing Module was created but not closed, which can cause memory leaks',
testModuleClosedInWrongHook:
'A Testing Module was created in {{ created }} but was closed in the wrong hook {{ closed }}',
},
},
defaultOptions: [],
create(context) {
let testModuleCreated = false;
let testModuleClosed = false;
let testingModuleVariableName: string | undefined;
let createdInHook: TestBeforeHooks | undefined;
const testingModuleCreatedPosition: TSESTree.SourceLocation = {
start: { line: 0, column: 0 },
end: { line: 0, column: 0 },
};
let closedInHook: TestAfterHooks | undefined;

let appModuleCreated = false;
let appModuleClosed = false;
let appModuleVariableName: string | undefined;

return {
// Matches code that defines a variable of type TestingModule
// e.g. `let testingModule: TestingModule;`
'VariableDeclarator[id.typeAnnotation.typeAnnotation.typeName.name="TestingModule"]':
(node: TSESTree.VariableDeclarator) => {
testModuleCreated = true;
if (ASTUtils.isIdentifier(node.id)) {
testingModuleVariableName = node.id.name;
}
},

// Matches code that creates a testing module and assigns it to a variable
// e.g. `const testingModule = await Test.createTestingModule({ ... }).compile();`
'VariableDeclarator[init.type="AwaitExpression"][init.argument.callee.type="MemberExpression"][init.argument.callee.object.callee.object.name="Test"][init.argument.callee.object.callee.property.name="createTestingModule"]':
(node: TSESTree.VariableDeclarator) => {
testModuleCreated = true;
if (ASTUtils.isIdentifier(node.id)) {
testingModuleVariableName = node.id.name;
}
},

'MemberExpression[object.name="Test"][property.name="createTestingModule"]':
(node: TSESTree.MemberExpression) => {
// Check under which hook the module was created
const callExpressions = traverser.getAllParentCallExpressions(node);
const callExpressionWithHook = callExpressions.find(
(expression) =>
ASTUtils.isIdentifier(expression.callee) &&
['beforeAll', 'beforeEach'].includes(expression.callee.name)
);
if (
callExpressionWithHook &&
ASTUtils.isIdentifier(callExpressionWithHook.callee)
) {
createdInHook = callExpressionWithHook.callee
.name as TestBeforeHooks;
}
},
'MemberExpression[property.name="createNestApplication"]': (node) => {
// Checks if app.createNestApplication() is called
appModuleCreated = true;
const assignmentExpression =
traverser.firstAssignmentExpressionInParentChain(node);
if (ASTUtils.isIdentifier(assignmentExpression?.left)) {
appModuleVariableName = assignmentExpression?.left.name;
}
},
'MemberExpression[property.name="close"]': (
node: TSESTree.MemberExpression
) => {
// Logic to check if module.close() is called
if (
node.object.type === AST_NODE_TYPES.Identifier &&
node.object.name === testingModuleVariableName &&
testModuleCreated
) {
testModuleClosed = true;
}

// Logic to check if app.close() is called
if (
node.object.type === AST_NODE_TYPES.Identifier &&
node.object.name === appModuleVariableName &&
appModuleCreated
) {
appModuleClosed = true;
}

// Logic to check if module.close() is called in the wrong hook
const callExpressions = traverser.getAllParentCallExpressions(node);
const callExpressionWithHook = callExpressions.find(
(expression) =>
ASTUtils.isIdentifier(expression.callee) &&
['afterAll', 'afterEach'].includes(expression.callee.name)
);
if (
callExpressionWithHook &&
ASTUtils.isIdentifier(callExpressionWithHook.callee)
) {
closedInHook = callExpressionWithHook.callee.name as TestAfterHooks;
}

if (
closedInHook &&
createdInHook &&
typeOfHook(closedInHook) !== typeOfHook(createdInHook) &&
testModuleCreated
) {
context.report({
node,
messageId: 'testModuleClosedInWrongHook',
data: {
created: createdInHook,
closed: closedInHook,
},
});
}
},
'Program:exit': (node) => {
if (testModuleCreated && !testModuleClosed && !appModuleClosed) {
context.report({
node,
messageId: 'testModuleNotClosed',
loc: testingModuleCreatedPosition,
});
}
},
};
},
});
8 changes: 8 additions & 0 deletions tests/global.setup.js
@@ -0,0 +1,8 @@
import { RuleTester } from '@typescript-eslint/rule-tester';
import { after, describe, it } from 'node:test';

// We need to specify these functions for RuleTester to work. See [Docs](https://eslint.org/docs/latest/integrate/nodejs-api#customizing-ruletester)
console.log('Loading global setup for RuleTester...');
RuleTester.afterAll = after;
RuleTester.describe = describe;
RuleTester.it = it;

0 comments on commit 0495f2d

Please sign in to comment.