Files
fusion/scripts/check-no-test-timeout-appeasement.mjs
gsxdsm 1f660f3e8a FN-6434: block Vitest timeout appeasement
Add a fast guard that rejects Vitest timeout bumps in tracked test files.

- Add a test-timeout appeasement scanner with a temporary allowlist for legacy exemptions.
- Run the scanner in pretest, pretest:full, and test:gate so merge gates catch timeout bumps.
- Cover the scanner behavior with node:test cases and document the policy/remediation path.

Files changed:
 docs/testing.md                                    |   6 ++
 package.json                                       |   6 +-
 .../check-no-test-timeout-appeasement.test.mjs     |  49 +++++++++
 scripts/check-no-test-timeout-appeasement.mjs      | 119 +++++++++++++++++++++
 .../lib/test-timeout-appeasement-allowlist.json    |  10 ++
 5 files changed, 187 insertions(+), 3 deletions(-)

Fusion-Task-Id: FN-6434

Fusion-Task-Lineage: deb46a27-b0c9-4644-b8bb-34ce98e7acde
2026-06-14 15:29:35 -07:00

120 lines
4.3 KiB
JavaScript
Executable File

#!/usr/bin/env node
/*
FNXC:TestHygiene 2026-06-14-03:15:
Repo policy forbids hiding slow or flaky Vitest suites with file-level or suite-level timeout bumps.
This guard blocks new `testTimeout` and `hookTimeout` appeasement in tracked test files, while a dated allowlist records temporary legacy exemptions that must link to the owning cleanup or quarantine work.
*/
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
export const ALLOWLIST_PATH = "scripts/lib/test-timeout-appeasement-allowlist.json";
const SCAN_ROOTS = ["packages", "plugins"];
const TEST_FILE_PATTERN = /\.test\.(?:ts|tsx|mts|cts|mjs|cjs|js|jsx)$/;
const VITEST_CONFIG_PATTERN = /(?:^|\/)vitest\.config\.[mc]?[jt]s$/;
const TIMEOUT_PROPERTY_PATTERN = /\b(?:testTimeout|hookTimeout)\s*:/;
function isTestFile(filePath) {
return TEST_FILE_PATTERN.test(filePath) && !VITEST_CONFIG_PATTERN.test(filePath);
}
function listTrackedTargets() {
const result = spawnSync("git", ["ls-files", "--", ...SCAN_ROOTS], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
if (result.status !== 0) {
throw new Error(result.stderr?.trim() || "git ls-files failed");
}
return result.stdout
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
.filter(isTestFile);
}
function loadAllowlistEntries(allowlistPath = ALLOWLIST_PATH) {
let parsed;
try {
parsed = JSON.parse(readFileSync(allowlistPath, "utf8"));
} catch (error) {
throw new Error(`Failed to read ${allowlistPath}: ${error instanceof Error ? error.message : String(error)}`);
}
if (!Array.isArray(parsed.entries)) {
throw new Error(`${allowlistPath} must contain an entries array`);
}
return parsed.entries;
}
function buildAllowlistedFiles(entries) {
const files = new Set();
for (const [index, entry] of entries.entries()) {
if (!entry || typeof entry.file !== "string" || entry.file.trim() === "") {
throw new Error(`${ALLOWLIST_PATH} entries[${index}] must include a non-empty file`);
}
if (typeof entry.reason !== "string" || entry.reason.trim() === "") {
throw new Error(`${ALLOWLIST_PATH} entries[${index}] for ${entry.file} must include a non-empty reason`);
}
files.add(entry.file);
}
return files;
}
export function scanFileContent(content, filePath, options = {}) {
if (!isTestFile(filePath)) return [];
const allowlistedFiles = options.allowlistedFiles ?? buildAllowlistedFiles(options.allowlistEntries ?? []);
if (allowlistedFiles.has(filePath)) return [];
const matches = [];
const lines = content.split(/\r?\n/);
for (let index = 0; index < lines.length; index += 1) {
const line = lines[index];
if (TIMEOUT_PROPERTY_PATTERN.test(line)) {
matches.push({ filePath, lineNumber: index + 1, line });
}
}
return matches;
}
export function scanTrackedFiles(files = listTrackedTargets(), options = {}) {
const allowlistedFiles = options.allowlistedFiles ?? buildAllowlistedFiles(options.allowlistEntries ?? loadAllowlistEntries());
const matches = [];
for (const filePath of files) {
if (!isTestFile(filePath) || allowlistedFiles.has(filePath)) continue;
let content;
try {
content = readFileSync(filePath, "utf8");
} catch {
continue;
}
matches.push(...scanFileContent(content, filePath, { allowlistedFiles }));
}
return matches;
}
export function formatFailureMessage(matches) {
const lines = matches.map(
({ filePath, lineNumber, line }) => `${filePath}:${lineNumber}: ${line.trim()}`,
);
return [
"[check-no-test-timeout-appeasement] found Vitest timeout appeasement in tracked test files.",
"Do not raise per-file/suite timeouts to mask slow/flaky tests — quarantine via `scripts/lib/test-quarantine.json` or narrow the seam; see AGENTS.md 'Do Not Add Slow Tests'.",
`For legitimately exempt legacy cases, add a dated rationale to ${ALLOWLIST_PATH}; exemptions are temporary and should point at the owning cleanup task.`,
...lines,
].join("\n");
}
export function main() {
const matches = scanTrackedFiles();
if (matches.length === 0) return 0;
console.error(formatFailureMessage(matches));
return 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
process.exitCode = main();
}