import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import vitestConfig from "../../../vitest.config.ts";
/**
* The three workspace Stryker configs (image-engine, media-engine, doc-engine)
* run with the package directory as cwd, so their project reader only ever sees
* `src/`. The two root configs run with the repository root as cwd, and Stryker
* does not read .gitignore: its ProjectReader crawls everything from cwd, then
* DisableTypeChecksPreprocessor reads every one of those files into memory at
* once. With no ignorePatterns that meant ~50k files and 3.3 GB, dominated by
* the gitignored docs build output, and both lanes died with a V8 heap OOM
* (exit 134) before testing a single mutant.
*
* These assertions keep the root lanes pointed at source. They are deliberately
* empirical: asserting the config contains a specific string would just restate
* the config.
*/
const root = path.resolve(import.meta.dirname, "../../..");
const ROOT_STRYKER_CONFIGS = ["stryker.api.config.json", "stryker.shared.config.json"];
const WORKSPACE_STRYKER_CONFIGS = [
"packages/image-engine/stryker.config.json",
"packages/media-engine/stryker.config.json",
"packages/doc-engine/stryker.config.json",
];
const ALL_STRYKER_CONFIGS = [...ROOT_STRYKER_CONFIGS, ...WORKSPACE_STRYKER_CONFIGS];
/** Mirrors ALWAYS_IGNORE in @stryker-mutator/core's ProjectReader. */
const ALWAYS_IGNORE = ["node_modules", ".git", ".next", ".nuxt", ".svelte-kit"];
/**
* Ceilings for what the root project readers may ingest. Today's scoped crawl
* is ~6.9k files / 234 MB, and the OOM was 50k files / 3.3 GB, so these sit
* well clear of normal growth while still catching a whole build tree leaking
* back in.
*/
const MAX_PROJECT_FILES = 12_000;
const MAX_PROJECT_BYTES = 400 * 1024 * 1024;
/**
* Ignore patterns are restricted to two shapes so this guard can reason about
* them exactly: a bare name (matches a path segment at any depth, e.g. "dist")
* or a root-anchored path (e.g. "/apps/api/data"). Globs are rejected rather
* than approximated.
*/
const SUPPORTED_PATTERN = /^\/?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/;
interface StrykerConfig {
ignorePatterns?: string[];
tempDirName?: string;
cleanTempDir?: string | boolean;
thresholds?: { break?: number | null };
}
/**
* Does a Vitest exclude glob swallow everything under `directory`? Only the two
* shapes the exclude list actually uses are handled: "
/**" and a leading
* "**\/" variant, with a single trailing "*" wildcard allowed on the directory
* segment.
*/
function excludesDirectory(patterns: string[], directory: string): boolean {
return patterns.some((pattern) => {
const glob = pattern.replace(/^\*\*\//, "");
if (!glob.endsWith("/**")) return false;
const segment = glob.slice(0, -"/**".length);
if (segment.includes("/")) return false;
return segment.endsWith("*")
? directory.startsWith(segment.slice(0, -1))
: directory === segment;
});
}
function readStrykerConfig(file: string): StrykerConfig {
return JSON.parse(readFileSync(path.join(root, file), "utf8")) as StrykerConfig;
}
function isIgnored(relativePath: string, patterns: string[]): boolean {
const segments = relativePath.split("/");
return patterns.some((pattern) => {
if (pattern.startsWith("/")) {
const anchored = pattern.slice(1);
return relativePath === anchored || relativePath.startsWith(`${anchored}/`);
}
return segments.includes(pattern);
});
}
function crawl(patterns: string[]): { files: number; bytes: number; heaviest: string[] } {
let files = 0;
let bytes = 0;
const byTopLevel = new Map();
const walk = (dir: string): void => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const absolute = path.join(dir, entry.name);
const relative = path.relative(root, absolute).split(path.sep).join("/");
if (isIgnored(relative, patterns)) continue;
if (entry.isDirectory()) {
walk(absolute);
} else if (entry.isFile()) {
files += 1;
const size = statSync(absolute).size;
bytes += size;
const top = relative.split("/").slice(0, 2).join("/");
byTopLevel.set(top, (byTopLevel.get(top) ?? 0) + size);
}
}
};
walk(root);
const heaviest = [...byTopLevel.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([name, size]) => `${(size / 1e6).toFixed(1)}MB ${name}`);
return { files, bytes, heaviest };
}
/**
* Directory-shaped .gitignore entries: everything git already treats as
* non-source. Anything git ignores is by definition not an input to a mutation
* run, so a new entry here that the Stryker configs do not also ignore is the
* next OOM waiting to happen.
*/
function gitignoredDirectories(): string[] {
const found = new Set();
for (const raw of readFileSync(path.join(root, ".gitignore"), "utf8").split("\n")) {
const line = raw.trim();
if (!line || line.startsWith("#") || line.startsWith("!")) continue;
const declaresDirectory = line.endsWith("/") || line.endsWith("/*");
const entry = line
.replace(/^\*\*\//, "")
.replace(/\/\*$/, "")
.replace(/\/$/, "")
.replace(/^\//, "");
if (!entry || entry.includes("*")) continue;
if (declaresDirectory || existsAsDirectory(entry)) found.add(entry);
}
return [...found].sort();
}
function existsAsDirectory(relativePath: string): boolean {
try {
return statSync(path.join(root, relativePath)).isDirectory();
} catch {
return false;
}
}
describe("root Stryker lanes see only source", () => {
it("declares ignore patterns on both root configs", () => {
for (const file of ROOT_STRYKER_CONFIGS) {
const patterns = readStrykerConfig(file).ignorePatterns;
expect(patterns, `${file} must declare ignorePatterns`).toBeDefined();
expect(patterns?.length, `${file} must declare ignorePatterns`).toBeGreaterThan(0);
}
});
it("keeps the two root configs on the same ignore list", () => {
const [api, shared] = ROOT_STRYKER_CONFIGS.map((file) =>
[...(readStrykerConfig(file).ignorePatterns ?? [])].sort(),
);
expect(api).toEqual(shared);
});
it("uses only pattern shapes this guard can evaluate", () => {
for (const file of ROOT_STRYKER_CONFIGS) {
for (const pattern of readStrykerConfig(file).ignorePatterns ?? []) {
expect(pattern, `${file} pattern ${pattern}`).toMatch(SUPPORTED_PATTERN);
}
}
});
it("ignores every directory git already ignores", () => {
const patterns = [
...ALWAYS_IGNORE,
...(readStrykerConfig(ROOT_STRYKER_CONFIGS[0]).ignorePatterns ?? []),
];
const escaped = gitignoredDirectories().filter((dir) => !isIgnored(dir, patterns));
expect(escaped).toEqual([]);
});
it("deletes its sandbox even when the run crashes", () => {
// Stryker's default cleanTempDir is `true`, which skips cleanup on error.
// A crashed lane then leaves a full copy of the project on disk.
for (const file of ALL_STRYKER_CONFIGS) {
expect(readStrykerConfig(file).cleanTempDir, `${file} cleanTempDir`).toBe("always");
}
});
it("keeps every Stryker sandbox out of Vitest's file discovery", () => {
const exclude = vitestConfig.test?.exclude ?? [];
for (const file of ALL_STRYKER_CONFIGS) {
// Stryker's own default when a config omits tempDirName.
const tempDirName = readStrykerConfig(file).tempDirName ?? ".stryker-tmp";
expect(
excludesDirectory(exclude, tempDirName),
`vitest.config.ts must exclude ${tempDirName} (from ${file}); a live sandbox otherwise turns into phantom "Cannot find module" failures`,
).toBe(true);
}
});
it("keeps the crawled project under the file and byte ceilings", () => {
const patterns = [
...ALWAYS_IGNORE,
...(readStrykerConfig(ROOT_STRYKER_CONFIGS[0]).ignorePatterns ?? []),
];
const { files, bytes, heaviest } = crawl(patterns);
const detail = `heaviest: ${heaviest.join(", ")}`;
expect(files, detail).toBeLessThan(MAX_PROJECT_FILES);
expect(bytes, detail).toBeLessThan(MAX_PROJECT_BYTES);
});
});