From 51fd474274dce7bffae6dbbd826d1fbb8fde1bd0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 15:35:56 +0900 Subject: [PATCH] test(testing): tighten mock cleanup audit --- .../mock-module-lifecycle-audit.test.ts | 184 +++++++++++------- 1 file changed, 118 insertions(+), 66 deletions(-) diff --git a/src/shared/mock-module-lifecycle-audit.test.ts b/src/shared/mock-module-lifecycle-audit.test.ts index cdc7a453a..8f1f7b691 100644 --- a/src/shared/mock-module-lifecycle-audit.test.ts +++ b/src/shared/mock-module-lifecycle-audit.test.ts @@ -6,63 +6,6 @@ import ts from "typescript" const SOURCE_ROOT = path.resolve(import.meta.dir, "..") const WORKSPACE_ROOT = path.resolve(SOURCE_ROOT, "..") const MOCK_MODULE_TOKEN = "mock.module" -const MOCK_MODULE_LIFECYCLE_ALLOWLIST = new Map([ - // TODO(MOCK-MODULE-AUDIT): add cleanup for team mailbox inbox module mocks. - [ - path.join(SOURCE_ROOT, "features", "team-mode", "team-mailbox", "inbox.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for doctor dependency module mocks. - [ - path.join(SOURCE_ROOT, "cli", "doctor", "checks", "dependencies.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for session recovery module mocks. - [ - path.join(SOURCE_ROOT, "hooks", "session-recovery", "index.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for auto-update checker hook module mocks. - [ - path.join(SOURCE_ROOT, "hooks", "auto-update-checker", "hook.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for tmux layout-runner module mocks. - [ - path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "layout-runner.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close-runner module mocks. - [ - path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close-runner.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-close module mocks. - [ - path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-close.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for tmux pane-dimensions module mocks. - [ - path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "pane-dimensions.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill-runner module mocks. - [ - path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill-runner.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for tmux session-kill module mocks. - [ - path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "session-kill.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], - // TODO(MOCK-MODULE-AUDIT): add cleanup for tmux stale-session sweep module mocks. - [ - path.join(SOURCE_ROOT, "shared", "tmux", "tmux-utils", "stale-session-sweep-runtime.test.ts"), - "justification: legacy mock.module call predates audit; TODO(MOCK-MODULE-AUDIT): add cleanup", - ], -]) async function listTestFiles(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }) @@ -152,6 +95,38 @@ function hasMockModuleCall(sourceFile: ts.SourceFile): boolean { return collectMockModulePaths(sourceFile).length > 0 } +function isTopLevelNode(node: ts.Node, sourceFile: ts.SourceFile): boolean { + let current = node.parent + while (current && current !== sourceFile) { + if (ts.isFunctionLike(current) || ts.isClassLike(current)) { + return false + } + current = current.parent + } + + return true +} + +function hasTopLevelMockModuleCall(sourceFile: ts.SourceFile): boolean { + let foundTopLevelMock = false + + const visit = (node: ts.Node): void => { + if (foundTopLevelMock) { + return + } + + if (ts.isCallExpression(node) && isMockModuleCall(node) && isTopLevelNode(node, sourceFile)) { + foundTopLevelMock = true + return + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return foundTopLevelMock +} + function hasDuplicateModuleReset(sourceFile: ts.SourceFile): boolean { const seenModulePaths = new Set() for (const modulePath of collectMockModulePaths(sourceFile)) { @@ -164,11 +139,7 @@ function hasDuplicateModuleReset(sourceFile: ts.SourceFile): boolean { return false } -function isCleanupCall(node: ts.CallExpression): boolean { - if (ts.isIdentifier(node.expression)) { - return node.expression.text === "afterEach" || node.expression.text === "afterAll" - } - +function isMockRestoreCall(node: ts.CallExpression): boolean { const expression = node.expression return ts.isPropertyAccessExpression(expression) && ts.isIdentifier(expression.expression) @@ -176,6 +147,14 @@ function isCleanupCall(node: ts.CallExpression): boolean { && expression.name.text === "restore" } +function isCleanupCall(node: ts.CallExpression): boolean { + if (ts.isIdentifier(node.expression)) { + return node.expression.text === "afterEach" || node.expression.text === "afterAll" + } + + return isMockRestoreCall(node) +} + function hasCleanupPattern(sourceFile: ts.SourceFile): boolean { if (hasDuplicateModuleReset(sourceFile)) { return true @@ -200,6 +179,74 @@ function hasCleanupPattern(sourceFile: ts.SourceFile): boolean { return foundCleanup } +function afterAllCallsMockRestore(node: ts.CallExpression): boolean { + if (!ts.isIdentifier(node.expression) || node.expression.text !== "afterAll") { + return false + } + + const callback = node.arguments[0] + if (!callback) { + return false + } + + let foundRestore = false + const visit = (child: ts.Node): void => { + if (foundRestore) { + return + } + + if (ts.isCallExpression(child) && isMockRestoreCall(child)) { + foundRestore = true + return + } + + ts.forEachChild(child, visit) + } + + visit(callback) + return foundRestore +} + +function hasAfterAllMockRestore(sourceFile: ts.SourceFile): boolean { + let foundCleanup = false + + const visit = (node: ts.Node): void => { + if (foundCleanup) { + return + } + + if (ts.isCallExpression(node) && afterAllCallsMockRestore(node)) { + foundCleanup = true + return + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return foundCleanup +} + +function hasTopLevelMockRestore(sourceFile: ts.SourceFile): boolean { + let foundCleanup = false + + const visit = (node: ts.Node): void => { + if (foundCleanup) { + return + } + + if (ts.isCallExpression(node) && isMockRestoreCall(node) && isTopLevelNode(node, sourceFile)) { + foundCleanup = true + return + } + + ts.forEachChild(node, visit) + } + + visit(sourceFile) + return foundCleanup +} + describe("mock.module lifecycle hygiene", () => { test("#given test files using mock.module #when audited #then each must pair with cleanup", async () => { // given @@ -208,15 +255,20 @@ describe("mock.module lifecycle hygiene", () => { // when for (const filePath of files) { - if (MOCK_MODULE_LIFECYCLE_ALLOWLIST.has(filePath)) { - continue - } - const contents = await readFile(filePath, "utf8") if (!contents.includes(MOCK_MODULE_TOKEN)) { continue } const sourceFile = ts.createSourceFile(filePath, contents, ts.ScriptTarget.Latest, true) + if ( + hasTopLevelMockModuleCall(sourceFile) + && !hasAfterAllMockRestore(sourceFile) + && !hasTopLevelMockRestore(sourceFile) + ) { + offenders.push(relativeSourcePath(filePath)) + continue + } + if (hasMockModuleCall(sourceFile) && !hasCleanupPattern(sourceFile)) { offenders.push(relativeSourcePath(filePath)) }