Files
kandev/apps/web/scripts/vitest-project-selection.ts
T
2026-09-13 12:06:08 +01:00

181 lines
6.1 KiB
TypeScript

import { readdirSync } from "node:fs";
import path from "node:path";
export const PROJECT_NAMES = ["node", "browser", "browser-locales"] as const;
export type ProjectName = (typeof PROJECT_NAMES)[number];
// Keep this aligned with Vitest's default test include. A single canonical
// pattern prevents the project selector from silently dropping cjs, cts, mjs,
// mts, and their JSX variants.
export const TEST_FILE_GLOBS = ["**/*.{test,spec}.?(c|m)[jt]s?(x)"] as const;
export const BASE_TEST_EXCLUDES = [
"e2e/**/*.spec.ts",
"e2e/fixtures/**",
"e2e/pages/**",
"node_modules/**",
] as const;
const TEST_FILE_NAME = /\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/;
const SKIPPED_DIRECTORIES = new Set([".git", "dist", "node_modules"]);
/**
* These test files were reviewed as safe for the Node setup. New tests stay
* on the full browser and locale setup until their imports are reviewed and
* they are added here explicitly.
*/
export const REVIEWED_NODE_TEST_FILES: readonly string[] = [
"scripts/check-i18n-keys.test.ts",
"scripts/check-no-em-dash-ui.test.ts",
"scripts/convert-zh-cn-to-zh-hant.test.mjs",
"scripts/fix-trans-indices.test.ts",
"scripts/generate-licenses-fallback.test.ts",
"scripts/generate-licenses.test.ts",
"scripts/i18n-parity.test.ts",
"scripts/i18n-sweep.test.ts",
"scripts/lib/changed-files.test.ts",
"scripts/lib/diff-ranges.test.ts",
"scripts/lib/e2e-sleep-ratchet.test.ts",
"scripts/lib/e2e-sleep-wiring.test.ts",
"scripts/lib/e2e-wait-inventory.test.ts",
"scripts/lib/eslint-i18n-config.test.ts",
"scripts/lib/git-base.test.ts",
"scripts/lib/guard-allowlist.test.ts",
"scripts/lib/nonjsx-copy.test.ts",
"scripts/lib/nonjsx-scope.test.ts",
"scripts/lib/removed-literals.test.ts",
"scripts/vitest-project-selection.test.ts",
"scripts/vitest-worker-budget.test.ts",
];
/** These browser tests were reviewed as not requiring the locale catalogs. */
export const REVIEWED_BROWSER_TEST_FILES: readonly string[] = [
"lib/test-support/happy-dom-network.test.ts",
"vitest-environment.test.tsx",
];
export interface ProjectFiles {
node: string[];
browser: string[];
"browser-locales": string[];
}
export interface VitestProjectDefinition {
include: readonly string[];
exclude: readonly string[];
}
export interface ProjectSelection {
projectFiles: ProjectFiles;
projects: Record<ProjectName, VitestProjectDefinition>;
}
function toRelativeFile(root: string, file: string): string {
return path.relative(root, file).split(path.sep).join("/");
}
function matchesExcludePattern(relativeFile: string, pattern: string): boolean {
if (pattern === "e2e/**/*.spec.ts") {
return relativeFile.startsWith("e2e/") && relativeFile.endsWith(".spec.ts");
}
if (pattern === "e2e/fixtures/**") return relativeFile.startsWith("e2e/fixtures/");
if (pattern === "e2e/pages/**") return relativeFile.startsWith("e2e/pages/");
if (pattern === "node_modules/**") return relativeFile.startsWith("node_modules/");
return relativeFile === pattern;
}
function isExcluded(relativeFile: string): boolean {
return BASE_TEST_EXCLUDES.some((pattern) => matchesExcludePattern(relativeFile, pattern));
}
function walkTestFiles(root: string, directory: string, files: string[]): void {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
if (entry.isDirectory() && !SKIPPED_DIRECTORIES.has(entry.name)) {
walkTestFiles(root, path.join(directory, entry.name), files);
continue;
}
if (!entry.isFile() || !TEST_FILE_NAME.test(entry.name)) continue;
const relativeFile = toRelativeFile(root, path.join(directory, entry.name));
if (!isExcluded(relativeFile)) files.push(relativeFile);
}
}
export function discoverTestFiles(root: string): string[] {
const files: string[] = [];
walkTestFiles(root, root, files);
return files.sort();
}
export function matchesTestFileGlob(relativeFile: string): boolean {
return TEST_FILE_NAME.test(relativeFile);
}
export function buildProjectFiles(root: string): ProjectFiles {
const files = discoverTestFiles(root);
const nodeSet = new Set(files.filter((file) => REVIEWED_NODE_TEST_FILES.includes(file)));
const browserSet = new Set(files.filter((file) => REVIEWED_BROWSER_TEST_FILES.includes(file)));
return {
node: [...nodeSet].sort(),
browser: [...browserSet].sort(),
"browser-locales": files.filter((file) => !nodeSet.has(file) && !browserSet.has(file)),
};
}
export function buildProjectSelection(root: string): ProjectSelection {
const projectFiles = buildProjectFiles(root);
return {
projectFiles,
projects: {
node: {
include: projectFiles.node,
exclude: [...BASE_TEST_EXCLUDES],
},
browser: {
include: [...TEST_FILE_GLOBS],
exclude: [...BASE_TEST_EXCLUDES, ...projectFiles.node, ...projectFiles["browser-locales"]],
},
"browser-locales": {
include: projectFiles["browser-locales"],
exclude: [...BASE_TEST_EXCLUDES],
},
},
};
}
function matchesIncludePattern(relativeFile: string, pattern: string): boolean {
if (pattern === TEST_FILE_GLOBS[0]) return matchesTestFileGlob(relativeFile);
return relativeFile === pattern;
}
export function matchesConfiguredProject(
relativeFile: string,
project: ProjectName,
selection: ProjectSelection,
): boolean {
const definition = selection.projects[project];
return (
definition.include.some((pattern) => matchesIncludePattern(relativeFile, pattern)) &&
!definition.exclude.some((pattern) => matchesExcludePattern(relativeFile, pattern))
);
}
const DEFAULT_ROOT = path.resolve(import.meta.dirname, "..");
export const defaultProjectSelection = buildProjectSelection(DEFAULT_ROOT);
export const projectFiles = defaultProjectSelection.projectFiles;
export const projectDefinitions = defaultProjectSelection.projects;
export function matchingProjectsForSelection(
relativeFile: string,
selection: ProjectSelection,
): ProjectName[] {
return PROJECT_NAMES.filter((project) =>
matchesConfiguredProject(relativeFile, project, selection),
);
}
export function matchingProjects(relativeFile: string): ProjectName[] {
return matchingProjectsForSelection(relativeFile, defaultProjectSelection);
}