feat(FN-4043): implement weighted CI shard balancing
Implements weighted shard planning for the CI test sharding script, with deterministic test coverage and documentation updates to `contributing.md`. Fusion-Task-Id: FN-4043
This commit is contained in:
@@ -76,7 +76,7 @@ GitHub Actions now runs deterministic test sharding via `pnpm test:ci:shard --sh
|
|||||||
- `pnpm test:full` remains the canonical workspace quality gate; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`.
|
- `pnpm test:full` remains the canonical workspace quality gate; dashboard exhaustive coverage is explicit via `pnpm --filter @fusion/dashboard test:deep`.
|
||||||
- `pnpm verify:workspace` remains the canonical local lint -> test -> build gate.
|
- `pnpm verify:workspace` remains the canonical local lint -> test -> build gate.
|
||||||
|
|
||||||
`test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that partitions workspace packages with `test` scripts by shard index modulo total shard count so coverage is deterministic and reproducible.
|
`test:ci:shard` is a CI-focused entrypoint (`scripts/ci-test-shard.mjs`) that deterministically balances workspace packages with `test` scripts by counting package-local `**/__tests__/**/*.test.{ts,tsx,mjs}` files, then assigning packages to shards with a largest-fit-decreasing planner (heaviest first, lexical package-name tie-break, then lowest-current-weight shard with lower-index tie-break) so coverage stays reproducible while spreading shard load more evenly.
|
||||||
|
|
||||||
`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter <pkg> test`). It automatically falls back to the full suite when the run is forced (CI / `--full`), the git comparison base or diff cannot be resolved, no changes are detected, shared/root test infrastructure changes, or changed workspace paths cannot be resolved to a workspace package (fail-safe coverage behavior).
|
`pnpm test` now uses a changed-only entrypoint (`scripts/test-changed.mjs`) for faster local iteration. It resolves the comparison base from `.changeset/config.json` (`baseBranch`) and runs only affected workspaces from `pnpm-workspace.yaml` (both `packages/*` and `plugins/**`) using safe package-first filtering (`pnpm --filter <pkg> test`). It automatically falls back to the full suite when the run is forced (CI / `--full`), the git comparison base or diff cannot be resolved, no changes are detected, shared/root test infrastructure changes, or changed workspace paths cannot be resolved to a workspace package (fail-safe coverage behavior).
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
resolveAffectedPackages,
|
resolveAffectedPackages,
|
||||||
shouldForceFullSuite,
|
shouldForceFullSuite,
|
||||||
} from "../../../../scripts/test-changed.mjs";
|
} from "../../../../scripts/test-changed.mjs";
|
||||||
import { parseShardArgs, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
|
import { parseShardArgs, planShardAssignments, selectShardPackages } from "../../../../scripts/ci-test-shard.mjs";
|
||||||
|
|
||||||
describe("root test command changed-only planning", () => {
|
describe("root test command changed-only planning", () => {
|
||||||
it("uses changed mode when package-only changes are detected", () => {
|
it("uses changed mode when package-only changes are detected", () => {
|
||||||
@@ -78,10 +78,43 @@ describe("CI shard test planner", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("selects deterministic package partitions", () => {
|
it("deterministically balances weighted packages across shards", () => {
|
||||||
const packages = ["a", "b", "c", "d", "e"];
|
const weightedPackages = [
|
||||||
expect(selectShardPackages(packages, 1, 3)).toEqual(["a", "d"]);
|
{ name: "@fusion/dashboard", testFileCount: 140 },
|
||||||
expect(selectShardPackages(packages, 2, 3)).toEqual(["b", "e"]);
|
{ name: "@fusion/engine", testFileCount: 120 },
|
||||||
expect(selectShardPackages(packages, 3, 3)).toEqual(["c"]);
|
{ 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 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const shardAssignments = planShardAssignments(weightedPackages, 3);
|
||||||
|
expect(shardAssignments).toEqual([
|
||||||
|
["@fusion/dashboard"],
|
||||||
|
["@fusion/engine", "@fusion/desktop", "@fusion/dashboard-utils"],
|
||||||
|
["@fusion/core", "@runfusion/fusion", "@fusion/plugin-sdk", "@fusion/mobile", "@fusion/no-tests-yet"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
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 weightsByName = new Map(weightedPackages.map((pkg) => [pkg.name, pkg.testFileCount]));
|
||||||
|
const shardWeights = shardAssignments.map((shardPackages) =>
|
||||||
|
shardPackages.reduce((sum, pkgName) => sum + (weightsByName.get(pkgName) ?? 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.min(...shardWeights)).toBeGreaterThanOrEqual(mean * 0.85);
|
||||||
|
|
||||||
|
const dashboardShard = shardAssignments.findIndex((pkgs) => pkgs.includes("@fusion/dashboard"));
|
||||||
|
const engineShard = shardAssignments.findIndex((pkgs) => pkgs.includes("@fusion/engine"));
|
||||||
|
expect(dashboardShard).not.toBe(engineShard);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { spawnSync } from "node:child_process";
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { globSync } from "node:fs";
|
||||||
import { cpus } from "node:os";
|
import { cpus } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -55,14 +56,54 @@ export function parseShardArgs(argv = process.argv.slice(2), env = process.env)
|
|||||||
return { shard, total };
|
return { shard, total };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function countPackageTestFiles(packageDir, { projectRoot = process.cwd() } = {}) {
|
||||||
|
const packageRoot = path.join(projectRoot, packageDir);
|
||||||
|
return globSync("**/__tests__/**/*.test.{ts,tsx,mjs}", {
|
||||||
|
cwd: packageRoot,
|
||||||
|
nodir: true,
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function planShardAssignments(packages, total) {
|
||||||
|
const shardAssignments = Array.from({ length: total }, () => []);
|
||||||
|
const shardWeights = Array.from({ length: total }, () => 0);
|
||||||
|
const normalized = packages
|
||||||
|
.map((pkg) => ({
|
||||||
|
name: pkg.name,
|
||||||
|
weight: pkg.testFileCount,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => {
|
||||||
|
if (b.weight !== a.weight) return b.weight - a.weight;
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const pkg of normalized) {
|
||||||
|
let targetIndex = 0;
|
||||||
|
for (let index = 1; index < total; index += 1) {
|
||||||
|
if (shardWeights[index] < shardWeights[targetIndex]) {
|
||||||
|
targetIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
shardAssignments[targetIndex].push(pkg.name);
|
||||||
|
shardWeights[targetIndex] += pkg.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
return shardAssignments;
|
||||||
|
}
|
||||||
|
|
||||||
export function selectShardPackages(packages, shard, total) {
|
export function selectShardPackages(packages, shard, total) {
|
||||||
return packages.filter((_, index) => index % total === shard - 1);
|
return planShardAssignments(packages, total)[shard - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listWorkspaceTestPackages({ projectRoot = process.cwd() } = {}) {
|
export function listWorkspaceTestPackages({ projectRoot = process.cwd() } = {}) {
|
||||||
return listWorkspacePackageInfos({ projectRoot })
|
return listWorkspacePackageInfos({ projectRoot })
|
||||||
.filter((workspacePackage) => workspacePackage.hasTestScript)
|
.filter((workspacePackage) => workspacePackage.hasTestScript)
|
||||||
.map((workspacePackage) => workspacePackage.name);
|
.map((workspacePackage) => ({
|
||||||
|
name: workspacePackage.name,
|
||||||
|
dir: workspacePackage.dir,
|
||||||
|
testFileCount: countPackageTestFiles(workspacePackage.dir, { projectRoot }),
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function main(argv = process.argv.slice(2), env = process.env) {
|
export function main(argv = process.argv.slice(2), env = process.env) {
|
||||||
|
|||||||
Reference in New Issue
Block a user