test(FN-4126): complete Step 5 — extend shard planner coverage
Fusion-Task-Id: FN-4126 Fusion-Task-Lineage: ed1e7e10-43d3-4347-933b-0dc9e8cd345a
This commit is contained in:
@@ -5,7 +5,7 @@ import {
|
||||
resolveAffectedPackages,
|
||||
shouldForceFullSuite,
|
||||
} from "../../../../scripts/test-changed.mjs";
|
||||
import { parseShardArgs, planShardAssignments, selectShardPackages, expandVirtualPackages } from "../../../../scripts/ci-test-shard.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", () => {
|
||||
@@ -78,115 +78,102 @@ describe("CI shard test planner", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("deterministically balances weighted packages across shards", () => {
|
||||
it("deterministically balances weighted packages across shards with virtual dashboard slices", () => {
|
||||
const weightedPackages = [
|
||||
{ name: "@fusion/dashboard", testFileCount: 140 },
|
||||
{ name: "@fusion/engine", testFileCount: 120 },
|
||||
{ name: "@fusion/core", testFileCount: 60 },
|
||||
{ name: "@runfusion/fusion", testFileCount: 40 },
|
||||
{ name: "@fusion/plugin-sdk", testFileCount: 18 },
|
||||
{ name: "@fusion/mobile", testFileCount: 12 },
|
||||
{ name: "@fusion/desktop", testFileCount: 8 },
|
||||
{ name: "@fusion/dashboard-utils", testFileCount: 4 },
|
||||
{ name: "@fusion/no-tests-yet", testFileCount: 0 },
|
||||
{ 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 },
|
||||
];
|
||||
|
||||
// Dashboard (140) exceeds avg threshold (ceil(402/3)=134) so it gets split
|
||||
// into 2 virtual entries of 70 each, dispatched with vitest --shard.
|
||||
const shardAssignments = planShardAssignments(weightedPackages, 3);
|
||||
|
||||
// Verify selectShardPackages returns matching slices
|
||||
expect(selectShardPackages(weightedPackages, 1, 3)).toEqual(shardAssignments[0]);
|
||||
expect(selectShardPackages(weightedPackages, 2, 3)).toEqual(shardAssignments[1]);
|
||||
expect(selectShardPackages(weightedPackages, 3, 3)).toEqual(shardAssignments[2]);
|
||||
|
||||
// Verify shard weights are balanced within 15% of mean
|
||||
const shardWeights = shardAssignments.map((shardEntries) =>
|
||||
shardEntries.reduce((sum, entry) => sum + (entry as { weight: number }).weight, 0),
|
||||
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.15);
|
||||
expect(Math.max(...shardWeights)).toBeLessThanOrEqual(mean * 1.1);
|
||||
expect(Math.min(...shardWeights)).toBeGreaterThanOrEqual(mean * 0.85);
|
||||
});
|
||||
|
||||
// Verify dashboard was split across 2 shards and engine is on a different shard
|
||||
const dashboardShards = shardAssignments.filter((shard) =>
|
||||
shard.some((e) => (e as { name: string }).name === "@fusion/dashboard"),
|
||||
);
|
||||
const engineShard = shardAssignments.findIndex((shard) =>
|
||||
shard.some((e) => (e as { name: string }).name === "@fusion/engine"),
|
||||
);
|
||||
expect(dashboardShards.length).toBe(2); // split into 2 virtual entries
|
||||
expect(engineShard).toBeGreaterThanOrEqual(0);
|
||||
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 },
|
||||
];
|
||||
|
||||
// Verify virtual entries carry vitest shard metadata
|
||||
const virtualEntries = shardAssignments
|
||||
.flat()
|
||||
.filter((e) => (e as { vitestShardCount?: number }).vitestShardCount);
|
||||
expect(virtualEntries.length).toBe(2);
|
||||
for (const entry of virtualEntries) {
|
||||
const e = entry as { name: string; vitestShardIndex: number; vitestShardCount: number };
|
||||
expect(e.name).toBe("@fusion/dashboard");
|
||||
expect(e.vitestShardCount).toBe(2);
|
||||
expect(e.vitestShardIndex).toBeGreaterThanOrEqual(1);
|
||||
expect(e.vitestShardIndex).toBeLessThanOrEqual(2);
|
||||
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("expandVirtualPackages", () => {
|
||||
it("passes through packages below threshold as plain entries", () => {
|
||||
const pkgs = [
|
||||
{ name: "small", testFileCount: 10 },
|
||||
{ name: "tiny", testFileCount: 3 },
|
||||
];
|
||||
const result = expandVirtualPackages(pkgs, 50);
|
||||
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: "small", weight: 10 },
|
||||
{ name: "tiny", weight: 3 },
|
||||
{ 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("splits oversized package into evenly-weighted virtual entries", () => {
|
||||
const pkgs = [{ name: "big", testFileCount: 100 }];
|
||||
const result = expandVirtualPackages(pkgs, 30);
|
||||
// ceil(100/30) = 4 entries, floor(100/4)=25, remainder=0
|
||||
expect(result).toHaveLength(4);
|
||||
for (const entry of result) {
|
||||
expect(entry.name).toBe("big");
|
||||
expect(entry.weight).toBe(25);
|
||||
expect(entry.vitestShardCount).toBe(4);
|
||||
}
|
||||
expect(result.map((e) => e.vitestShardIndex)).toEqual([1, 2, 3, 4]);
|
||||
});
|
||||
it("returns rewritten list with whole and virtual entries", () => {
|
||||
const result = computeSplitPlan(
|
||||
[
|
||||
{ name: "@fusion/dashboard", testFileCount: 505 },
|
||||
{ name: "@fusion/core", testFileCount: 60 },
|
||||
],
|
||||
3,
|
||||
);
|
||||
|
||||
it("distributes remainder to first entries when weight is not evenly divisible", () => {
|
||||
const pkgs = [{ name: "odd", testFileCount: 10 }];
|
||||
const result = expandVirtualPackages(pkgs, 4);
|
||||
// ceil(10/4) = 3 entries, floor(10/3)=3, remainder=1
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((e) => e.weight)).toEqual([4, 3, 3]);
|
||||
expect(result.map((e) => e.vitestShardIndex)).toEqual([1, 2, 3]);
|
||||
expect(result.every((e) => e.vitestShardCount === 3)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns plain entry when testFileCount equals threshold exactly", () => {
|
||||
const pkgs = [{ name: "exact", testFileCount: 50 }];
|
||||
const result = expandVirtualPackages(pkgs, 50);
|
||||
expect(result).toEqual([{ name: "exact", weight: 50 }]);
|
||||
});
|
||||
|
||||
it("handles zero testFileCount without splitting", () => {
|
||||
const pkgs = [{ name: "empty", testFileCount: 0 }];
|
||||
const result = expandVirtualPackages(pkgs, 10);
|
||||
expect(result).toEqual([{ name: "empty", weight: 0 }]);
|
||||
});
|
||||
|
||||
it("defaults to no splitting when threshold is Infinity", () => {
|
||||
const pkgs = [{ name: "huge", testFileCount: 9999 }];
|
||||
const result = expandVirtualPackages(pkgs);
|
||||
expect(result).toEqual([{ name: "huge", weight: 9999 }]);
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user