Merge pull request #4033 from code-yeongyu/perf/ci-test-build-time-20260515
perf(ci): shard CI test runner
This commit is contained in:
@@ -32,7 +32,34 @@ jobs:
|
|||||||
echo "PR targets '${BASE_REF}' branch - OK"
|
echo "PR targets '${BASE_REF}' branch - OK"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
test:
|
test-isolated:
|
||||||
|
name: Isolated tests (${{ matrix.shard }})
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
shard: [0, 1, 2, 3]
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
with:
|
||||||
|
bun-version: "1.3.12"
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-1.3.12-${{ hashFiles('bun.lock') }}
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: bun install --frozen-lockfile
|
||||||
|
env:
|
||||||
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||||
|
|
||||||
|
- name: Run isolated tests
|
||||||
|
run: bun run script/run-ci-tests.ts --phase=isolated --shard-count=4 --shard-index=${{ matrix.shard }}
|
||||||
|
|
||||||
|
test-shared:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -41,16 +68,34 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bun-version: "1.3.12"
|
bun-version: "1.3.12"
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-1.3.12-${{ hashFiles('bun.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
|
||||||
|
|
||||||
- name: Build plugin
|
- name: Run shared tests
|
||||||
run: bun run build
|
run: bun run script/run-ci-tests.ts --phase=shared
|
||||||
|
|
||||||
- name: Run tests
|
test:
|
||||||
run: bun run script/run-ci-tests.ts
|
runs-on: ubuntu-latest
|
||||||
|
needs: [test-isolated, test-shared]
|
||||||
|
if: ${{ always() }}
|
||||||
|
steps:
|
||||||
|
- name: Verify test shards passed
|
||||||
|
env:
|
||||||
|
ISOLATED_RESULT: ${{ needs.test-isolated.result }}
|
||||||
|
SHARED_RESULT: ${{ needs.test-shared.result }}
|
||||||
|
run: |
|
||||||
|
if [ "$ISOLATED_RESULT" != "success" ] || [ "$SHARED_RESULT" != "success" ]; then
|
||||||
|
echo "::error::test-isolated=${ISOLATED_RESULT}, test-shared=${SHARED_RESULT}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "All test shards passed"
|
||||||
|
|
||||||
typecheck:
|
typecheck:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -61,6 +106,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bun-version: "1.3.12"
|
bun-version: "1.3.12"
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-1.3.12-${{ hashFiles('bun.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -86,6 +136,11 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
bun-version: "1.3.12"
|
bun-version: "1.3.12"
|
||||||
|
|
||||||
|
- uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.bun/install/cache
|
||||||
|
key: ${{ runner.os }}-bun-1.3.12-${{ hashFiles('bun.lock') }}
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
env:
|
env:
|
||||||
@@ -99,6 +154,9 @@ jobs:
|
|||||||
test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1)
|
test -f dist/index.js || (echo "ERROR: dist/index.js not found!" && exit 1)
|
||||||
test -f dist/index.d.ts || (echo "ERROR: dist/index.d.ts not found!" && exit 1)
|
test -f dist/index.d.ts || (echo "ERROR: dist/index.d.ts not found!" && exit 1)
|
||||||
|
|
||||||
|
- name: Verify dist bundle tests
|
||||||
|
run: bun test src/shared/dist-bundle-bun-globals.test.ts
|
||||||
|
|
||||||
- name: Auto-commit schema changes
|
- name: Auto-commit schema changes
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
@@ -3,19 +3,34 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
import { readFileSync } from "node:fs"
|
import { readFileSync } from "node:fs"
|
||||||
|
|
||||||
const workflowPaths = [
|
const workflowChecks = [
|
||||||
new URL("../.github/workflows/ci.yml", import.meta.url),
|
{
|
||||||
new URL("../.github/workflows/publish.yml", import.meta.url),
|
path: new URL("../.github/workflows/ci.yml", import.meta.url),
|
||||||
|
testRuns: [
|
||||||
|
"run: bun run script/run-ci-tests.ts --phase=isolated --shard-count=4 --shard-index=${{ matrix.shard }}",
|
||||||
|
"run: bun run script/run-ci-tests.ts --phase=shared",
|
||||||
|
"if: ${{ always() }}",
|
||||||
|
"ISOLATED_RESULT: ${{ needs.test-isolated.result }}",
|
||||||
|
"SHARED_RESULT: ${{ needs.test-shared.result }}",
|
||||||
|
"echo \"::error::test-isolated=${ISOLATED_RESULT}, test-shared=${SHARED_RESULT}\"",
|
||||||
|
"run: bun test src/shared/dist-bundle-bun-globals.test.ts",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: new URL("../.github/workflows/publish.yml", import.meta.url),
|
||||||
|
testRuns: ["run: bun run script/run-ci-tests.ts"],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
describe("test workflows", () => {
|
describe("test workflows", () => {
|
||||||
test("use pure bun test for workflows", () => {
|
test("use pure bun test for workflows", () => {
|
||||||
for (const workflowPath of workflowPaths) {
|
for (const workflowCheck of workflowChecks) {
|
||||||
// #given
|
// #given
|
||||||
const workflow = readFileSync(workflowPath, "utf8")
|
const workflow = readFileSync(workflowCheck.path, "utf8")
|
||||||
|
|
||||||
expect(workflow).toContain("- name: Run tests")
|
for (const testRun of workflowCheck.testRuns) {
|
||||||
expect(workflow).toMatch(/run: bun (test|run script\/run-ci-tests\.ts)/)
|
expect(workflow).toContain(testRun)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test"
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { selectCiTestTargets } from "./run-ci-tests"
|
||||||
|
|
||||||
describe("test script isolation", () => {
|
describe("test script isolation", () => {
|
||||||
test("#given mock.module tests in the suite #then bun run test uses the isolated CI runner", async () => {
|
test("#given mock.module tests in the suite #then bun run test uses the isolated CI runner", async () => {
|
||||||
@@ -8,4 +9,37 @@ describe("test script isolation", () => {
|
|||||||
//#then
|
//#then
|
||||||
expect(packageJson.scripts.test).toBe("bun run script/run-ci-tests.ts")
|
expect(packageJson.scripts.test).toBe("bun run script/run-ci-tests.ts")
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("#given isolated test shards #when selecting targets #then shards are deterministic and complete", () => {
|
||||||
|
// given
|
||||||
|
const ciTestPlan = {
|
||||||
|
isolatedModuleMockFiles: [],
|
||||||
|
isolatedTestTargets: ["a.test.ts", "b.test.ts", "c.test.ts", "d.test.ts", "e.test.ts"],
|
||||||
|
sharedTestFiles: ["shared.test.ts"],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const shardOne = selectCiTestTargets(ciTestPlan, { phase: "isolated", shardCount: 2, shardIndex: 0 })
|
||||||
|
const shardTwo = selectCiTestTargets(ciTestPlan, { phase: "isolated", shardCount: 2, shardIndex: 1 })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(shardOne).toEqual({ isolatedTestTargets: ["a.test.ts", "c.test.ts", "e.test.ts"], sharedTestFiles: [] })
|
||||||
|
expect(shardTwo).toEqual({ isolatedTestTargets: ["b.test.ts", "d.test.ts"], sharedTestFiles: [] })
|
||||||
|
expect([...shardOne.isolatedTestTargets, ...shardTwo.isolatedTestTargets].sort()).toEqual(ciTestPlan.isolatedTestTargets)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("#given shared phase #when selecting targets #then only shared tests run", () => {
|
||||||
|
// given
|
||||||
|
const ciTestPlan = {
|
||||||
|
isolatedModuleMockFiles: [],
|
||||||
|
isolatedTestTargets: ["isolated.test.ts"],
|
||||||
|
sharedTestFiles: ["shared.test.ts"],
|
||||||
|
}
|
||||||
|
|
||||||
|
// when
|
||||||
|
const selectedTargets = selectCiTestTargets(ciTestPlan, { phase: "shared", shardCount: 1, shardIndex: 0 })
|
||||||
|
|
||||||
|
// then
|
||||||
|
expect(selectedTargets).toEqual({ isolatedTestTargets: [], sharedTestFiles: ["shared.test.ts"] })
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+108
-8
@@ -6,6 +6,19 @@ type CiTestPlan = {
|
|||||||
sharedTestFiles: string[]
|
sharedTestFiles: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type CiTestPhase = "all" | "isolated" | "shared"
|
||||||
|
|
||||||
|
type CiTestRunOptions = {
|
||||||
|
phase: CiTestPhase
|
||||||
|
shardCount: number
|
||||||
|
shardIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
type CiTestTargetSelection = {
|
||||||
|
isolatedTestTargets: string[]
|
||||||
|
sharedTestFiles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
const TEST_ROOTS = ["bin", "script", "src"] as const
|
const TEST_ROOTS = ["bin", "script", "src"] as const
|
||||||
const MODULE_MOCK_PATTERN = "mock.module("
|
const MODULE_MOCK_PATTERN = "mock.module("
|
||||||
const ALWAYS_ISOLATED_TEST_FILES = [
|
const ALWAYS_ISOLATED_TEST_FILES = [
|
||||||
@@ -62,6 +75,86 @@ function collapseNestedTargets(isolatedTargets: string[]): string[] {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readFlagValue(args: string[], flagName: string): string | null {
|
||||||
|
const prefix = `${flagName}=`
|
||||||
|
const flag = args.find((arg) => arg.startsWith(prefix))
|
||||||
|
|
||||||
|
return flag?.slice(prefix.length) ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePhase(rawPhase: string | null): CiTestPhase {
|
||||||
|
if (rawPhase === null) {
|
||||||
|
return "all"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawPhase === "all" || rawPhase === "isolated" || rawPhase === "shared") {
|
||||||
|
return rawPhase
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Invalid --phase value: ${rawPhase}. Expected all, isolated, or shared.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveIntegerFlag(args: string[], flagName: string, defaultValue: number): number {
|
||||||
|
const rawValue = readFlagValue(args, flagName)
|
||||||
|
if (rawValue === null) {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number(rawValue)
|
||||||
|
if (!Number.isInteger(parsedValue) || parsedValue < 1) {
|
||||||
|
throw new Error(`Invalid ${flagName} value: ${rawValue}. Expected a positive integer.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNonNegativeIntegerFlag(args: string[], flagName: string, defaultValue: number): number {
|
||||||
|
const rawValue = readFlagValue(args, flagName)
|
||||||
|
if (rawValue === null) {
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number(rawValue)
|
||||||
|
if (!Number.isInteger(parsedValue) || parsedValue < 0) {
|
||||||
|
throw new Error(`Invalid ${flagName} value: ${rawValue}. Expected a non-negative integer.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCiTestRunOptions(args: string[]): CiTestRunOptions {
|
||||||
|
const phase = parsePhase(readFlagValue(args, "--phase"))
|
||||||
|
const shardCount = parsePositiveIntegerFlag(args, "--shard-count", 1)
|
||||||
|
const shardIndex = parseNonNegativeIntegerFlag(args, "--shard-index", 0)
|
||||||
|
|
||||||
|
if (shardIndex >= shardCount) {
|
||||||
|
throw new Error(`Invalid --shard-index value: ${shardIndex}. Expected a value less than --shard-count ${shardCount}.`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (shardCount > 1 && phase !== "isolated") {
|
||||||
|
throw new Error("Test sharding is only supported with --phase=isolated.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return { phase, shardCount, shardIndex }
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectShard(testTargets: string[], shardCount: number, shardIndex: number): string[] {
|
||||||
|
if (shardCount === 1) {
|
||||||
|
return testTargets
|
||||||
|
}
|
||||||
|
|
||||||
|
return testTargets.filter((_, index) => index % shardCount === shardIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function selectCiTestTargets(ciTestPlan: CiTestPlan, options: CiTestRunOptions): CiTestTargetSelection {
|
||||||
|
const isolatedTestTargets = options.phase === "shared"
|
||||||
|
? []
|
||||||
|
: selectShard(ciTestPlan.isolatedTestTargets, options.shardCount, options.shardIndex)
|
||||||
|
const sharedTestFiles = options.phase === "isolated" ? [] : ciTestPlan.sharedTestFiles
|
||||||
|
|
||||||
|
return { isolatedTestTargets, sharedTestFiles }
|
||||||
|
}
|
||||||
|
|
||||||
export async function createCiTestPlan(rootDirectory: string = process.cwd()): Promise<CiTestPlan> {
|
export async function createCiTestPlan(rootDirectory: string = process.cwd()): Promise<CiTestPlan> {
|
||||||
const allTestFiles = await collectTestFiles(rootDirectory)
|
const allTestFiles = await collectTestFiles(rootDirectory)
|
||||||
const isolatedModuleMockFiles: string[] = []
|
const isolatedModuleMockFiles: string[] = []
|
||||||
@@ -98,13 +191,12 @@ async function runBunTest(testFiles: string[], label: string): Promise<void> {
|
|||||||
|
|
||||||
console.log(`::group::${label}`)
|
console.log(`::group::${label}`)
|
||||||
|
|
||||||
// For directory paths, exclude _auc* directories which are separate isolated targets
|
const args = testFiles.map((testFile) => {
|
||||||
const args = testFiles.map(tf => {
|
if (testFile.includes("/") && !testFile.endsWith(".test.ts")) {
|
||||||
if (tf.includes('/') && !tf.endsWith('.test.ts')) {
|
return [testFile, "!_auc-*/**/*.test.ts"]
|
||||||
// It's a directory path, add negation glob
|
|
||||||
return [tf, '!_auc-*/**/*.test.ts']
|
|
||||||
}
|
}
|
||||||
return tf
|
|
||||||
|
return testFile
|
||||||
}).flat()
|
}).flat()
|
||||||
|
|
||||||
const command = ["bun", "test", ...args]
|
const command = ["bun", "test", ...args]
|
||||||
@@ -123,17 +215,25 @@ async function runBunTest(testFiles: string[], label: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
|
const options = parseCiTestRunOptions(process.argv.slice(2))
|
||||||
const ciTestPlan = await createCiTestPlan()
|
const ciTestPlan = await createCiTestPlan()
|
||||||
|
const selectedTargets = selectCiTestTargets(ciTestPlan, options)
|
||||||
|
|
||||||
console.log(
|
console.log(
|
||||||
`Detected ${ciTestPlan.isolatedModuleMockFiles.length} mock.module() test files, ${ciTestPlan.isolatedTestTargets.length} isolated targets, and ${ciTestPlan.sharedTestFiles.length} shared test files.`,
|
`Detected ${ciTestPlan.isolatedModuleMockFiles.length} mock.module() test files, ${ciTestPlan.isolatedTestTargets.length} isolated targets, and ${ciTestPlan.sharedTestFiles.length} shared test files.`,
|
||||||
)
|
)
|
||||||
|
|
||||||
for (const isolatedTestTarget of ciTestPlan.isolatedTestTargets) {
|
if (options.phase === "isolated" && options.shardCount > 1) {
|
||||||
|
console.log(
|
||||||
|
`Running isolated test shard ${options.shardIndex + 1}/${options.shardCount} with ${selectedTargets.isolatedTestTargets.length} targets.`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const isolatedTestTarget of selectedTargets.isolatedTestTargets) {
|
||||||
await runBunTest([isolatedTestTarget], `Isolated ${isolatedTestTarget}`)
|
await runBunTest([isolatedTestTarget], `Isolated ${isolatedTestTarget}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await runBunTest(ciTestPlan.sharedTestFiles, "Shared Bun test suite")
|
await runBunTest(selectedTargets.sharedTestFiles, "Shared Bun test suite")
|
||||||
}
|
}
|
||||||
|
|
||||||
export const moduleMockPattern = MODULE_MOCK_PATTERN
|
export const moduleMockPattern = MODULE_MOCK_PATTERN
|
||||||
|
|||||||
Reference in New Issue
Block a user