Files
scriptc/scripts/ci-test-plan.mjs
T
Chris Tate bbd24beba3 Split npm-static.test.ts across the Linux shards by measured test cost
npm-static.test.ts ran whole on one shard as an isolated suite: 406 s plain and
519 s sanitized in the cost table, and 672 s on san 2/20 in run 38835202089,
the second-longest job of that run.

skipUnownedTest (tests/harness/shard.ts), called from the suite's beforeEach,
balances the suite's tests across the shards of SCRIPTC_TEST_SHARD by their
measured milliseconds (tests/harness/npm-static-costs.json; an unmeasured test
weighs as much as the median) and skips the tests this shard does not own.
Repeated test.each titles get an occurrence suffix as their key. Without a
shard spec every test runs.

The CI plan runs the suite on every shard in its own vitest process
(caseShardedSideFiles) instead of on one owner. At 20 shards the largest
shares are 113 and 106 s plain and 181 and 137 s sanitized (the two published
Effect generator tests), and about 10 s elsewhere, plus about 11 s per shard to
collect the file. scripts/ci-test-costs.mjs also refreshes the per-test table
and drops side-queue files that no longer run whole.
2026-10-15 22:43:42 -05:00

193 lines
7.6 KiB
JavaScript

import { readFileSync } from "node:fs";
// Case-sharded harnesses run in every job without Vitest file sharding.
export const effectFiles = ["tests/harness/effect.test.ts", "tests/harness/effect4.test.ts"];
export const corpusFiles = [
"tests/harness/differential.test.ts",
"tests/harness/llvm-differential.test.ts",
"tests/harness/npm.test.ts",
"tests/harness/server.test.ts",
"tests/harness/test262.test.ts",
"tests/harness/three.test.ts",
];
const coverageFile = "tests/harness/coverage.test.ts";
const cacheFile = "packages/compiler/src/backend/native-toolchain.test.ts";
// Expensive integration suites run in their own vitest process, serially
// beside the corpus, avoiding worker contention between large compilations.
export const isolatedFiles = [
["self-hosting serialization", "tests/harness/self-hosting-serialization.test.ts"],
["self-hosting", "tests/harness/self-hosting.test.ts"],
["self-hosting LLVM emitter", "tests/harness/self-hosting-llvm-emitter.test.ts"],
["fetch", "tests/harness/fetch.test.ts"],
["surface manifest", "tests/harness/surface-manifest.test.ts"],
];
// Suites that split their own tests across the shards (SCRIPTC_TEST_SHARD,
// skipUnownedTest in tests/harness/shard.ts): every shard runs its share in
// its own vitest process.
export const caseShardedSideFiles = [["npm static", "tests/harness/npm-static.test.ts"]];
export const fileExclusions = [
...corpusFiles,
...effectFiles,
coverageFile,
cacheFile,
...isolatedFiles.map(([, file]) => file),
...caseShardedSideFiles.map(([, file]) => file),
"tests/harness/self-hosting-native-driver.test.ts",
];
// Milliseconds each side-queue file takes on a CI runner, per flavor, from the
// logs of green main runs (scripts/ci-test-costs.mjs refreshes it).
export const testCosts = JSON.parse(
readFileSync(new URL("./ci-test-costs.json", import.meta.url), "utf8"),
);
// The shard (1-based) of each measured side-queue file and isolated suite:
// longest first to the shard with the least work so far, counting about a
// second per file for vitest to import it. Ties go to the highest shard, so the
// longest files start from the last shard while the cost-balanced corpus and
// three.js cases (tests/harness/shard.ts) place their longest from the first.
// An isolated suite without a measurement weighs as much as the median
// measured file. Files without a measurement are left to vitest's --shard,
// which splits them by count.
export function sideOwners(flavor, count) {
const costs = testCosts[flavor];
const known = Object.values(costs).sort((a, b) => a - b);
const median = known.length === 0 ? 0 : known[Math.floor(known.length / 2)];
const weight = (file) => (costs[file] ?? median) + 1000;
const files = [...new Set([...Object.keys(costs), ...isolatedFiles.map(([, file]) => file)])];
files.sort((a, b) => weight(b) - weight(a) || (a < b ? -1 : a > b ? 1 : 0));
const loads = new Array(count).fill(0);
const owners = new Map();
for (const file of files) {
let lightest = count - 1;
for (let shard = count - 2; shard >= 0; shard--)
if (loads[shard] < loads[lightest]) lightest = shard;
loads[lightest] += weight(file);
owners.set(file, lightest + 1);
}
return owners;
}
// Pull requests run the full suite on Linux. Their macOS lane keeps the
// darwin clang/libc/kqueue oracles: the Node differential corpus, plus every
// file whose assertions run only on darwin or take darwin-specific branches
// (including the Mach-O library localization arm and the arm64 runtime
// string oracle).
// Pushes to main and the nightly schedule still run the full suite on macOS.
export const darwinCorpusFiles = ["tests/harness/differential.test.ts"];
export const darwinFiles = [
"packages/cli/test/bootstrap.test.ts",
"packages/cli/test/native-link-info.test.ts",
"packages/cli/test/native-output.test.ts",
"packages/cli/test/runtime-pack.test.ts",
"packages/compiler/test/debug-variables.test.ts",
"packages/compiler/test/native-codegen-integration.test.ts",
"packages/compiler/test/source-debugging.test.ts",
"packages/runtime-darwin-arm64/test/archive.test.ts",
"packages/runtime/test/string.test.ts",
"tests/harness/ffi-callback-pool.test.ts",
"tests/harness/ffi-catalog.test.ts",
"tests/harness/ffi-frameworks.test.ts",
"tests/harness/library-callbacks.test.ts",
"tests/harness/library-dispatch.test.ts",
"tests/harness/library-multi.test.ts",
"tests/harness/native-object-example.test.ts",
"tests/harness/runtime-tree-shaking.test.ts",
"tests/harness/terminal-geometry.test.ts",
];
export function ciTestPlan({ shard, flavor, workers = 4, suite = "full" }) {
const match = /^([1-9]\d*)\/([1-9]\d*)$/.exec(shard);
if (
!match ||
!Number.isSafeInteger(Number(match[1])) ||
!Number.isSafeInteger(Number(match[2])) ||
Number(match[1]) > Number(match[2])
)
throw new Error("invalid CI shard");
if (flavor !== "plain" && flavor !== "san") throw new Error("invalid CI flavor");
if (!Number.isInteger(workers) || workers < 1) throw new Error("invalid CI worker budget");
if (suite !== "full" && suite !== "darwin") throw new Error("invalid CI suite");
const index = Number(match[1]),
count = Number(match[2]);
const sideWorkers = workers === 1 ? 0 : 1;
const env = { SCRIPTC_TEST_SHARD: shard, SCRIPTC_SAN: flavor === "san" ? "1" : "" };
if (suite === "darwin")
return {
sideWorkers,
corpus: {
name: "case-sharded differential corpus",
args: ["test", ...darwinCorpusFiles],
env: { ...env, SCRIPTC_TEST_WORKERS: String(workers - sideWorkers) },
},
side: [
{
name: "darwin contracts",
args: ["test", "--passWithNoTests", "--shard=" + shard, ...darwinFiles],
env: { ...env, SCRIPTC_TEST_WORKERS: "1" },
},
],
};
const owners = sideOwners(flavor, count);
const isolated = new Set(isolatedFiles.map(([, file]) => file));
const balanced = [...owners]
.filter(([file, owner]) => owner === index && !isolated.has(file))
.map(([file]) => file)
.sort();
const side = [
{
name: "file-sharded tests",
args: [
"test",
"--passWithNoTests",
"--shard=" + shard,
...[...new Set([...fileExclusions, ...owners.keys()])].map((file) => "--exclude=" + file),
],
env: { ...env, SCRIPTC_TEST_WORKERS: "1" },
},
];
if (balanced.length > 0)
side.push({
name: "cost-balanced tests",
args: ["test", "--passWithNoTests", ...balanced],
env: { ...env, SCRIPTC_TEST_WORKERS: "1" },
});
for (const [name, file] of isolatedFiles) {
if (owners.get(file) === index)
side.push({ name, args: ["test", file], env: { ...env, SCRIPTC_TEST_WORKERS: "1" } });
}
for (const [name, file] of caseShardedSideFiles)
side.push({ name, args: ["test", file], env: { ...env, SCRIPTC_TEST_WORKERS: "1" } });
// Frontend coverage and cache invalidation do not consume sanitizer state.
if (flavor === "plain") {
side.push({
name: "native-cache correctness",
args: ["test", cacheFile],
env: {
...env,
SCRIPTC_TEST_WORKERS: "1",
SCRIPTC_CACHE_TEST_SHARD: shard,
SCRIPTC_TEST_STABLE_TOOLCHAIN: "0",
},
});
side.push({
name: "coverage sweep",
args: ["test", coverageFile],
env: { ...env, SCRIPTC_TEST_WORKERS: "1" },
});
}
return {
sideWorkers,
corpus: {
name: "case-sharded corpus",
args: ["test", ...corpusFiles],
env: {
...env,
SCRIPTC_TEST_WORKERS: String(workers - sideWorkers),
SCRIPTC_LLVM_TEST_MODE: "dev",
},
},
side,
};
}