diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index c3213b820..3b43bde6d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -47,7 +47,7 @@ jobs:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Run tests
- run: bun test
+ run: bun run test:ci
typecheck:
runs-on: ubuntu-latest
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 72252bac8..37b3a7a3b 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -46,7 +46,7 @@ jobs:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Run tests
- run: bun test
+ run: bun run test:ci
typecheck:
runs-on: ubuntu-latest
diff --git a/package.json b/package.json
index d88d831eb..692ec4c00 100644
--- a/package.json
+++ b/package.json
@@ -31,6 +31,7 @@
"postinstall": "node postinstall.mjs",
"prepublishOnly": "bun run clean && bun run build",
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
+ "test:ci": "bun run script/run-ci-tests.ts",
"typecheck": "tsc --noEmit",
"test": "bun test"
},
diff --git a/script/publish-workflow.test.ts b/script/publish-workflow.test.ts
index b690a856a..18c667d63 100644
--- a/script/publish-workflow.test.ts
+++ b/script/publish-workflow.test.ts
@@ -1,3 +1,5 @@
+///
+
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
@@ -7,16 +9,14 @@ const workflowPaths = [
]
describe("test workflows", () => {
- test("use a single plain bun test step without isolated split hacks", () => {
+ test("use the CI-safe test runner for workflows", () => {
for (const workflowPath of workflowPaths) {
// given
const workflow = readFileSync(workflowPath, "utf8")
// then
expect(workflow).toContain("- name: Run tests")
- expect(workflow).toContain("run: bun test")
- expect(workflow).not.toContain("Run mock-heavy tests (isolated)")
- expect(workflow).not.toContain("Run remaining tests")
+ expect(workflow).toContain("run: bun run test:ci")
}
})
})
diff --git a/script/run-ci-tests.ts b/script/run-ci-tests.ts
new file mode 100644
index 000000000..de07b9077
--- /dev/null
+++ b/script/run-ci-tests.ts
@@ -0,0 +1,128 @@
+///
+
+type CiTestPlan = {
+ isolatedTestTargets: string[]
+ isolatedModuleMockFiles: string[]
+ sharedTestFiles: string[]
+}
+
+const TEST_ROOTS = ["script", "src"] as const
+const MODULE_MOCK_PATTERN = "mock.module("
+
+async function collectTestFiles(rootDirectory: string): Promise {
+ const testFiles: string[] = []
+
+ for (const testRoot of TEST_ROOTS) {
+ const glob = new Bun.Glob("**/*.test.ts")
+
+ for await (const testFile of glob.scan({ cwd: `${rootDirectory}/${testRoot}` })) {
+ testFiles.push(`${testRoot}/${testFile}`)
+ }
+ }
+
+ return testFiles.sort((left, right) => left.localeCompare(right))
+}
+
+async function usesModuleMock(rootDirectory: string, testFile: string): Promise {
+ const testContents = await Bun.file(`${rootDirectory}/${testFile}`).text()
+ return testContents.includes(MODULE_MOCK_PATTERN)
+}
+
+function toIsolatedTarget(testFile: string): string {
+ const pathSegments = testFile.split("/")
+
+ if (pathSegments.length <= 3) {
+ return testFile
+ }
+
+ return pathSegments.slice(0, -1).join("/")
+}
+
+function isCoveredByTarget(testFile: string, isolatedTarget: string): boolean {
+ return testFile === isolatedTarget || testFile.startsWith(`${isolatedTarget}/`)
+}
+
+function collapseNestedTargets(isolatedTargets: string[]): string[] {
+ return isolatedTargets.filter((isolatedTarget) => {
+ return !isolatedTargets.some((otherTarget) => {
+ return otherTarget !== isolatedTarget && isolatedTarget.startsWith(`${otherTarget}/`)
+ })
+ })
+}
+
+export async function createCiTestPlan(rootDirectory: string = process.cwd()): Promise {
+ const allTestFiles = await collectTestFiles(rootDirectory)
+ const isolatedModuleMockFiles: string[] = []
+
+ for (const testFile of allTestFiles) {
+ if (await usesModuleMock(rootDirectory, testFile)) {
+ isolatedModuleMockFiles.push(testFile)
+ }
+ }
+
+ const isolatedTestTargets = collapseNestedTargets(
+ Array.from(new Set(isolatedModuleMockFiles.map((testFile) => toIsolatedTarget(testFile)))).sort((left, right) =>
+ left.localeCompare(right),
+ ),
+ )
+ const sharedTestFiles = allTestFiles.filter((testFile) => {
+ return !isolatedTestTargets.some((isolatedTarget) => isCoveredByTarget(testFile, isolatedTarget))
+ })
+
+ return {
+ isolatedTestTargets,
+ isolatedModuleMockFiles,
+ sharedTestFiles,
+ }
+}
+
+async function runBunTest(testFiles: string[], label: string): Promise {
+ if (testFiles.length === 0) {
+ return
+ }
+
+ console.log(`::group::${label}`)
+ const command = ["bun", "test", ...testFiles]
+ const spawnedProcess = Bun.spawn(command, {
+ cwd: process.cwd(),
+ stdin: "inherit",
+ stdout: "inherit",
+ stderr: "inherit",
+ })
+ const exitCode = await spawnedProcess.exited
+ console.log("::endgroup::")
+
+ if (exitCode !== 0) {
+ throw new Error(`Command failed: ${command.join(" ")}`)
+ }
+}
+
+async function main(): Promise {
+ const ciTestPlan = await createCiTestPlan()
+
+ console.log(
+ `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) {
+ await runBunTest([isolatedTestTarget], `Isolated ${isolatedTestTarget}`)
+ }
+
+ await runBunTest(ciTestPlan.sharedTestFiles, "Shared Bun test suite")
+}
+
+export const moduleMockPattern = MODULE_MOCK_PATTERN
+export const testRoots = TEST_ROOTS
+
+if (process.argv.includes("--print-plan")) {
+ const ciTestPlan = await createCiTestPlan()
+ console.log(JSON.stringify(ciTestPlan, null, 2))
+} else if (import.meta.main) {
+ try {
+ await main()
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ console.error(message)
+ process.exit(1)
+ }
+}
diff --git a/script/tsconfig.json b/script/tsconfig.json
new file mode 100644
index 000000000..330ebffce
--- /dev/null
+++ b/script/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ESNext",
+ "module": "ESNext",
+ "moduleResolution": "bundler",
+ "strict": true,
+ "resolveJsonModule": true,
+ "lib": ["ESNext"],
+ "types": ["bun-types"],
+ "allowImportingTsExtensions": true,
+ "noEmit": true
+ },
+ "include": ["./publish-workflow.test.ts", "./run-ci-tests.ts"]
+}