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 and pushes to main run the full suite on Linux. Their macOS // lane runs the darwin suite, which 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). // The nightly schedule and manual runs with macos_full 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") { // vitest runs one file's cases in sequence, so the shard's corpus cases // run as two halves in separate processes: case shards 2i-1 and 2i of // 2n. The second half follows the darwin contracts in the side queue. const half = (part) => ({ name: `case-sharded differential corpus ${part}/2`, args: ["test", ...darwinCorpusFiles], env: { ...env, SCRIPTC_TEST_SHARD: `${2 * index - 2 + part}/${2 * count}`, SCRIPTC_TEST_WORKERS: "1", }, }); return { sideWorkers, corpus: half(1), side: [ { name: "darwin contracts", args: ["test", "--passWithNoTests", "--shard=" + shard, ...darwinFiles], env: { ...env, SCRIPTC_TEST_WORKERS: "1" }, }, half(2), ], }; } 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, }; }