forked from lidge-jun/opencodex
-
Notifications
You must be signed in to change notification settings - Fork 0
ci: shard slow cross-platform test lanes #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
OnlineChef
wants to merge
8
commits into
dev
Choose a base branch
from
ci/shard-windows-root-tests
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
23ed0df
ci: add deterministic root test sharding
OnlineChef f234004
ci: shard slow cross-platform test lanes
OnlineChef 65f3515
ci: run sharded tests through the isolated test environment
OnlineChef d4bced2
test: pin shard partition coverage invariants
OnlineChef 7ee5305
test: pin workflow to canonical isolated test entry points
OnlineChef 1994a6f
ci: install GUI dependencies on root-test lanes
OnlineChef 3831bb1
ci: pass exact shard file paths and pin shard argument validation
OnlineChef f37076c
test: cover whitespace-only shard argument rejection
OnlineChef File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,130 @@ | ||
| import { readdir, stat } from "node:fs/promises"; | ||
| import { join, relative, resolve, sep } from "node:path"; | ||
|
|
||
| import { createIsolatedTestEnvironment } from "./test"; | ||
|
|
||
| export const TEST_ROOT = resolve("tests"); | ||
| const TEST_FILE = /(?:\.test\.|\.spec\.|_test\.|_spec\.)(?:[cm]?[jt]sx?)$/i; | ||
| const BATCH_SIZE = 80; | ||
|
|
||
| export interface TestFile { | ||
| path: string; | ||
| bytes: number; | ||
| } | ||
|
|
||
| export async function collectTestFiles(directory: string): Promise<TestFile[]> { | ||
| const entries = await readdir(directory, { withFileTypes: true }); | ||
| const files: TestFile[] = []; | ||
|
|
||
| for (const entry of entries) { | ||
| const absolute = join(directory, entry.name); | ||
| if (entry.isDirectory()) { | ||
| files.push(...await collectTestFiles(absolute)); | ||
| continue; | ||
| } | ||
| if (!entry.isFile() || !TEST_FILE.test(entry.name)) continue; | ||
| const metadata = await stat(absolute); | ||
| files.push({ | ||
| path: relative(process.cwd(), absolute).split(sep).join("/"), | ||
| bytes: metadata.size, | ||
| }); | ||
| } | ||
|
|
||
| return files; | ||
| } | ||
|
|
||
| export function parseInteger(value: string | undefined, name: string): number { | ||
| // Number("") coerces to 0, so a blank matrix value must be rejected explicitly. | ||
| const parsed = value === undefined || value.trim() === "" ? Number.NaN : Number(value); | ||
| if (!Number.isInteger(parsed)) throw new Error(`${name} must be an integer; got ${value ?? "<missing>"}`); | ||
| return parsed; | ||
| } | ||
|
|
||
| export interface ShardSelection { | ||
| shardIndex: number; | ||
| shardCount: number; | ||
| } | ||
|
|
||
| export function parseShardSelection( | ||
| shardIndexRaw: string | undefined, | ||
| shardCountRaw: string | undefined, | ||
| ): ShardSelection { | ||
| const shardIndex = parseInteger(shardIndexRaw, "shardIndex"); | ||
| const shardCount = parseInteger(shardCountRaw, "shardCount"); | ||
| if (shardCount < 1) throw new Error("shardCount must be at least 1"); | ||
| if (shardIndex < 0 || shardIndex >= shardCount) { | ||
| throw new Error(`shardIndex must be between 0 and ${shardCount - 1}; got ${shardIndex}`); | ||
| } | ||
| return { shardIndex, shardCount }; | ||
| } | ||
|
|
||
| export function assignBalancedShards(files: TestFile[], shardCount: number): TestFile[][] { | ||
| const shards = Array.from({ length: shardCount }, () => [] as TestFile[]); | ||
| const totals = Array.from({ length: shardCount }, () => 0); | ||
|
|
||
| // File size is a stable, repository-local proxy for test cost. Greedy assignment avoids the | ||
| // severe imbalance produced by alphabetical modulo sharding while remaining deterministic. | ||
| const ordered = [...files].sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path)); | ||
| for (const file of ordered) { | ||
| let target = 0; | ||
| for (let index = 1; index < shardCount; index += 1) { | ||
| if (totals[index]! < totals[target]!) target = index; | ||
| } | ||
| shards[target]!.push(file); | ||
| totals[target] += file.bytes; | ||
| } | ||
|
|
||
| for (const shard of shards) shard.sort((left, right) => left.path.localeCompare(right.path)); | ||
| return shards; | ||
| } | ||
|
|
||
| async function runBatch(paths: string[], env: Record<string, string | undefined>): Promise<number> { | ||
| // Match the canonical scripts/test.ts orchestration: spawn the current Bun binary and run | ||
| // against an isolated HOME so shards never read or mutate the runner's real configuration. | ||
| // Bun treats bare positional test arguments as substring filters; a "./" prefix forces each | ||
| // argument to be resolved as an exact file path so a batch never pulls in unrelated tests. | ||
| const child = Bun.spawn([process.execPath, "test", "--isolate", ...paths.map(path => `./${path}`)], { | ||
| cwd: process.cwd(), | ||
| env, | ||
| stdin: "inherit", | ||
| stdout: "inherit", | ||
| stderr: "inherit", | ||
| }); | ||
| return await child.exited; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| async function main(): Promise<void> { | ||
| const { shardIndex, shardCount } = parseShardSelection(Bun.argv[2], Bun.argv[3]); | ||
|
|
||
| const files = await collectTestFiles(TEST_ROOT); | ||
| if (files.length === 0) throw new Error("No Bun test files found under tests/"); | ||
|
|
||
| const shards = assignBalancedShards(files, shardCount); | ||
| const selected = shards[shardIndex]!; | ||
| const totalBytes = selected.reduce((sum, file) => sum + file.bytes, 0); | ||
| console.log( | ||
| `[ci-test-shard] shard ${shardIndex + 1}/${shardCount}: ${selected.length}/${files.length} files, ${totalBytes} source bytes`, | ||
| ); | ||
|
|
||
| const isolated = createIsolatedTestEnvironment(); | ||
| try { | ||
| for (let offset = 0; offset < selected.length; offset += BATCH_SIZE) { | ||
| const batch = selected.slice(offset, offset + BATCH_SIZE).map(file => file.path); | ||
| console.log(`[ci-test-shard] batch ${Math.floor(offset / BATCH_SIZE) + 1}: ${batch.length} files`); | ||
| const exitCode = await runBatch(batch, isolated.env); | ||
| if (exitCode !== 0) { | ||
| process.exitCode = exitCode; | ||
| return; | ||
| } | ||
| } | ||
| } finally { | ||
| isolated.cleanup(); | ||
| } | ||
| } | ||
|
|
||
| if (import.meta.main) { | ||
| main().catch(error => { | ||
| console.error(error instanceof Error ? error.stack ?? error.message : String(error)); | ||
| process.exit(1); | ||
| }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
|
|
||
| import { | ||
| assignBalancedShards, | ||
| collectTestFiles, | ||
| parseInteger, | ||
| parseShardSelection, | ||
| TEST_ROOT, | ||
| type TestFile, | ||
| } from "../scripts/ci-test-shard"; | ||
|
|
||
| // CI relies on the shard partitioner to run every root test exactly once across the configured | ||
| // shard invocations (Windows currently uses two). A regression in discovery, path normalization, | ||
| // or assignment would silently skip or duplicate tests on the sharded platforms only, so pin the | ||
| // coverage invariant here where the full suite catches it on every platform. | ||
| describe("ci-test-shard partition invariants", () => { | ||
| const shardCounts = [1, 2, 3, 4]; | ||
|
|
||
| test("discovers this test file among the root tests", async () => { | ||
| const files = await collectTestFiles(TEST_ROOT); | ||
| expect(files.map(file => file.path)).toContain("tests/ci-test-shard.test.ts"); | ||
| }); | ||
|
|
||
| test.each(shardCounts)("%i shard(s) cover every discovered test exactly once", async shardCount => { | ||
| const files = await collectTestFiles(TEST_ROOT); | ||
| expect(files.length).toBeGreaterThan(0); | ||
|
|
||
| const shards = assignBalancedShards(files, shardCount); | ||
| expect(shards.length).toBe(shardCount); | ||
|
|
||
| const union = shards.flat().map(file => file.path); | ||
| expect(union.length).toBe(files.length); | ||
| expect(new Set(union).size).toBe(union.length); | ||
| expect([...union].sort()).toEqual(files.map(file => file.path).sort()); | ||
| }); | ||
|
|
||
| test("assignment is deterministic regardless of discovery order", () => { | ||
| const files: TestFile[] = [ | ||
| { path: "tests/a.test.ts", bytes: 500 }, | ||
| { path: "tests/b.test.ts", bytes: 300 }, | ||
| { path: "tests/c.test.ts", bytes: 300 }, | ||
| { path: "tests/d.test.ts", bytes: 100 }, | ||
| ]; | ||
| const shuffled = [files[2]!, files[0]!, files[3]!, files[1]!]; | ||
|
|
||
| const fromOrdered = assignBalancedShards(files, 2).map(shard => shard.map(file => file.path)); | ||
| const fromShuffled = assignBalancedShards(shuffled, 2).map(shard => shard.map(file => file.path)); | ||
| expect(fromShuffled).toEqual(fromOrdered); | ||
| }); | ||
| }); | ||
|
|
||
| // A misconfigured CI matrix (missing, malformed, or out-of-range shard arguments) must fail loudly | ||
| // instead of silently running the wrong slice of the suite, so pin the CLI validation branches. | ||
| describe("ci-test-shard argument validation", () => { | ||
| test("parseInteger rejects missing values", () => { | ||
| expect(() => parseInteger(undefined, "shardIndex")).toThrow("shardIndex must be an integer; got <missing>"); | ||
| }); | ||
|
|
||
| test.each(["", " ", "two", "1.5", "NaN"])("parseInteger rejects non-integer value %j", value => { | ||
| expect(() => parseInteger(value, "shardCount")).toThrow(/shardCount must be an integer/); | ||
| }); | ||
|
|
||
| test("parseInteger accepts integer strings", () => { | ||
| expect(parseInteger("0", "shardIndex")).toBe(0); | ||
| expect(parseInteger("3", "shardCount")).toBe(3); | ||
| }); | ||
|
|
||
| test("parseShardSelection rejects shardCount below 1", () => { | ||
| expect(() => parseShardSelection("0", "0")).toThrow("shardCount must be at least 1"); | ||
| expect(() => parseShardSelection("0", "-2")).toThrow("shardCount must be at least 1"); | ||
| }); | ||
|
|
||
| test("parseShardSelection rejects out-of-range shardIndex", () => { | ||
| expect(() => parseShardSelection("-1", "2")).toThrow("shardIndex must be between 0 and 1; got -1"); | ||
| expect(() => parseShardSelection("2", "2")).toThrow("shardIndex must be between 0 and 1; got 2"); | ||
| }); | ||
|
|
||
| test("parseShardSelection accepts a valid in-range configuration", () => { | ||
| expect(parseShardSelection("1", "2")).toEqual({ shardIndex: 1, shardCount: 2 }); | ||
| }); | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.