Files
metabase/.github/scripts/create-test-plan.ts

146 lines
5.1 KiB
TypeScript

// I/O entrypoint: gather inputs (env vars, the test-file lists, the cruise graph on request),
// hand them to createTestPlan, which does the computing, and publish its small stats output.
import { execFileSync, spawnSync } from "node:child_process";
import { appendFileSync, existsSync, readFileSync } from "node:fs";
import micromatch from "micromatch";
import { MAIN_APP_STORY_GLOBS } from "../../.storybook/story-files.cjs";
import { elements, rules } from "../../frontend/lint/module-boundaries.mjs";
import { type FileDependency, parseCruiseModules } from "./affected-modules";
import { createTestPlan } from "./affected-tests";
import { listSpecFiles } from "./e2e-spec-globs.mjs";
// The specs `bun run test-unit` runs.
const UNIT_ROOTS = ["."];
const UNIT_GLOBS = [
"**/*.unit.spec.{js,jsx,ts,tsx}",
"!.github/**", // the ci-scripts project, which test-unit ignores
"!release/**", // has its own jest config
"!enterprise/frontend/src/custom-viz/**", // modulePathIgnorePatterns in jest.config.js
"!**/*.leak.unit.spec.{js,jsx,ts,tsx}", // run only by jest.memory.conf.js
];
const FRONTEND_ROOTS = ["frontend", "enterprise/frontend"];
const LOKI_PREVIEW_FILE = ".storybook/preview.tsx";
// `git ls-files -- frontend enterprise/frontend` already prints just over a
// megabyte of paths, and node's default maxBuffer is exactly 1 MiB: past that
// the spawn dies with ENOBUFS. Give the listings room to grow.
const LS_FILES_MAX_BUFFER = 64 * 1024 * 1024;
// Returns the tracked files under `roots` that match `globs`. The `dot: true`
// option means files inside dot-directories such as `.storybook` are included.
function listFiles(roots: string[], globs: string[]): string[] {
const tracked = execFileSync("git", ["ls-files", "-z", "--", ...roots], {
encoding: "utf8",
maxBuffer: LS_FILES_MAX_BUFFER,
})
.split("\0")
.filter(Boolean);
return micromatch(tracked, globs, { dot: true });
}
// dorny outputs comma-separated lists; CHANGED_FILES is `all_changed_files`.
const csvToList = (csv: string | undefined) =>
(csv ?? "")
.split(",")
.map((s) => s.trim())
.filter(Boolean);
// Runs dependency-cruiser over the frontend sources and the Storybook preview and parses its edges.
// Null falls back to the rules graph, so a failed cruise never breaks the plan.
function loadFileDependencies(): FileDependency[] | null {
const output = "dependency-graph.json";
process.stderr.write("Building usage graph with dependency-cruiser.\n");
try {
// stdout is the plan JSON, so the cruise's own output goes to stderr.
const result = spawnSync(
"bunx",
[
"depcruise",
"frontend/src",
"enterprise/frontend/src",
LOKI_PREVIEW_FILE,
"--config",
".dependency-cruiser.cjs",
"--output-type",
"json",
"--output-to",
output,
],
{ stdio: ["ignore", 2, 2] },
);
if (result.error) {
throw result.error;
}
if (result.status !== 0) {
throw new Error(`depcruise exited with status ${result.status}`);
}
const { modules } = JSON.parse(readFileSync(output, "utf8"));
return parseCruiseModules(modules);
} catch (error) {
process.stderr.write(
`Failed to build usage graph; falling back to rules graph: ${error}\n`,
);
return null;
}
}
// Reads the nightly coverage manifest (E2E_SPEC_MANIFEST): { builtAt, specs:
// { spec -> files } }. Returns the specs map, or null so e2e falls back to a
// full run when the manifest is missing/unparseable.
function readE2eSpecFiles(): Record<string, string[]> | null {
const path = process.env.E2E_SPEC_MANIFEST;
if (path && existsSync(path)) {
try {
const { specs } = JSON.parse(readFileSync(path, "utf8"));
if (specs && typeof specs === "object") {
process.stderr.write(`Using e2e coverage manifest from ${path}.\n`);
return specs;
}
} catch (error) {
process.stderr.write(
`Failed to read ${path}; e2e will run in full: ${error}\n`,
);
}
} else {
process.stderr.write("No E2E_SPEC_MANIFEST found; e2e will run in full.\n");
}
return null;
}
const testPlan = createTestPlan({
elements,
rules,
changedFiles: csvToList(process.env.CHANGED_FILES),
loadFileDependencies,
testFilesBySuite: {
unit: listFiles(UNIT_ROOTS, UNIT_GLOBS),
loki: listFiles(FRONTEND_ROOTS, MAIN_APP_STORY_GLOBS),
e2e: listSpecFiles(),
},
lokiPreviewFile: LOKI_PREVIEW_FILE,
e2eSpecFiles: readE2eSpecFiles(),
unitInfraTouched: process.env.UNIT_INFRA_TOUCHED === "true",
lokiInfraTouched: process.env.LOKI_INFRA_TOUCHED === "true",
e2eInfraTouched: process.env.E2E_INFRA_TOUCHED === "true",
sharedSourcesTouched: process.env.SHARED_SOURCES_TOUCHED === "true",
feFilesChanged: csvToList(process.env.FE_CHANGED_FILES).length,
beFilesChanged: csvToList(process.env.BE_CHANGED_FILES).length,
feFilesTotal: listFiles(FRONTEND_ROOTS, ["**"]).length,
beFilesTotal: listFiles(["src", "enterprise/backend"], ["**"]).length,
});
process.stdout.write(JSON.stringify(testPlan) + "\n");
if (process.env.GITHUB_OUTPUT) {
appendFileSync(
process.env.GITHUB_OUTPUT,
`stats=${JSON.stringify(testPlan.stats)}\n`,
);
}