mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
A release-readiness QA pass over the whole product. The commits split into defects a user would hit and gates that were reporting green while measuring nothing. ## Fixes that change behaviour Rate limiting was bypassable on every install: TRUST_PROXY defaulted to true, so request.ip came from a client-set header and a forged X-Forwarded-For got past the login limiter. The default is now a private-network trust list. A transient Postgres outage stranded in-flight jobs, leaving finished output on disk with no row pointing at it. A reconciler now resolves those rows and adopts the bytes rather than dropping the work. A Redis connection that moved to a new address wedged every read-blocked consumer, so completions stopped signalling while health still answered 200. Socket timeouts plus subscriber pings recover it. Installing more than one AI bundle left the shared venv multi-versioned and silently broke three tools. The installer now reconciles distributions to one version each. Converting an image to JXL at quality 1 through 4 returned a 500, because libjxl 0.7 rejects the distance those values compute. The quality is floored at what the encoder honours. A missing ffmpeg was also reported to the user as a corrupt upload; it now says the engine is unavailable. RAW uploads reached an unpatched LibRaw on arm64, so it is built from source at 0.22.2, and the release scan was split so it can fail on an unfixed critical instead of hiding it behind ignore-unfixed. ## Gates that could not fail Two mutation lanes ran zero mutants because Stryker crawled the gitignored docs build; coverage discarded its whole report on any failing test; the lint gate skipped root tests, scripts, and two workspaces; and several generated matrices counted a host missing ffmpeg as a passing tool. Each now measures what it claims. Full evidence and the outstanding release items are tracked locally and are not part of this branch.
218 lines
8.1 KiB
TypeScript
218 lines
8.1 KiB
TypeScript
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: "<dir>/**" 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<string, number>();
|
|
|
|
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<string>();
|
|
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);
|
|
});
|
|
});
|