180 lines
6.8 KiB
TypeScript
180 lines
6.8 KiB
TypeScript
import { describe, expect, it } from "vitest";
|
|
import {
|
|
decideExecutionPlan,
|
|
normalizeForwardedArgs,
|
|
resolveAffectedPackages,
|
|
shouldForceFullSuite,
|
|
} from "../../../../scripts/test-changed.mjs";
|
|
import { computeSplitPlan, parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
|
|
|
|
describe("root test command changed-only planning", () => {
|
|
it("uses changed mode when package-only changes are detected", () => {
|
|
const packageMap = new Map([
|
|
["packages/core", "@fusion/core"],
|
|
["packages/engine", "@fusion/engine"],
|
|
]);
|
|
|
|
const plan = decideExecutionPlan({
|
|
forceFullSuite: false,
|
|
comparisonBase: "abc123",
|
|
changedFiles: ["packages/core/src/store.ts", "packages/engine/src/index.ts"],
|
|
packageNameByDir: packageMap,
|
|
});
|
|
|
|
expect(plan).toEqual({ mode: "changed", packages: ["@fusion/core", "@fusion/engine"] });
|
|
});
|
|
|
|
it("falls back to full suite when shared test infra changes", () => {
|
|
const plan = decideExecutionPlan({
|
|
forceFullSuite: false,
|
|
comparisonBase: "abc123",
|
|
changedFiles: ["scripts/test-with-lock.mjs"],
|
|
packageNameByDir: new Map([["packages/core", "@fusion/core"]]),
|
|
});
|
|
|
|
expect(plan).toEqual({ mode: "full", reason: "shared-infra-changed" });
|
|
});
|
|
|
|
it("falls back to full suite when comparison base cannot be resolved", () => {
|
|
const plan = decideExecutionPlan({
|
|
forceFullSuite: false,
|
|
comparisonBase: null,
|
|
changedFiles: null,
|
|
packageNameByDir: new Map(),
|
|
});
|
|
|
|
expect(plan).toEqual({ mode: "full", reason: "missing-comparison-base" });
|
|
});
|
|
|
|
it("treats unknown package directories as full-suite fallback", () => {
|
|
const resolved = resolveAffectedPackages(["packages/unknown/src/index.ts"], new Map());
|
|
expect(resolved).toBeNull();
|
|
});
|
|
|
|
it("marks root workflow/config changes as full-suite triggers", () => {
|
|
expect(shouldForceFullSuite([".github/workflows/ci.yml"])).toBe(true);
|
|
expect(shouldForceFullSuite(["package.json"])).toBe(true);
|
|
expect(shouldForceFullSuite(["packages/core/src/store.ts"])).toBe(false);
|
|
});
|
|
|
|
it("strips forwarded silent flags so package vitest scripts do not receive duplicates", () => {
|
|
expect(
|
|
normalizeForwardedArgs(["--full", "--silent", "--silent=passed-only", "--reporter=dot"]),
|
|
).toEqual(["--reporter=dot"]);
|
|
});
|
|
});
|
|
|
|
describe("CI shard test planner", () => {
|
|
it("parses valid shard args", () => {
|
|
expect(parseShardArgs(["--shard", "2", "--total", "3"], {} as NodeJS.ProcessEnv)).toEqual({
|
|
shard: 2,
|
|
total: 3,
|
|
});
|
|
});
|
|
|
|
it("rejects invalid shard args", () => {
|
|
expect(() => parseShardArgs(["--shard", "4", "--total", "3"], {} as NodeJS.ProcessEnv)).toThrow(
|
|
"Usage: node scripts/ci-test-shard.mjs --shard <1..N> --total <N>",
|
|
);
|
|
});
|
|
|
|
it("deterministically balances weighted packages across shards with virtual dashboard slices", () => {
|
|
const weightedPackages = [
|
|
{ name: "@fusion/dashboard", testFileCount: 505 },
|
|
{ name: "@fusion/engine", testFileCount: 90 },
|
|
{ name: "@fusion/core", testFileCount: 80 },
|
|
{ name: "@runfusion/fusion", testFileCount: 50 },
|
|
{ name: "@fusion/plugin-sdk", testFileCount: 30 },
|
|
];
|
|
|
|
const shardAssignments = planShardAssignments(weightedPackages, 3);
|
|
expect(selectShardPackages(weightedPackages, 1, 3)).toEqual(shardAssignments[0]);
|
|
expect(selectShardPackages(weightedPackages, 2, 3)).toEqual(shardAssignments[1]);
|
|
expect(selectShardPackages(weightedPackages, 3, 3)).toEqual(shardAssignments[2]);
|
|
|
|
const dashboardSlices = shardAssignments
|
|
.flat()
|
|
.filter((entry) => entry.name === "@fusion/dashboard" && entry.shardCount === 3);
|
|
expect(dashboardSlices).toHaveLength(3);
|
|
expect(dashboardSlices.map((entry) => entry.shardIndex).sort()).toEqual([1, 2, 3]);
|
|
|
|
const shardsContainingDashboard = shardAssignments
|
|
.map((entries, index) => ({ entries, index }))
|
|
.filter(({ entries }) => entries.some((entry) => entry.name === "@fusion/dashboard"))
|
|
.map(({ index }) => index);
|
|
expect(shardsContainingDashboard).toEqual([0, 1, 2]);
|
|
|
|
const computedSplitPlan = computeSplitPlan(weightedPackages, 3);
|
|
const byWeight = new Map(computedSplitPlan.map((entry) => [
|
|
`${entry.name}:${entry.shardIndex ?? 0}/${entry.shardCount ?? 0}`,
|
|
entry.weight,
|
|
]));
|
|
const shardWeights = shardAssignments.map((entries) =>
|
|
entries.reduce(
|
|
(sum, entry) =>
|
|
sum +
|
|
(byWeight.get(`${entry.name}:${entry.shardIndex ?? 0}/${entry.shardCount ?? 0}`) ?? 0),
|
|
0,
|
|
),
|
|
);
|
|
|
|
const totalWeight = weightedPackages.reduce((sum, pkg) => sum + pkg.testFileCount, 0);
|
|
const mean = totalWeight / 3;
|
|
expect(Math.max(...shardWeights)).toBeLessThanOrEqual(mean * 1.1);
|
|
expect(Math.min(...shardWeights)).toBeGreaterThanOrEqual(mean * 0.85);
|
|
});
|
|
|
|
it("leaves packages whole when no single package exceeds the split threshold", () => {
|
|
const weightedPackages = [
|
|
{ name: "@fusion/engine", testFileCount: 40 },
|
|
{ name: "@fusion/core", testFileCount: 40 },
|
|
{ name: "@runfusion/fusion", testFileCount: 40 },
|
|
];
|
|
|
|
const shardAssignments = planShardAssignments(weightedPackages, 3, { threshold: 2 });
|
|
expect(shardAssignments.flat().every((entry) => entry.shardCount === undefined)).toBe(true);
|
|
});
|
|
|
|
it("splits never co-locate two slices of the same package on the same shard", () => {
|
|
const weightedPackages = [
|
|
{ name: "@fusion/dashboard", testFileCount: 505 },
|
|
{ name: "@fusion/engine", testFileCount: 10 },
|
|
{ name: "@fusion/core", testFileCount: 10 },
|
|
];
|
|
|
|
const shardAssignments = planShardAssignments(weightedPackages, 3);
|
|
for (const entries of shardAssignments) {
|
|
const dashboardEntries = entries.filter((entry) => entry.name === "@fusion/dashboard");
|
|
expect(dashboardEntries).toHaveLength(1);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe("computeSplitPlan", () => {
|
|
it("splits oversized package into k slices where k is capped by total", () => {
|
|
const result = computeSplitPlan([{ name: "big", testFileCount: 100 }], 3);
|
|
expect(result).toEqual([
|
|
{ name: "big", weight: 34, shardIndex: 1, shardCount: 3 },
|
|
{ name: "big", weight: 34, shardIndex: 2, shardCount: 3 },
|
|
{ name: "big", weight: 34, shardIndex: 3, shardCount: 3 },
|
|
]);
|
|
});
|
|
|
|
it("returns rewritten list with whole and virtual entries", () => {
|
|
const result = computeSplitPlan(
|
|
[
|
|
{ name: "@fusion/dashboard", testFileCount: 505 },
|
|
{ name: "@fusion/core", testFileCount: 60 },
|
|
],
|
|
3,
|
|
);
|
|
|
|
expect(result).toEqual([
|
|
{ name: "@fusion/dashboard", weight: 169, shardIndex: 1, shardCount: 3 },
|
|
{ name: "@fusion/dashboard", weight: 169, shardIndex: 2, shardCount: 3 },
|
|
{ name: "@fusion/dashboard", weight: 169, shardIndex: 3, shardCount: 3 },
|
|
{ name: "@fusion/core", weight: 60 },
|
|
]);
|
|
});
|
|
});
|