Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add enforce close testing module rule
closes #1
- Loading branch information
1 parent
dd8eae3
commit 0495f2d
Showing
9 changed files
with
412 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,3 @@ | ||
nodeLinker: node-modules | ||
|
||
yarnPath: .yarn/releases/yarn-4.0.2.cjs |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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, | ||
}); | ||
} | ||
}, | ||
}; | ||
}, | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; |
Oops, something went wrong.