From a232850b88e2b2c790d880f3ef67c60b25486a12 Mon Sep 17 00:00:00 2001 From: Shohruh Ismoilov Date: Sat, 7 Nov 2026 21:12:09 +0500 Subject: [PATCH] fix(run): rerun exact Go tests in each package (#1978) Go test reruns now keep full subtest paths (each segment anchored) and run each package with its own selection; tests and benchmarks use their own filters (-run / -bench with -run '^$'), and stale tool or test-binary filters can no longer override reruns or gutter launches. File configurations keep their file target and original run configuration. Boolean test flag aliases are parsed before package targets. Co-authored-by: shohruh-genius <7658451+shohruh-genius@users.noreply.github.com> --- e2e/run.spec.ts | 63 +++++++++++++ src/features/run/goArguments.ts | 19 +++- src/features/run/store.ts | 26 +++--- src/features/run/targets.test.ts | 17 +++- src/features/run/targets.ts | 3 +- src/features/run/testing/rerun.test.ts | 123 ++++++++++++++++++++++--- src/features/run/testing/rerun.ts | 65 +++++++++---- 7 files changed, 264 insertions(+), 52 deletions(-) diff --git a/e2e/run.spec.ts b/e2e/run.spec.ts index 75596434e..78902a460 100644 --- a/e2e/run.spec.ts +++ b/e2e/run.spec.ts @@ -80,3 +80,66 @@ test("⌘F2 stops a running process", async ({ workbench: page }) => { await expect(run.getByRole("button", { name: "Stop" })).toBeDisabled(); await expect(run.getByRole("tab", { name: "dev" })).toHaveAttribute("data-tooltip", /exit code 143$/); }); + +test("Go reruns keep each package's exact failures and the original configuration", async ({ workbench: page }) => { + await stubDesktop(page); + await page.evaluate(async () => { + const config = { + id: "go:test", + name: "All tests", + kind: "go", + command: "go", + args: ["test", "-json", "./..."], + cwd: "/dev/acme-api", + env: {}, + detected: false, + isTest: true, + debugAdapter: "delve", + }; + const h = window.__ipc.handlers; + h.run_configs = () => [config]; + h.run_apply_settings = (args) => args.configs; + let id = 20; + h.run_start = (args) => { + const run = id++; + setTimeout(() => { + for (const [pkg, name] of [ + ["example.com/a", "TestA/one"], + ["example.com/b", "TestB/two/leaf"], + ]) { + for (const event of [ + { Action: "run", Package: pkg, Test: name }, + { Action: "fail", Package: pkg, Test: name }, + { Action: "fail", Package: pkg }, + ]) + window.__ipc.send(args.onOutput, { kind: "stdout", text: JSON.stringify(event) + "\n" }); + } + window.__ipc.emit({ type: "runExited", run, code: 1 }); + }, 50); + return { id: run, configId: config.id, name: config.name, pid: 4242, running: true, pty: false }; + }; + const module = "/src/features/run/store.ts"; + const { loadConfigs, selectConfig } = await import(/* @vite-ignore */ module); + await loadConfigs(); + selectConfig(config.id); + }); + await page.keyboard.press("Control+r"); + const run = page.getByRole("region", { name: "Run", exact: true }); + await expect(run.getByRole("button", { name: "Stop", exact: true })).toBeDisabled(); + await run.getByRole("button", { name: "Rerun Failed Tests", exact: true }).click(); + await expect.poll(async () => (await ipcCalls(page, "run_start")).length).toBe(3); + const calls = await ipcCalls(page, "run_start"); + expect(calls.slice(1).map((call) => (call.config as { args: string[] }).args)).toEqual([ + ["test", "-json", "-run", "^TestA$/^one$", "example.com/a"], + ["test", "-json", "-run", "^TestB$/^two$/^leaf$", "example.com/b"], + ]); + const originals = await page.evaluate(async () => { + const module = "/src/features/run/store.ts"; + const { useRun } = await import(/* @vite-ignore */ module); + return useRun.getState().sessions.map((session: { baseConfig?: { args: string[] } }) => session.baseConfig?.args); + }); + expect(originals).toEqual([ + ["test", "-json", "./..."], + ["test", "-json", "./..."], + ]); +}); diff --git a/src/features/run/goArguments.ts b/src/features/run/goArguments.ts index 3d1c82d79..91306136c 100644 --- a/src/features/run/goArguments.ts +++ b/src/features/run/goArguments.ts @@ -34,7 +34,8 @@ export function goTestParts(args: readonly string[]) { } if (arg.startsWith("-")) { flags.push(arg); - if (!arg.includes("=") && !BOOLEAN_FLAGS.has(arg) && args[i + 1] !== undefined) flags.push(args[++i]!); + if (!arg.includes("=") && !BOOLEAN_FLAGS.has(arg.replace(/^-test\./, "-")) && args[i + 1] !== undefined) + flags.push(args[++i]!); } else targets.push(arg); } return { flags, targets, program }; @@ -42,13 +43,21 @@ export function goTestParts(args: readonly string[]) { export function withoutGoSelection(flags: readonly string[]): string[] { const out: string[] = []; + let program = false; for (let i = 0; i < flags.length; i++) { - if (["-run", "-skip", "-bench", "-fuzz"].includes(flags[i]!)) { - i++; + const arg = flags[i]!; + if (arg === "--") { + out.push(...flags.slice(i)); + break; + } + if (arg === "-args") program = true; + const selector = program ? /^-test\.(run|skip|bench|fuzz)(=|$)/ : /^-(?:test\.)?(run|skip|bench|fuzz)(=|$)/; + const match = selector.exec(arg); + if (match) { + if (match[2] !== "=") i++; continue; } - if (/^-(run|skip|bench|fuzz)=/.test(flags[i]!)) continue; - out.push(flags[i]!); + out.push(arg); } return out; } diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 168c45158..d3a19f2e2 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -47,7 +47,7 @@ import { RubyTestOutput } from "./testing/ruby"; import { TeamcityOutput } from "./testing/teamcity"; import type { TestStdout } from "./testing/types"; import { recordTestStates } from "./testing/states"; -import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun"; +import { failedTestPaths, leafTestPaths, rerunFailedConfigs, testsConfigs } from "./testing/rerun"; import { compoundChildren } from "./configs"; import { remoteLabel, startWithRemedies } from "./remote"; import { TerminalOutput } from "./terminalOutput"; @@ -1158,9 +1158,9 @@ export function rerunFailed(sessionId: string): boolean { const session = getSession(sessionId); if (!session || isLive(session)) return false; const base = baseConfigOf(session); - const derived = rerunFailedConfig(base, failedTests(sessionId)); - if (!derived) return false; - void startRun(derived, sessionId, base); + const derived = rerunFailedConfigs(base, failedTests(sessionId)); + if (!derived.length) return false; + derived.forEach((config, index) => void startRun(config, index === 0 ? sessionId : undefined, base)); return true; } @@ -1180,14 +1180,16 @@ export async function runSelectedTests( const paths = leafTestPaths(nodes); const base = baseConfigOf(session); const name = nodes.length === 1 ? nodes[0]!.name : `${paths.length} tests`; - const derived = testsConfig(base, paths, name); - if (!derived) return false; - const config = adoptTemporary({ - ...derived, - id: `context:tests:${base.id}:${paths.map((p) => p.join("/")).join("|")}`, - }); - if (debug) await executeAction("run.debugConfig", config); - else await startRun(config); + const derived = testsConfigs(base, paths, name); + if (!derived.length) return false; + for (const [index, next] of derived.entries()) { + const config = adoptTemporary({ + ...next, + id: `context:tests:${base.id}:${index}:${paths.map((p) => p.join("/")).join("|")}`, + }); + if (debug) await executeAction("run.debugConfig", config); + else await startRun(config, undefined, base); + } return true; } diff --git a/src/features/run/targets.test.ts b/src/features/run/targets.test.ts index 8be28ee07..67d834102 100644 --- a/src/features/run/targets.test.ts +++ b/src/features/run/targets.test.ts @@ -245,14 +245,26 @@ describe("Go test targets", () => { id: "go:typed", kind: "go", cwd: "runtime", - args: ["test", "-tags", "demo", "-run", "Old", ".", "-args", "-test.short"], + args: [ + "test", + "-tags", + "demo", + "-run", + "Old", + ".", + "-args", + "-test.run=Old", + "-test.bench", + "OldBench", + "-test.short", + ], params: { goKind: "test", module: "app", scope: "package", target: ".", pattern: "Old", - programArgs: "-test.short", + programArgs: "-test.run=Old -test.bench OldBench -test.short", }, }); const text = 'package calc\nfunc TestNew(t *testing.T) {\n t.Run("child case", func(t *testing.T) {})\n}'; @@ -273,6 +285,7 @@ describe("Go test targets", () => { scope: "directory", target: ".", pattern: "^TestNew$/^child_case$", + programArgs: "-test.short", }); }); }); diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index 867e99d5c..af20d2060 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -530,13 +530,14 @@ export function targetConfig( ...withoutGoSelection(parts.flags), ...parts.targets, ...selection, - ...parts.program, + ...withoutGoSelection(parts.program), ]); if (file.params?.goKind) result.params = { ...file.params, pattern: benchmark ? "^$" : pattern, toolArgs: formatArgs([...withoutGoSelection(parts.flags), ...(benchmark ? ["-bench", pattern] : [])]), + programArgs: formatArgs(withoutGoSelection(parts.program).slice(1)), }; return result; } diff --git a/src/features/run/testing/rerun.test.ts b/src/features/run/testing/rerun.test.ts index 4379d855e..b22ed4ad2 100644 --- a/src/features/run/testing/rerun.test.ts +++ b/src/features/run/testing/rerun.test.ts @@ -1,7 +1,14 @@ import { describe, expect, it } from "vitest"; import type { RunConfig } from "@/platform/ipc"; import { TestResults } from "./results"; -import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./rerun"; +import { + failedTestPaths, + leafTestPaths, + rerunFailedConfig, + rerunFailedConfigs, + testsConfig, + testsConfigs, +} from "./rerun"; const cfg = (kind: string, command: string, args: string[]): RunConfig => ({ id: `${kind}:test`, @@ -129,20 +136,17 @@ describe("rerunFailedConfig", () => { }); }); - it("go: -run with top-level names and packages", () => { - const c = rerunFailedConfig(cfg("go", "go", ["test", "-v", "./..."]), [ + it("go: keeps full subtest paths separate for each package", () => { + const configs = rerunFailedConfigs(cfg("go", "go", ["test", "-v", "./..."]), [ ["example.com/m/pkg", "TestAdd"], ["example.com/m/pkg", "TestSub", "case_1"], ["example.com/m/other", "TestX"], - ])!; - expect(c.args).toEqual([ - "test", - "-v", - "-run", - "^(TestAdd|TestSub|TestX)$", - "example.com/m/pkg", - "example.com/m/other", ]); + expect(configs.map((config) => config.args)).toEqual([ + ["test", "-v", "-run", "^TestAdd$|^TestSub$/^case_1$", "example.com/m/pkg"], + ["test", "-v", "-run", "^TestX$", "example.com/m/other"], + ]); + expect(new Set(configs.map((config) => config.id)).size).toBe(2); }); it("gradle and maven filters", () => { @@ -204,6 +208,15 @@ describe("withTestReporting", () => { }); describe("Go rerun argument boundaries", () => { + it.each(["-test.v", "-test.short", "-test.benchmem", "-test.failfast", "-test.fullpath"])( + "does not retain package targets after the boolean flag %s", + (flag) => { + const config = cfg("go", "go", ["test", flag, "./..."]); + const rerun = rerunFailedConfig(config, [["example.com/selected", "TestOnly"]])!; + expect(rerun.args).toEqual(["test", flag, "-run", "^TestOnly$", "example.com/selected"]); + }, + ); + it("replaces import-path targets without losing flag values or binary arguments", () => { const config = cfg("go", "go", ["test", "-tags", "demo", "-run", "old", "example.com/old", "-args", "-test.short"]); const rerun = rerunFailedConfig(config, [["example.com/new", "TestChild", "case"]])!; @@ -218,18 +231,98 @@ describe("Go rerun argument boundaries", () => { "-test.short", ]); }); - it("retains preparation of file-scoped configurations", () => { + it.each(["command-line-arguments", "example.com/app"])("retains file-scoped preparation for %s", (packageName) => { const config = { ...cfg("go", "go", ["test", "main_test.go"]), params: { goKind: "test", scope: "file", target: "main_test.go", module: "app", pattern: "", useTags: "true" }, }; - const rerun = rerunFailedConfig(config, [["command-line-arguments", "TestFail"]])!; + const rerun = rerunFailedConfig(config, [[packageName, "TestFail"]])!; expect(rerun.params).toMatchObject({ goKind: "test", scope: "file", target: "main_test.go", - pattern: "^(TestFail)$", + pattern: "^TestFail$", }); - expect(rerun.args).toEqual(["test", "-run", "^(TestFail)$", "main_test.go"]); + expect(rerun.args).toEqual(["test", "-run", "^TestFail$", "main_test.go"]); + }); +}); + +describe("exact Go test and benchmark selections", () => { + it("keeps benchmark selectors and removes stale selectors from binary arguments", () => { + const base = cfg("go", "go", [ + "test", + "-tags=demo", + "-run=Old", + "-bench", + "Old", + "./...", + "-args", + "-test.run", + "Old", + "-test.bench=Old", + "-test.skip=New", + "-custom", + "keep", + ]); + const [config] = testsConfigs(base, [["example.com/p", "BenchmarkNew", "case"]], "selected"); + expect(config!.args).toEqual([ + "test", + "-tags=demo", + "-run", + "^$", + "-bench", + "^BenchmarkNew$/^case$", + "example.com/p", + "-args", + "-custom", + "keep", + ]); + }); + + it("keeps independent alternatives for tests of different depths", () => { + const [config] = testsConfigs( + cfg("go", "go", ["test", "."]), + [ + ["p", "TestA", "one"], + ["p", "TestB", "two", "leaf.with.dot"], + ["p", "BenchmarkC"], + ], + "selected", + ); + expect(config!.args).toEqual([ + "test", + "-run", + "^TestA$/^one$|^TestB$/^two$/^leaf\\.with\\.dot$", + "-bench", + "^BenchmarkC$", + "p", + ]); + }); + + it("keeps typed file benchmark flags and clears overridden binary selectors", () => { + const base = { + ...cfg("go", "go", ["test", "chosen_test.go", "-args", "-test.run=Old", "-custom", "keep"]), + params: { + goKind: "test", + scope: "file", + target: "chosen_test.go", + module: "app", + programArgs: "-test.run=Old -custom keep", + }, + }; + const [config] = testsConfigs(base, [["example.com/p", "BenchmarkNew"]], "selected"); + expect(config!.params).toMatchObject({ + target: "chosen_test.go", + pattern: "^$", + toolArgs: "-bench '^BenchmarkNew$'", + programArgs: "-custom keep", + }); + expect(config!.args).toContain("chosen_test.go"); + }); + + it("reruns an entire failed package with its original runner options", () => { + const base = cfg("go", "go", ["test", "-race", "-bench", ".", "./..."]); + const [config] = testsConfigs(base, [["example.com/p"]], "selected"); + expect(config!.args).toEqual(["test", "-race", "-bench", ".", "example.com/p"]); }); }); diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index d03e0f3d4..e2a9bec5b 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -86,23 +86,32 @@ function pytestArgs(base: string[], paths: string[][]): string[] | null { return [...kept, ...ids]; } -function goArgs(base: string[], paths: string[][]): string[] | null { +function goArgs(base: string[], paths: string[][], file?: string): string[] | null { const pkgs = unique(paths.map((p) => p[0]!).filter(Boolean)); - if (!pkgs.length) return null; - const tests = paths.filter((p) => p.length > 1); + if (pkgs.length !== 1) return null; const parts = goTestParts(base); - const top = unique(tests.map((p) => p[1]!)); - const pattern = - tests.length === 1 && tests[0]!.length > 2 - ? tests[0]! + const targets = file ? [file] : pkgs.includes("command-line-arguments") ? parts.targets : pkgs; + if (paths.some((path) => path.length === 1)) return ["test", ...parts.flags, ...targets, ...parts.program]; + const pattern = (selected: string[][]) => + unique( + selected.map((path) => + path .slice(1) .map((name) => `^${escapeRegExp(name)}$`) - .join("/") - : `^(${top.map(escapeRegExp).join("|")})$`; - const run = top.length ? ["-run", pattern] : []; - // A file-scoped run is reported under this synthetic import path; keep its file targets. - const targets = pkgs.includes("command-line-arguments") ? parts.targets : pkgs; - return ["test", ...withoutGoSelection(parts.flags), ...run, ...targets, ...parts.program]; + .join("/"), + ), + ).join("|"); + const benchmarks = paths.filter((path) => path[1]?.startsWith("Benchmark")); + const tests = paths.filter((path) => !path[1]?.startsWith("Benchmark")); + return [ + "test", + ...withoutGoSelection(parts.flags), + "-run", + tests.length ? pattern(tests) : "^$", + ...(benchmarks.length ? ["-bench", pattern(benchmarks)] : []), + ...targets, + ...withoutGoSelection(parts.program), + ]; } function jvmArgs(config: RunConfig, paths: string[][]): string[] | null { @@ -141,8 +150,29 @@ export function leafTestPaths(nodes: readonly TestNode[]): string[][] { /** Config running only `paths` (failed test paths from the results tree), or null when unsupported. */ export function rerunFailedConfig(config: RunConfig, paths: string[][]): RunConfig | null { + const configs = rerunFailedConfigs(config, paths); + return configs.length === 1 ? configs[0]! : null; +} + +export function rerunFailedConfigs(config: RunConfig, paths: string[][]): RunConfig[] { const baseName = config.name.replace(/^Failed: /, ""); - return testsConfig(config, paths, `Failed: ${baseName}`); + return testsConfigs(config, paths, `Failed: ${baseName}`); +} + +/** Go filters apply to every package in an invocation, so each package keeps its own selection. */ +export function testsConfigs(config: RunConfig, paths: string[][], name: string): RunConfig[] { + const groups = new Map(); + for (const path of paths) { + const key = frameworkFor(config) === "go" ? (path[0] ?? "") : ""; + const group = groups.get(key) ?? []; + group.push(path); + groups.set(key, group); + } + return [...groups].flatMap(([pkg, selected]) => { + const derived = testsConfig(config, selected, groups.size > 1 ? `${name} (${pkg})` : name); + if (!derived) return []; + return [{ ...derived, id: groups.size > 1 ? `context:tests:${config.id}:${pkg}` : derived.id }]; + }); } /** Config running only the tests at `paths` (test tree paths), named `name`; null when unsupported. */ @@ -169,7 +199,7 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string): args = phpunitArgs(config.args, { filter: phpunitFilter(paths) }); break; case "go": - args = goArgs(config.args, paths); + args = goArgs(config.args, paths, config.params?.scope === "file" ? config.params.target : undefined); break; case "jvm": args = jvmArgs(config, paths); @@ -191,8 +221,9 @@ export function testsConfig(config: RunConfig, paths: string[][], name: string): config.params.scope === "file" ? { ...config.params, - pattern: args[args.indexOf("-run") + 1] ?? "", - toolArgs: formatArgs(withoutGoSelection(goTestParts(args).flags)), + pattern: args.includes("-run") ? args[args.indexOf("-run") + 1]! : "", + toolArgs: formatArgs(dropFlag(goTestParts(args).flags, ["-run", "-test.run"])), + programArgs: formatArgs(goTestParts(args).program.slice(1)), } : { module: config.params.module ?? "", useTags: config.params.useTags ?? "false" }, }