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
120 lines
4.3 KiB
JavaScript
Executable File
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();
|
|
}
|