diff --git a/scripts/lib/extension-test-plan.mjs b/scripts/lib/extension-test-plan.mjs index 5a6d687f3e7..79ed6d412eb 100644 --- a/scripts/lib/extension-test-plan.mjs +++ b/scripts/lib/extension-test-plan.mjs @@ -70,7 +70,9 @@ function isPathInsideRepo(relativePath) { } function isSkippedTrackedTestFile(relativePath) { - return relativePath.split("/").some((segment) => segment === "dist" || segment === "node_modules"); + return relativePath + .split("/") + .some((segment) => segment === "dist" || segment === "node_modules"); } function listTrackedTestFiles(rootPath) { @@ -99,6 +101,18 @@ function listTrackedTestFiles(rootPath) { ); } +export function listTrackedTestFilesForRoots(roots) { + const files = []; + for (const root of roots) { + const trackedFiles = listTrackedTestFiles(path.join(repoRoot, root)); + if (!trackedFiles) { + return null; + } + files.push(...trackedFiles); + } + return [...new Set(files)].toSorted((left, right) => left.localeCompare(right)); +} + function countTestFiles(rootPath) { const trackedFiles = listTrackedTestFiles(rootPath); if (trackedFiles) { diff --git a/scripts/test-extension-batch.mjs b/scripts/test-extension-batch.mjs index de33c575adf..2ef98a23b2d 100644 --- a/scripts/test-extension-batch.mjs +++ b/scripts/test-extension-batch.mjs @@ -1,7 +1,10 @@ #!/usr/bin/env node import path from "node:path"; -import { resolveExtensionBatchPlan } from "./lib/extension-test-plan.mjs"; +import { + listTrackedTestFilesForRoots, + resolveExtensionBatchPlan, +} from "./lib/extension-test-plan.mjs"; import { isDirectScriptRun, runVitestBatch } from "./lib/vitest-batch-runner.mjs"; const FS_MODULE_CACHE_PATH_ENV_KEY = "OPENCLAW_VITEST_FS_MODULE_CACHE_PATH"; @@ -87,9 +90,62 @@ function orderPlanGroups(planGroups, parallelism) { }); } +function normalizeRelativePath(inputPath) { + return path + .relative(process.cwd(), path.resolve(process.cwd(), inputPath)) + .split(path.sep) + .join("/"); +} + +function isExactExcludePath(inputPath) { + return !/[*!?[\]{}]/u.test(inputPath); +} + +export function parseExactVitestExcludePaths(vitestArgs) { + const excludePaths = new Set(); + for (let index = 0; index < vitestArgs.length; index += 1) { + const arg = vitestArgs[index]; + if (arg === "--exclude") { + const value = vitestArgs[index + 1]; + if (value && isExactExcludePath(value)) { + excludePaths.add(normalizeRelativePath(value)); + } + index += 1; + continue; + } + const prefix = "--exclude="; + if (arg.startsWith(prefix)) { + const value = arg.slice(prefix.length); + if (value && isExactExcludePath(value)) { + excludePaths.add(normalizeRelativePath(value)); + } + } + } + return excludePaths; +} + +function resolveGroupTargets(group, exactExcludePaths) { + if (exactExcludePaths.size === 0) { + return group.roots; + } + + const testFiles = listTrackedTestFilesForRoots(group.roots); + if (!testFiles) { + return group.roots; + } + + return testFiles.filter((file) => !exactExcludePaths.has(file)); +} + async function runPlanGroup(group, params) { + const targets = resolveGroupTargets(group, params.exactExcludePaths); + if (targets.length === 0) { + console.log(`[test-extension-batch] ${group.config}: no test files remain after excludes`); + return 0; + } + console.log( - `[test-extension-batch] ${group.config}: ${group.extensionIds.join(", ")} (${group.testFileCount} files)`, + `[test-extension-batch] ${group.config}: ${group.extensionIds.join(", ")} (${targets.length} targets)`, ); return await params.runGroup({ args: params.vitestArgs, @@ -100,13 +156,14 @@ async function runPlanGroup(group, params) { groupIndex: params.groupIndex, useDedicatedCache: params.useDedicatedCache, }), - targets: group.roots, + targets, }); } export async function runExtensionBatchPlan(batchPlan, params = {}) { const env = params.env ?? process.env; const vitestArgs = params.vitestArgs ?? []; + const exactExcludePaths = parseExactVitestExcludePaths(vitestArgs); const runGroup = params.runGroup ?? runVitestBatch; const parallelism = resolveExtensionBatchParallelism(batchPlan.planGroups.length, env); const orderedGroups = orderPlanGroups(batchPlan.planGroups, parallelism); @@ -130,6 +187,7 @@ export async function runExtensionBatchPlan(batchPlan, params = {}) { env, groupIndex, runGroup, + exactExcludePaths, useDedicatedCache, vitestArgs, }); diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 802767c3859..ba85548fc00 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -16,6 +16,7 @@ import { import { buildVitestBatchPnpmArgs } from "../../scripts/lib/vitest-batch-runner.mjs"; import { parseExtensionIds, + parseExactVitestExcludePaths, resolveExtensionBatchParallelism, runExtensionBatchPlan, } from "../../scripts/test-extension-batch.mjs"; @@ -626,6 +627,46 @@ describe("scripts/test-extension.mjs", () => { ]); }); + it("expands extension batch roots before applying exact Vitest excludes", async () => { + const runGroup = vi.fn<() => Promise>().mockResolvedValue(0); + await runExtensionBatchPlan( + { + extensionCount: 1, + extensionIds: ["codex"], + estimatedCost: 1, + hasTests: true, + planGroups: [ + { + config: "test/vitest/vitest.extensions.config.ts", + estimatedCost: 1, + extensionIds: ["codex"], + roots: [bundledPluginRoot("codex")], + testFileCount: 1, + }, + ], + testFileCount: 1, + }, + { + runGroup, + vitestArgs: ["--exclude", "extensions/codex/src/app-server/run-attempt.test.ts"], + }, + ); + + const runParams = requireFirstMockArg(runGroup); + expect(runParams.targets).not.toContain("extensions/codex/src/app-server/run-attempt.test.ts"); + expect(runParams.targets).toContain("extensions/codex/src/app-server/client.test.ts"); + }); + + it("detects exact Vitest excludes in extension batch args", () => { + expect([ + ...parseExactVitestExcludePaths([ + "--exclude", + "extensions/codex/src/app-server/run-attempt.test.ts", + ]), + ]).toEqual(["extensions/codex/src/app-server/run-attempt.test.ts"]); + expect([...parseExactVitestExcludePaths(["--exclude=extensions/**/*.test.ts"])]).toEqual([]); + }); + it("treats extensions without tests as a no-op by default", () => { const extensionId = findExtensionWithoutTests(); const stdout = runScript([extensionId]);