Files
fusion/packages/cli/src/__tests__/root-test-command.test.ts
Fusion ed7cb58d77 test(FN-4126): complete Step 5 — extend shard planner coverage
Fusion-Task-Id: FN-4126
Fusion-Task-Lineage: ed1e7e10-43d3-4347-933b-0dc9e8cd345a
2026-05-13 06:29:35 -07:00

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 },
]);
});
});