From a58e5babbe3b32da2522db0cb82b6c78651e02b1 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 30 Sep 2026 11:58:15 -0700 Subject: [PATCH] fix(test): keep extension batches inside requested directories (#162009) * fix(test): keep extension batches inside requested directories Preserve canonical plugin roots as directory filters and intersect plain directory test globs with their owning scope. Prevent policy batches from selecting sibling policy filenames or nested policy directories while retaining watch discovery, explicit files, exclusions, and complex owner patterns. Add real runner-to-selector regression cases, proven failing before the fix, and keep existing aggregation and scoped-config assertions aligned. * fix(test): preserve explicit extension file filters --- scripts/test-extension-batch.mts | 16 ++++++-- test/scripts/test-extension.test.ts | 56 ++++++++++++++++++++++++-- test/vitest-scoped-config.test.ts | 4 +- test/vitest/vitest.include-patterns.ts | 13 ++++-- 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/scripts/test-extension-batch.mts b/scripts/test-extension-batch.mts index 6738c6e973d1..ab9fc17bd67d 100644 --- a/scripts/test-extension-batch.mts +++ b/scripts/test-extension-batch.mts @@ -33,7 +33,7 @@ import { isDirectScriptRun, runVitestBatch } from "./lib/vitest-batch-runner.mts import type { VitestBatchRunParams } from "./lib/vitest-batch-runner.mts"; import { prepareVitestRuntime } from "./lib/vitest-build-prerequisites.mts"; import { resolveVitestCacheRoot, resolveVitestCacheSlotPath } from "./lib/vitest-cache-slots.mts"; -import { resolveExplicitVitestMode } from "./lib/vitest-cli-mode.mts"; +import { collectVitestFileFilters, resolveExplicitVitestMode } from "./lib/vitest-cli-mode.mts"; import { resolveVitestHomeSelection } from "./lib/vitest-home-selection.mts"; import { createVitestReportOwner, type VitestReportOutcome } from "./lib/vitest-report-owner.mts"; import { resolveVitestRuntimeCliSelections } from "./lib/vitest-runtime-selection.mts"; @@ -178,6 +178,7 @@ function preparePlanGroup( exactExcludePaths: Set, ) { const targets = resolveGroupTargets(group, exactExcludePaths); + const hasFileFilters = collectVitestFileFilters(vitestArgs).length > 0; const targetChunks = targets.length === 0 ? [] @@ -195,7 +196,11 @@ function preparePlanGroup( group, watchMode: resolveExplicitVitestMode(["run", ...vitestArgs]) === "watch", }), - targets: chunk.map((target) => relativizeExtensionVitestPath(target)), + targets: chunk.map((target) => { + const relative = relativizeExtensionVitestPath(target); + // Bound default discovery without widening an explicit CLI file selection. + return !hasFileFilters && group.extensionIds.includes(relative) ? `${relative}/` : relative; + }), }), ); const bunInvocations: typeof invocations = []; @@ -238,7 +243,12 @@ function combineSinglePluginGroups( config, args: relativizeExtensionVitestArgs(vitestArgs), targets: targets.filter( - (target) => !targets.some((root) => target.startsWith(`${root}/`)), + (target) => + !targets.some( + (root) => + root !== target && + target.startsWith(root.endsWith("/") ? root : `${root}/`), + ), ), env: { ...createGroupEnv({ diff --git a/test/scripts/test-extension.test.ts b/test/scripts/test-extension.test.ts index 35abbf353cd5..a4bfcdf2caa0 100644 --- a/test/scripts/test-extension.test.ts +++ b/test/scripts/test-extension.test.ts @@ -62,7 +62,10 @@ import { providerExtensionTestRoots } from "../vitest/vitest.extension-provider- import { qaExtensionTestRoots } from "../vitest/vitest.extension-qa-paths.mjs"; import { zaloExtensionTestRoots } from "../vitest/vitest.extension-zalo-paths.mjs"; import { extensionCatchAllExcludedTestRoots } from "../vitest/vitest.extensions.config.ts"; -import { isSharedVitestExcludedPath } from "../vitest/vitest.pattern-file.ts"; +import { + isSharedVitestExcludedPath, + matchesVitestCliSelection, +} from "../vitest/vitest.pattern-file.ts"; vi.mock("../../scripts/lib/vitest-build-prerequisites.mts", async (importOriginal) => ({ ...(await importOriginal()), @@ -689,7 +692,7 @@ describe("scripts/test-extension.mts", () => { env: { OPENCLAW_EXTENSION_BATCH_PARALLEL: "2", }, - targets: ["two"], + targets: ["two/"], }); const cachePaths = runGroup.mock.calls.map(([params]) => params.env?.OPENCLAW_VITEST_FS_MODULE_CACHE_PATH?.replaceAll("\\", "/"), @@ -1009,6 +1012,53 @@ await new Promise(()=>{});export default {};`, }, ); + it.each([ + { ids: ["policy"], args: [], selected: ["extensions/policy/src/example.test.ts"] }, + { + ids: ["policy", "file-transfer"], + args: [], + selected: [ + "extensions/policy/src/example.test.ts", + "extensions/file-transfer/src/shared/policy.test.ts", + ], + }, + { + ids: ["policy"], + args: ["--watch"], + selected: ["extensions/policy/src/example.test.ts"], + }, + ])("confines extension roots $ids with $args", async ({ ids, args, selected }) => { + const runGroup = vi.fn<(params: RunGroupParams) => Promise>().mockResolvedValue(0); + await expect( + runExtensionBatchPlan(resolveExtensionBatchPlan({ extensionIds: ids }), { + env: {}, + runGroup, + vitestArgs: args, + }), + ).resolves.toBe(0); + + expect(runGroup).toHaveBeenCalledOnce(); + const invocation = requireFirstMockArg(runGroup); + const candidates = [ + "extensions/policy/src/example.test.ts", + "extensions/file-transfer/src/shared/policy.test.ts", + "extensions/other/src/policy/example.test.ts", + "extensions/policy-extra/src/example.test.ts", + "extensions/policy/src/example.test.tsx", + ]; + expect( + candidates.filter((file) => + matchesVitestCliSelection( + file, + ["extensions/**/*.test.ts"], + ["run", "--config", invocation.config, ...invocation.args, ...invocation.targets], + "extensions", + invocation.env ?? {}, + ), + ), + ).toEqual(selected); + }); + it("expands extension batch roots before applying exact Vitest excludes", async () => { const runGroup = vi.fn<() => Promise>().mockResolvedValue(0); await runExtensionBatchPlan( @@ -1222,7 +1272,7 @@ await new Promise(()=>{});export default {};`, expect(result).toBe(0); expect(runGroup).toHaveBeenCalledOnce(); const invocation = requireFirstMockArg(runGroup); - expect(invocation.targets).toEqual(["matrix"]); + expect(invocation.targets).toEqual(["matrix/"]); expect(invocation.config).toBe("test/vitest/vitest.database-worker-watch.config.ts"); expect(invocation.homeMode).toBe("live-aware"); expect(invocation.env?.OPENCLAW_VITEST_DATABASE_WORKER_WATCH_OWNER).toBe( diff --git a/test/vitest-scoped-config.test.ts b/test/vitest-scoped-config.test.ts index 7fbf311e7865..6cd0d75e02aa 100644 --- a/test/vitest-scoped-config.test.ts +++ b/test/vitest-scoped-config.test.ts @@ -69,7 +69,7 @@ function expectForkedNonIsolatedRunner( } describe("createScopedVitestConfig", () => { - it("keeps broad package scoped cli directory filters aligned with repo-root include patterns", () => { + it("narrows package CLI directories within their owner", () => { const config = createScopedVitestConfig(["packages/**/*.test.ts"], { argv: ["vitest", "run", "packages/normalization-core"], dir: "packages", @@ -77,7 +77,7 @@ describe("createScopedVitestConfig", () => { passWithNoTests: true, }); - expect(requireTestConfig(config).include).toEqual(["**/*.test.ts"]); + expect(requireTestConfig(config).include).toEqual(["normalization-core/**/*.test.ts"]); }); it("relativizes scoped include and exclude patterns to the configured dir", () => { diff --git a/test/vitest/vitest.include-patterns.ts b/test/vitest/vitest.include-patterns.ts index 8f3c11f349c3..29a4d1cbe1f8 100644 --- a/test/vitest/vitest.include-patterns.ts +++ b/test/vitest/vitest.include-patterns.ts @@ -62,18 +62,25 @@ export function narrowIncludePatterns( return null; } - // Vitest applies CLI filters after discovery. Prefix overlap cannot prove glob - // containment, so retain the owner's patterns unless selecting an owned literal file. + // Prefix overlap alone cannot prove containment. Only narrow literal files + // and plain directory test patterns; preserve more complex owner globs. const narrowed = new Set(); for (const candidate of candidatePatterns) { const isLiteral = !/[?*[\]{}]/u.test(candidate); + const directoryCandidate = candidate.replace(/\.test\.\*$/u, ".test.ts"); + const candidateRoot = directoryTestPatternRoot(directoryCandidate); for (const laneScope of includePatterns) { if (isLiteral) { if (matchesGlob(candidate, laneScope)) { narrowed.add(candidate); } } else if (patternsCouldOverlap(candidate, laneScope, matchesGlob)) { - narrowed.add(laneScope); + const ownerRoot = directoryTestPatternRoot(laneScope); + narrowed.add( + candidateRoot !== null && ownerRoot !== null && isAtOrUnder(candidateRoot, ownerRoot) + ? directoryCandidate + : laneScope, + ); } } }