fix: run concrete changed test groups concurrently (#162701)

Allow concrete changed-test selections to use the existing bounded concurrent scheduler while preserving serial and exclusive-suite safeguards.

Fixes #162615.
This commit is contained in:
Shakker 2026-10-01 14:35:53 +01:00 • committed by GitHub
parent d8a110b1a1
commit afca8f9fce
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 22 additions and 8 deletions

View file

@ -393,25 +393,27 @@ export async function runTestProjects(
const { parseCLI } = await import("vitest/node");
let exactTargetRun = false;
const selectedTargets = changedTargetArgs ?? targetArgs;
if (
targetArgs.length &&
selectedTargets.length &&
!runSpecs.some((spec) => spec.watchMode) &&
!hasNonRunVitestSubcommand(forwardedArgs)
) {
// Native parsing stays in the execution owner. Original filters distinguish
// explicit files from broad selections that also lower to literal include files.
// Changed selection already resolved its targets. Broad/config selections
// keep their existing policy even when lowered to literal include files.
const execution = parseVitestExecutionArgs(["run", ...forwardedArgs], parseCLI);
const filters = changedTargetArgs ?? execution?.filter ?? [];
if (
execution &&
!execution.options.watch &&
execution.options.run !== false &&
execution.filter.length > 0 &&
execution.filter.every(
filters.length > 0 &&
filters.every(
(file) => isTestFileTarget(file) && /[/\\]/u.test(file) && !/[*?[\]{}]|[@+!]\(/u.test(file),
)
) {
exactTargetRun = true;
if (!Object.hasOwn(execution.options, "passWithNoTests")) {
if (targetArgs.length && !Object.hasOwn(execution.options, "passWithNoTests")) {
for (const spec of runSpecs) {
const separator = spec.pnpmArgs.indexOf("--");
spec.pnpmArgs.splice(

View file

@ -654,6 +654,8 @@ describe("automatic exact-target admission", () => {
it.each([
{ name: "CI=1", expected: 2 },
{ name: "changed targets", changed: true, expected: 2 },
{ name: "changed targets with serial override", changed: true, serial: "1", expected: 1 },
{ name: "CI=true", ci: "true", expected: 2 },
{ name: "unresolved config outputs", args: [], expected: 1 },
{ name: "console reporter without coverage override", args: ["--reporter=dot"], expected: 1 },
@ -682,7 +684,7 @@ describe("automatic exact-target admission", () => {
{ name: "caller cache leaf", callerLeaf: true, expected: 1 },
])(
"preserves resolved specs and policy for $name",
async ({ ci, cpus, gib, parallel, serial, portable, callerLeaf, args, expected }) => {
async ({ ci, cpus, gib, parallel, serial, portable, callerLeaf, args, changed, expected }) => {
vi.stubEnv("CI", ci ?? "1");
vi.stubEnv("OPENCLAW_TEST_PROJECTS_PARALLEL", parallel ?? "");
vi.stubEnv("OPENCLAW_TEST_PROJECTS_SERIAL", serial ?? "");
@ -697,6 +699,10 @@ describe("automatic exact-target admission", () => {
vi.mocked(os.availableParallelism).mockReturnValue(cpus ?? 8);
vi.mocked(os.totalmem).mockReturnValue((gib ?? 24) * 1024 ** 3);
const planner = await import("../../scripts/test-projects.test-support.mts");
if (changed) {
const lanes = await import("../../scripts/changed-lanes.mts");
vi.spyOn(lanes, "listChangedPathsFromGit").mockReturnValue(files);
}
const produced = vi.spyOn(planner, "createVitestRunSpecs");
const { runTestProjects } = await import("../../scripts/test-projects-run.mts");
const selections: Array<{ config: string; include: string[] | null; workers: string }> = [];
@ -720,11 +726,17 @@ describe("automatic exact-target admission", () => {
getForwardedSignal: () => undefined,
};
});
await runTestProjects(async () => {}, [...files, ...(args ?? outputArgs)]);
await runTestProjects(async () => {}, [
...(changed ? ["--changed", "origin/main"] : files),
...(args ?? outputArgs),
]);
const specs = produced.mock.results[0]!.value as ReturnType<
typeof planner.createVitestRunSpecs
>;
expect(specs.length).toBeGreaterThanOrEqual(3);
if (changed) {
expect(specs.some((spec) => spec.pnpmArgs.includes("--passWithNoTests=false"))).toBe(false);
}
expect(peak).toBe(expected);
const actual = selections.map(({ config, include }) => ({ config, include }));
const planned = specs.map((spec) => ({ config: spec.config, include: spec.includePatterns }));