From 5a48beab074af3f86722bfa08c000a80d7fe5b83 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 9 Nov 2026 17:17:27 +0800 Subject: [PATCH] feat(dotnet): add workspace, NuGet and test workflows (#2303) * feat(run): show .NET test results and rerun failed methods * feat(dotnet): add workspace, NuGet and editor test workflows * fix(dotnet): discard cancelled queries and scan test targets linearly --- src/features/dotnet/Dotnet.module.css | 51 +++ src/features/dotnet/DotnetView.tsx | 225 +++++++++++++ src/features/dotnet/index.ts | 125 ++++++++ src/features/dotnet/store.test.ts | 97 ++++++ src/features/dotnet/store.ts | 315 +++++++++++++++++++ src/features/index.ts | 2 + src/features/run/context.ts | 25 +- src/features/run/csharpTargets.test.ts | 106 +++++++ src/features/run/csharpTargets.ts | 64 ++++ src/features/run/dotnetTypes.ts | 46 ++- src/features/run/producers.ts | 7 +- src/features/run/store.ts | 20 +- src/features/run/targets.ts | 17 +- src/features/run/testing/dotnet.test.ts | 146 +++++++++ src/features/run/testing/dotnet.ts | 95 ++++++ src/features/run/testing/fixtures/mstest.trx | 81 +++++ src/features/run/testing/fixtures/mtp.trx | 85 +++++ src/features/run/testing/fixtures/nunit.trx | 96 ++++++ src/features/run/testing/fixtures/xunit.trx | 113 +++++++ src/features/run/testing/index.ts | 7 +- src/features/run/testing/rerun.ts | 28 +- src/platform/dotnet.test.ts | 91 ++++++ src/platform/dotnet.ts | 285 +++++++++++++++++ src/platform/dotnetProcess.test.ts | 63 ++++ src/platform/locales/text/en.json | 33 +- src/platform/locales/text/zh.json | 32 +- 26 files changed, 2242 insertions(+), 13 deletions(-) create mode 100644 src/features/dotnet/Dotnet.module.css create mode 100644 src/features/dotnet/DotnetView.tsx create mode 100644 src/features/dotnet/index.ts create mode 100644 src/features/dotnet/store.test.ts create mode 100644 src/features/dotnet/store.ts create mode 100644 src/features/run/csharpTargets.test.ts create mode 100644 src/features/run/csharpTargets.ts create mode 100644 src/features/run/testing/dotnet.test.ts create mode 100644 src/features/run/testing/dotnet.ts create mode 100644 src/features/run/testing/fixtures/mstest.trx create mode 100644 src/features/run/testing/fixtures/mtp.trx create mode 100644 src/features/run/testing/fixtures/nunit.trx create mode 100644 src/features/run/testing/fixtures/xunit.trx create mode 100644 src/platform/dotnet.test.ts create mode 100644 src/platform/dotnet.ts create mode 100644 src/platform/dotnetProcess.test.ts diff --git a/src/features/dotnet/Dotnet.module.css b/src/features/dotnet/Dotnet.module.css new file mode 100644 index 000000000..1c7f72cc3 --- /dev/null +++ b/src/features/dotnet/Dotnet.module.css @@ -0,0 +1,51 @@ +.view { + display: flex; + flex-direction: column; + gap: var(--space-3); + padding: var(--space-3); + overflow: auto; + height: 100%; + color: var(--fg); +} +.view label { + display: flex; + flex-direction: column; + gap: var(--space-2); + min-width: 0; +} +.view select { + color: var(--fg); + background: var(--bg-elev); + border: 1px solid var(--border); + padding: var(--space-2); + max-width: 100%; +} +.toolbar { + display: flex; + gap: var(--space-2); + flex-wrap: wrap; +} +.list { + padding: 0; + margin: var(--space-2) 0; + list-style: none; +} +.list li { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: var(--space-2); + padding: var(--space-2) 0; +} +.list li span { + flex: 1 1 100%; + overflow-wrap: anywhere; +} +.error { + color: var(--danger); + white-space: pre-wrap; +} +.output { + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/src/features/dotnet/DotnetView.tsx b/src/features/dotnet/DotnetView.tsx new file mode 100644 index 000000000..13c1493fd --- /dev/null +++ b/src/features/dotnet/DotnetView.tsx @@ -0,0 +1,225 @@ +import { executeAction } from "@/platform/actions"; +import { useState } from "react"; +import { useProject } from "@/platform/project"; +import { useTx } from "@/platform/i18n"; +import { Button, Input } from "@/ui"; +import { useDotnet } from "./store"; +import styles from "./Dotnet.module.css"; + +export function DotnetView() { + const s = useDotnet(); + const tx = useTx(); + const root = useProject((state) => state.project?.root ?? ""); + const relative = (path: string) => (root && path.startsWith(root + "/") ? path.slice(root.length + 1) : path); + const [versions, setVersions] = useState>({}); + const results = new Map(); + for (const p of s.found) results.set(p.id, [...new Set([...(results.get(p.id) ?? []), p.version])]); + const project = s.model.projects.find((p) => p.path === s.selected); + const action = (id: string, arg?: unknown) => void executeAction(`dotnet.${id}`, arg); + return ( +
+
+ + {s.busy && } +
+ {s.error && ( +
+ {s.error} +
+ )} + {s.model.errors?.map((error) => ( +
+ {tx("Project evaluation failed")} + {error} +
+ ))} + {s.model.solutions.map((solution) => ( +
+ {solution.path.split("/").at(-1)} + {solution.projects.map((path) => ( + + ))} +
+ ))} + + {project && ( + <> +
+ + +
+
+ {["build", "restore", "clean"].map((op) => ( + + ))} +
+
+ {tx("Project references")} + {project.references.map((path) => ( + + ))} +
+
+ {tx("NuGet packages")} + +
{ + e.preventDefault(); + action("search", { query: s.query }); + }} + > + useDotnet.setState({ query: e.target.value })} + /> + +
+
    + {[...results].map(([id, available]) => { + const version = available.includes(versions[id] ?? "") ? versions[id]! : available[0]!; + return ( +
  • + {id} + + + +
  • + ); + })} +
+
    + {s.packages.map((p) => ( +
  • + + {p.id} {p.version} {p.transitive ? tx("Transitive") : ""} + + {!p.transitive && ( + <> + + + + )} +
  • + ))} +
+
+ {project.test && ( +
+ {tx("Tests")} +
+ + +
+ {project.runner !== "vstest" && !project.trx && ( +
+ {tx("Install the TRX reporting extension to show MTP results in the test panel.")} + +
+ )} +
    + {s.tests.map((test) => ( +
  • + {test} + +
  • + ))} +
+
+ )} + + )} + {s.output && ( +
+ {tx("Output")} +
{s.output}
+
+ )} +
+ ); +} diff --git a/src/features/dotnet/index.ts b/src/features/dotnet/index.ts new file mode 100644 index 000000000..ae2c85f27 --- /dev/null +++ b/src/features/dotnet/index.ts @@ -0,0 +1,125 @@ +import { registerActions } from "@/platform/actions"; +import { useProject } from "@/platform/project"; +import { registerToolWindow, toolWindowService } from "@/workbench/contributions"; +import { DotnetView } from "./DotnetView"; +import { + buildDotnet, + cancelDotnet, + changePackage, + discoverTests, + installTrx, + listPackages, + reloadDotnet, + resetDotnet, + runTests, + searchPackages, + selectDotnet, +} from "./store"; + +export function activate() { + const offWindow = registerToolWindow({ + id: "dotnet", + title: ".NET", + icon: "file-csharp", + anchor: "right", + stripe: "onUse", + component: DotnetView, + }); + const show = () => toolWindowService().show("dotnet", true); + const offActions = registerActions([ + { + id: "dotnet.show", + title: ".NET Workspace", + category: "Build", + when: "projectOpen", + run: () => { + show(); + return reloadDotnet(); + }, + }, + { + id: "dotnet.reload", + title: "Reload .NET Workspace", + category: "Build", + when: "projectOpen", + run: () => reloadDotnet(), + }, + { id: "dotnet.cancel", title: "Cancel .NET Operation", category: "Build", run: () => cancelDotnet() }, + { + id: "dotnet.select", + title: "Select .NET Project", + category: "Build", + hidden: true, + run: ({ arg }) => { + const a = arg as { path: string; configuration?: string; framework?: string }; + return selectDotnet(a.path, a.configuration, a.framework); + }, + }, + ...(["build", "restore", "clean"] as const).map((op) => ({ + id: `dotnet.${op}`, + title: op === "build" ? "Build .NET Project" : op === "restore" ? "Restore .NET Project" : "Clean .NET Project", + category: "Build", + when: "projectOpen", + run: () => buildDotnet(op), + })), + { + id: "dotnet.packages", + title: "Refresh NuGet Packages", + category: "Build", + when: "projectOpen", + run: () => listPackages(), + }, + { + id: "dotnet.search", + title: "Search NuGet Packages", + category: "Build", + hidden: true, + run: ({ arg }) => { + const a = arg as { query: string; exact?: boolean }; + return searchPackages(a.query, a.exact); + }, + }, + { + id: "dotnet.packageChange", + title: "Change NuGet Package", + category: "Build", + hidden: true, + run: ({ arg }) => { + const a = arg as { id: string; version: string | null }; + return changePackage(a.id, a.version); + }, + }, + { + id: "dotnet.discover", + title: "Discover .NET Tests", + category: "Run", + when: "projectOpen", + run: () => discoverTests(), + }, + { + id: "dotnet.test", + title: "Run .NET Tests", + category: "Run", + when: "projectOpen", + run: async ({ arg }) => { + await runTests(typeof arg === "string" ? arg : undefined); + }, + }, + { + id: "dotnet.installTrx", + title: "Install TRX reporting", + category: "Build", + when: "projectOpen", + run: () => installTrx(), + }, + ]); + const offProject = useProject.subscribe((s, old) => { + if (s.project?.root !== old.project?.root) resetDotnet(); + }); + return () => { + resetDotnet(); + offProject(); + offActions(); + offWindow(); + }; +} diff --git a/src/features/dotnet/store.test.ts b/src/features/dotnet/store.test.ts new file mode 100644 index 000000000..8651c7b3c --- /dev/null +++ b/src/features/dotnet/store.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cancelDotnet, discoveredTests, packageList, selectDotnet, resetDotnet, useDotnet } from "./store"; +import * as platform from "@/platform/dotnet"; + +afterEach(() => { + resetDotnet(); + vi.restoreAllMocks(); +}); + +it("recovers a project that failed the initial workspace scan", async () => { + const project: platform.DotnetProject = { + path: "/repo/Test.csproj", + name: "Test", + frameworks: ["net9.0"], + configurations: ["Debug"], + references: [], + packages: [], + test: true, + runner: "vstest", + adapter: "mstest", + trx: false, + }; + vi.spyOn(platform, "evaluateProject").mockResolvedValue(project); + useDotnet.setState({ model: { projects: [], solutions: [], errors: [`${project.path}: failed`] } }); + await selectDotnet(project.path); + expect(useDotnet.getState().model).toEqual({ projects: [project], solutions: [], errors: [] }); +}); + +it("ignores an evaluation completed after switching workspaces", async () => { + let complete!: (p: platform.DotnetProject) => void; + vi.spyOn(platform, "evaluateProject").mockImplementation( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const pending = selectDotnet("/repo/Test.csproj"); + resetDotnet(); + complete({ path: "/repo/Test.csproj", frameworks: ["net9.0"] } as platform.DotnetProject); + await pending; + expect(useDotnet.getState().selected).toBe(""); + expect(useDotnet.getState().busy).toBe(false); +}); + +it("ignores an evaluation completed after cancellation", async () => { + let complete!: (p: platform.DotnetProject) => void; + vi.spyOn(platform, "evaluateProject").mockImplementation( + () => + new Promise((resolve) => { + complete = resolve; + }), + ); + const pending = selectDotnet("/repo/Test.csproj"); + cancelDotnet(); + complete({ path: "/repo/Test.csproj", frameworks: ["net9.0"] } as platform.DotnetProject); + await pending; + expect(useDotnet.getState().selected).toBe(""); + expect(useDotnet.getState().busy).toBe(false); + expect(useDotnet.getState().error).toBe(""); +}); + +describe("real dotnet discovery output", () => { + it.each([ + "The following Tests are available:\n Passes\n Parameterized (1)\n\nProcess finished with exit code 0", + "The following Tests are available:\nPasses\nParameterized (1)\nTests succeeded: 'Tests.dll' [net9.0|x64]", + "Discovering tests from Tests.dll (net10.0|x64)\nDiscovered 2 tests in assembly - Tests.dll (net10.0|x64)\n Passes\n Parameterized (1)\n\nDiscovered 2 tests.", + ])("extracts names without host and runner summaries", (output) => { + expect(discoveredTests(output)).toEqual(["Passes", "Parameterized (1)"]); + }); + it("merges target frameworks and assemblies without duplicating names", () => { + expect( + discoveredTests( + "The following Tests are available:\n A.Tests.One\n\nThe following Tests are available:\n A.Tests.One\n B.Tests.Two\n", + ), + ).toEqual(["A.Tests.One", "B.Tests.Two"]); + }); + it("keeps direct and transitive dependencies across frameworks", () => { + expect( + packageList({ + projects: [ + { + frameworks: [ + { + topLevelPackages: [{ id: "Direct", resolvedVersion: "1.0" }], + transitivePackages: [{ id: "Child", resolvedVersion: "2.0" }], + }, + { transitivePackages: [{ id: "Direct", resolvedVersion: "1.0" }] }, + ], + }, + ], + }), + ).toEqual([ + { id: "Direct", version: "1.0", transitive: false, latest: undefined }, + { id: "Child", version: "2.0", transitive: true, latest: undefined }, + ]); + }); +}); diff --git a/src/features/dotnet/store.ts b/src/features/dotnet/store.ts new file mode 100644 index 000000000..6062ce3c3 --- /dev/null +++ b/src/features/dotnet/store.ts @@ -0,0 +1,315 @@ +import { create } from "zustand"; +import { executeAction } from "@/platform/actions"; +import { + dotnet, + dotnetWorkspace, + evaluateProject, + dotnetTestArgs, + jsonOutput, + type DotnetWorkspace, +} from "@/platform/dotnet"; +import { dirname, useProject } from "@/platform/project"; +import type { RunConfig } from "@/platform/ipc"; +import { tx } from "@/platform/i18n"; + +export interface Package { + id: string; + version: string; + latest?: string; + transitive?: boolean; +} +interface State { + model: DotnetWorkspace; + selected: string; + framework: string; + configuration: string; + busy: boolean; + error: string; + output: string; + query: string; + packages: Package[]; + found: Package[]; + tests: string[]; +} +const initial: State = { + model: { projects: [], solutions: [] }, + selected: "", + framework: "", + configuration: "Debug", + busy: false, + error: "", + output: "", + query: "", + packages: [], + found: [], + tests: [], +}; +export const useDotnet = create(() => initial); +let controller: AbortController | undefined; +let generation = 0; +export function resetDotnet() { + generation++; + controller?.abort(); + useDotnet.setState(initial); +} +export const selectedProject = () => + useDotnet.getState().model.projects.find((p) => p.path === useDotnet.getState().selected); + +async function task(work: (signal: AbortSignal) => Promise>) { + controller?.abort(); + const current = ++generation; + const abort = new AbortController(); + controller = abort; + useDotnet.setState({ busy: true, error: "" }); + try { + const patch = await work(abort.signal); + if (current === generation && !abort.signal.aborted) useDotnet.setState(patch); + } catch (e) { + if (current === generation && !abort.signal.aborted) useDotnet.setState({ error: String(e) }); + } finally { + if (current === generation) { + controller = undefined; + useDotnet.setState({ busy: false }); + } + } +} +export function cancelDotnet() { + controller?.abort(); +} + +export function reloadDotnet() { + const root = useProject.getState().project?.root; + if (!root) return; + return task(async (signal) => { + const model = await dotnetWorkspace(root, signal); + const old = useDotnet.getState(); + const project = model.projects.find((p) => p.path === old.selected) ?? model.projects[0]; + return { + model, + selected: project?.path ?? "", + configuration: "Debug", + framework: project?.frameworks[0] ?? "", + packages: [], + found: [], + tests: [], + }; + }); +} +export function selectDotnet(path: string, configuration?: string, framework?: string) { + const state = useDotnet.getState(); + return task(async (signal) => { + const project = await evaluateProject(path, configuration ?? state.configuration, framework ?? "", signal); + return { + selected: path, + configuration: configuration ?? state.configuration, + framework: framework ?? project.frameworks[0] ?? "", + model: { + ...state.model, + projects: state.model.projects.some((p) => p.path === path) + ? state.model.projects.map((p) => (p.path === path ? project : p)) + : [...state.model.projects, project], + errors: state.model.errors?.filter((error) => !error.startsWith(`${path}:`)), + }, + packages: [], + found: [], + tests: [], + }; + }); +} + +export function buildDotnet(operation: "build" | "restore" | "clean") { + const p = selectedProject(); + if (!p) return; + const { framework, configuration } = useDotnet.getState(); + return task(async (signal) => { + const output = await dotnet( + [ + operation, + p.path, + ...(operation === "restore" + ? [`-p:Configuration=${configuration}`] + : ["--configuration", configuration, ...(framework ? ["--framework", framework] : [])]), + ], + dirname(p.path), + signal, + ); + const project = await evaluateProject(p.path, configuration, framework, signal); + const model = useDotnet.getState().model; + return { + output, + model: { ...model, projects: model.projects.map((item) => (item.path === p.path ? project : item)) }, + }; + }); +} + +interface PackageJson { + id: string; + resolvedVersion?: string; + requestedVersion?: string; + latestVersion?: string; +} +interface PackagesJson { + projects?: Array<{ frameworks?: Array<{ topLevelPackages?: PackageJson[]; transitivePackages?: PackageJson[] }> }>; +} +export function packageList(data: PackagesJson): Package[] { + const result = new Map(); + for (const p of data.projects ?? []) + for (const f of p.frameworks ?? []) { + for (const [key, transitive] of [ + ["topLevelPackages", false], + ["transitivePackages", true], + ] as const) + for (const pkg of f[key] ?? []) + result.set(`${pkg.id}:${pkg.resolvedVersion}`, { + id: pkg.id, + version: pkg.resolvedVersion ?? pkg.requestedVersion ?? "", + latest: pkg.latestVersion, + transitive: transitive && result.get(`${pkg.id}:${pkg.resolvedVersion}`)?.transitive !== false, + }); + } + return [...result.values()]; +} +export function listPackages() { + const p = selectedProject(); + if (!p) return; + return task(async (signal) => ({ + packages: packageList( + jsonOutput( + await dotnet(["list", p.path, "package", "--include-transitive", "--format", "json"], dirname(p.path), signal), + ), + ), + })); +} +export function searchPackages(query: string, exact = false) { + const p = selectedProject(); + if (!p || !query.trim()) return; + return task(async (signal) => { + const result = jsonOutput<{ + problems?: Array<{ text?: string }>; + searchResult?: Array<{ packages?: Array<{ id: string; latestVersion?: string; version?: string }> }>; + }>( + await dotnet( + ["package", "search", query.trim(), "--format", "json", "--take", "30", ...(exact ? ["--exact-match"] : [])], + dirname(p.path), + signal, + ), + ); + const found = (result.searchResult ?? []).flatMap((source) => + (source.packages ?? []).map((pkg) => ({ id: pkg.id, version: pkg.latestVersion ?? pkg.version ?? "" })), + ); + if (result.problems?.length) throw new Error(JSON.stringify(result.problems)); + if (exact) found.reverse(); + return { found }; + }); +} +export function changePackage(id: string, version: string | null) { + const p = selectedProject(); + if (!p || !id) return; + return task(async (signal) => { + if (id === "Microsoft.Testing.Extensions.TrxReport" && version === "") { + await dotnet(["restore", p.path], dirname(p.path), signal); + const restored = await evaluateProject( + p.path, + useDotnet.getState().configuration, + useDotnet.getState().framework, + signal, + ); + if (!restored.platformVersion) + throw new Error(tx("Microsoft.Testing.Platform was not found in the restored project.")); + version = restored.platformVersion; + } + const output = await dotnet( + version === null + ? ["remove", p.path, "package", id] + : ["add", p.path, "package", id, ...(version ? ["--version", version] : [])], + dirname(p.path), + signal, + ); + if (version === null) await dotnet(["restore", p.path], dirname(p.path), signal); + const project = await evaluateProject( + p.path, + useDotnet.getState().configuration, + useDotnet.getState().framework, + signal, + ); + const packages = packageList( + jsonOutput( + await dotnet(["list", p.path, "package", "--include-transitive", "--format", "json"], dirname(p.path), signal), + ), + ); + return { + output, + packages, + model: { + ...useDotnet.getState().model, + projects: useDotnet.getState().model.projects.map((x) => (x.path === p.path ? project : x)), + }, + }; + }); +} +export function discoveredTests(output: string): string[] { + const lines = output.replace(new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g"), "").split(/\r?\n/); + const tests = new Set(); + let listing = false; + for (const line of lines) { + if (/following Tests are available|Discovered tests:|Discovered \d+ tests? in assembly/i.test(line)) { + listing = true; + continue; + } + if (!listing) continue; + const name = line.trim(); + if ( + !name || + /^(?:Process finished|Tests succeeded:|Tests failed:|Test run summary:|Discovered \d+ tests?\.|Total:)/.test(name) + ) { + listing = false; + continue; + } + if (!/^(?:at |\[|.*\.dll\b)/.test(name)) tests.add(name); + } + return [...tests]; +} +export function discoverTests() { + const p = selectedProject(); + if (!p) return; + const s = useDotnet.getState(); + return task(async (signal) => { + const output = await dotnet( + dotnetTestArgs(p, s.framework, ["--list-tests"], s.configuration), + dirname(p.path), + signal, + ); + return { output, tests: discoveredTests(output) }; + }); +} +export function runTests(test?: string) { + const p = selectedProject(); + if (!p) return; + const s = useDotnet.getState(); + const method = test?.replace(/\(.*$/, "").trim(); + const config: RunConfig = { + id: `dotnet:${p.path}:tests`, + name: test ?? p.name, + kind: "dotnet-test", + command: "dotnet", + args: [], + cwd: dirname(p.path), + env: {}, + detected: true, + isTest: true, + debugAdapter: null, + params: { + project: p.path, + framework: s.framework, + configuration: s.configuration, + runner: p.runner, + adapter: p.adapter, + programArgs: "", + ...(method ? { testMethods: JSON.stringify([method]) } : {}), + }, + }; + return executeAction("run.runConfig", config); +} +export function installTrx() { + return changePackage("Microsoft.Testing.Extensions.TrxReport", ""); +} diff --git a/src/features/index.ts b/src/features/index.ts index 261fe35d6..6ce8624d9 100644 --- a/src/features/index.ts +++ b/src/features/index.ts @@ -21,6 +21,7 @@ import { } from "./vcs"; import { activate as terminal } from "./terminal"; import { activate as run } from "./run"; +import { activate as dotnet } from "./dotnet"; import { activate as runAnything } from "./run-anything"; import { activate as macros } from "./macros"; import { activate as debuggerFeature } from "./debugger"; @@ -70,6 +71,7 @@ const features: Array<[string, Activate]> = [ ["vcs", vcs], ["terminal", terminal], ["run", run], + ["dotnet", dotnet], ["run-anything", runAnything], ["macros", macros], ["debugger", debuggerFeature], diff --git a/src/features/run/context.ts b/src/features/run/context.ts index 41e45731d..bad81637e 100644 --- a/src/features/run/context.ts +++ b/src/features/run/context.ts @@ -12,11 +12,14 @@ import { isWindows } from "./jvmTypes"; import { typeFor } from "./types"; import { runTemplate } from "./runTemplates"; import { appendScriptArgs } from "./scriptArgs"; +import { csharpTargets } from "./csharpTargets"; export type FileRole = "test" | "main" | "script" | null; /** `django`: a Django project, whose default test module is `tests.py` (elsewhere a script). */ export function fileRole(relPath: string, content?: string, windows = isWindows(), django = false): FileRole { + if (/\.cs$/i.test(relPath) && content && csharpTargets(content).some((target) => target.kind === "test")) + return "test"; const name = relPath.split("/").pop() ?? relPath; if ( /\.(test|spec)\.[cm]?[jt]sx?$/.test(name) || @@ -44,6 +47,7 @@ const stem = (p: string) => (p.split("/").pop() ?? p).replace(/\.[^.]+$/, ""); /** Kinds whose test configs can run a file of this extension. */ function testKinds(relPath: string): string[] { + if (/\.cs$/i.test(relPath)) return ["dotnet-test"]; if (/\.[cm]?[jt]sx?$/.test(relPath)) return ["npm", "deno", "bun"]; if (/\.py$/.test(relPath)) return ["pytest", "python", "django-test"]; if (/\.php$/.test(relPath)) return ["phpunit"]; @@ -57,6 +61,8 @@ function testKinds(relPath: string): string[] { /** The module owning a configuration's targets, relative to the project root. */ export function configScope(config: RunConfig): string { + if (config.kind === "dotnet" || config.kind === "dotnet-test") + return dirOf((config.params?.project || "").replace(/\\/g, "/")); return ( config.kind === "go" ? (config.params?.module ?? config.module ?? config.cwd ?? "") @@ -210,11 +216,26 @@ export function testBasesFor(relPath: string, configs: RunConfig[]): RunConfig[] * File-level test configuration for `relPath` (the closest matching test config, * or `base`, narrowed to the file). */ -export function testConfigFor(relPath: string, configs: RunConfig[], base?: RunConfig): RunConfig | null { +export function testConfigFor( + relPath: string, + configs: RunConfig[], + base?: RunConfig, + content?: string, +): RunConfig | null { base ??= testBasesFor(relPath, configs)[0]; if (!base) return null; const file = relToCwd(relPath, base); switch (base.kind) { + case "dotnet-test": { + const methods = csharpTargets(content ?? "") + .filter((t) => t.kind === "test") + .map((t) => t.path.join(".")); + return methods.length + ? derived(base, relPath, { + params: { ...base.params, testMethods: JSON.stringify(methods) }, + }) + : null; + } case "npm": case "bun": { const args = appendScriptArgs(base.command, base.args, [file]); @@ -409,7 +430,7 @@ export function contextConfig( windows = isWindows(), ): RunConfig | null { const role = fileRole(relPath, content, windows, hasDjangoTests(configs)); - if (role === "test") return testConfigFor(relPath, configs); + if (role === "test") return testConfigFor(relPath, configs, undefined, content); if (role === "main") return mainConfigFor(relPath, configs) ?? scriptConfigFor(relPath, configs, windows, content); if (role === "script") return scriptConfigFor(relPath, configs, windows, content); return null; diff --git a/src/features/run/csharpTargets.test.ts b/src/features/run/csharpTargets.test.ts new file mode 100644 index 000000000..e38086c33 --- /dev/null +++ b/src/features/run/csharpTargets.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from "vitest"; +import { csharpTargets } from "./csharpTargets"; +import { targetConfig } from "./targets"; +import { produceContext, produceFile, OPTIMISTIC } from "./producers"; +import { dotnetTestType } from "./dotnetTypes"; +import type { RunConfig } from "@/platform/ipc"; + +const config = (project: string): RunConfig => ({ + id: project, + name: project, + command: "dotnet", + args: ["test", project], + kind: "dotnet-test", + params: { project, framework: "net9.0" }, + cwd: null, + env: {}, + detected: true, + isTest: true, + debugAdapter: null, +}); +const source = `namespace Demo.Tests; +public class Calculator { + // [Fact] public void NotATest() {} + [Xunit.Fact] + public void Adds() { var s = "}"; } + [Theory, InlineData(1)] + public async Task Values(int n) { } + public class Nested { + [TestMethod] public void Child() {} + } +}`; +describe("C# editor test targets", () => { + it("tracks lines across multiline tokens and separate test classes", () => { + const text = `namespace Demo { +/* a comment + across lines */ +class First { + [Fact] + void One( + ) {} +} +class Second { + [TestCase( + 1)] + void Two(int value) {} +} +}`; + expect(csharpTargets(text).map((t) => [t.line, t.path])).toEqual([ + [3, ["Demo.First"]], + [5, ["Demo.First", "One"]], + [8, ["Demo.Second"]], + [11, ["Demo.Second", "Two"]], + ]); + }); + it("runs only methods declared in the chosen file", () => { + const result = produceFile( + { path: "/repo/two/Tests.cs", root: "/repo", content: source, configs: [config("/repo/two/Two.csproj")] }, + OPTIMISTIC, + ); + expect(JSON.parse(result!.config!.params!.testMethods!)).toEqual([ + "Demo.Tests.Calculator.Adds", + "Demo.Tests.Calculator.Values", + "Demo.Tests.Calculator+Nested.Child", + ]); + }); + it("finds qualified and multiline attributes, nested types, and skips literals/comments", () => { + expect( + csharpTargets(source) + .filter((t) => t.kind === "test") + .map((t) => [t.line, t.path]), + ).toEqual([ + [4, ["Demo.Tests.Calculator", "Adds"]], + [6, ["Demo.Tests.Calculator", "Values"]], + [8, ["Demo.Tests.Calculator+Nested", "Child"]], + ]); + expect(csharpTargets("namespace A { namespace B { class C { [Test] void T() {} } } }").at(-1)?.path).toEqual([ + "A.B.C", + "T", + ]); + }); + it("selects the owning project when same-named tests exist in sibling projects", () => { + const targets = csharpTargets(source); + const configs = [config("one/One.csproj"), config("two/Two.csproj")]; + const result = targetConfig( + targets.find((t) => t.name === "Adds")!, + "two/Tests.cs", + configs, + source, + )!; + expect(result.params?.project).toBe("two/Two.csproj"); + expect(dotnetTestType.commandLine(result.params!)?.args).toContain("FullyQualifiedName=Demo.Tests.Calculator.Adds"); + }); + it("normalizes absolute workspace project paths for caret/gutter launch", () => { + const result = produceContext( + { + path: "/repo/two/Tests.cs", + root: "/repo", + content: source, + line: 4, + configs: [config("/repo/one/One.csproj"), config("/repo/two/Two.csproj")], + }, + OPTIMISTIC, + ); + expect(result?.config?.params?.project).toBe("two/Two.csproj"); + }); +}); diff --git a/src/features/run/csharpTargets.ts b/src/features/run/csharpTargets.ts new file mode 100644 index 000000000..827598b73 --- /dev/null +++ b/src/features/run/csharpTargets.ts @@ -0,0 +1,64 @@ +import type { RunTarget } from "./targets"; + +/** Mask comments/literals without changing UTF-16 offsets or line breaks. */ +export function csharpTargets(text: string): RunTarget[] { + const code = text.replace( + /\/\*[\s\S]*?\*\/|\/\/[^\n]*|@"(?:""|[^"])*"|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'/g, + (value) => value.replace(/[^\r\n]/g, " "), + ); + const tokens = + /\bnamespace\s+([\w.]+)\s*([;{])|\b(?:class|record|struct)\s+(\w+)[^;{}]*\{|\[([^\]]*)\]|\b(\w+)\s*\([^;{}]*\)\s*(?=\{|=>)|[{};]/g; + const scopes: Array<{ name: string; depth: number; line: number; kind: "namespace" | "class" }> = []; + const out: RunTarget[] = []; + const suites = new Set(); + let depth = 0; + let line = 0; + let offset = 0; + let fileNamespace = ""; + let test = false; + for (const match of code.matchAll(tokens)) { + const value = match[0]; + while (offset < match.index) { + if (code.charCodeAt(offset) === 10) line++; + offset++; + } + if (match[1]) { + if (match[2] === ";") fileNamespace = match[1]; + else scopes.push({ name: match[1], depth: ++depth, line, kind: "namespace" }); + } else if (match[3]) { + scopes.push({ name: match[3], depth: ++depth, line, kind: "class" }); + test = false; + } else if (match[4] !== undefined) { + test ||= + /(?:^|[.,\s])(?:Fact|Theory|Test|TestCase|TestCaseSource|TestMethod|DataTestMethod)(?:Attribute)?\b/.test( + match[4], + ); + } else if (match[5]) { + if (test && scopes.some((s) => s.kind === "class")) { + const className = [ + fileNamespace, + ...scopes.filter((s) => s.kind === "namespace").map((s) => s.name), + scopes + .filter((s) => s.kind === "class") + .map((s) => s.name) + .join("+"), + ] + .filter(Boolean) + .join("."); + const owner = scopes.filter((s) => s.kind === "class").at(-1)!; + if (!suites.has(className)) { + suites.add(className); + out.push({ line: owner.line, kind: "suite", name: owner.name, path: [className] }); + } + out.push({ line, kind: "test", name: match[5], path: [className, match[5]] }); + } + test = false; + } else if (value === "{") depth++; + else if (value === "}") { + depth--; + while (scopes.length && scopes.at(-1)!.depth > depth) scopes.pop(); + test = false; + } else test = false; + } + return out; +} diff --git a/src/features/run/dotnetTypes.ts b/src/features/run/dotnetTypes.ts index 534ed5733..e9ca7a8c5 100644 --- a/src/features/run/dotnetTypes.ts +++ b/src/features/run/dotnetTypes.ts @@ -2,6 +2,7 @@ import { call, commands, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; import type { RunConfigTypeContribution } from "@/workbench/contributions"; import { parseArgs } from "./configs"; +import { prepareDotnetTest } from "@/platform/dotnet"; function profile(p: Readonly>): { applicationUrl?: string; @@ -20,6 +21,7 @@ export function dotnetCommand(p: Readonly>, test = false) args: [ ...(test ? ["test", p.project || ""] : ["run", "--project", p.project || ""]), ...(p.framework ? ["--framework", p.framework] : []), + ...(p.configuration ? ["--configuration", p.configuration] : []), ...(!test ? (p.launchProfile ? ["--launch-profile", p.launchProfile] : ["--no-launch-profile"]) : []), ...(p.programArgs?.trim() ? ["--", ...parseArgs(p.programArgs)] : []), ], @@ -125,10 +127,50 @@ export const dotnetTestType: RunConfigTypeContribution = { fields: [...dotnetProjectType.fields.slice(0, 2), { key: "programArgs", label: "Test arguments", mono: true }], commandLine(p) { const line = dotnetCommand({ ...p, programArgs: "" }, true); - return { ...line, args: [...line.args, ...parseArgs(p.programArgs || "")] }; + if (p.runner === "mtp") line.args.splice(1, 0, "--project"); + if (p.runner === "mtp-legacy") + line.args.push("-p:TestingPlatformCaptureOutput=false", "-p:TestingPlatformDotnetTestSupport=true"); + let extra = parseArgs(p.programArgs || ""); + if (p.runner === "mtp-legacy") { + const boundary = extra.indexOf("--"); + if (boundary >= 0) { + line.args.push(...extra.slice(0, boundary)); + extra = extra.slice(boundary + 1); + } else { + const runner: string[] = []; + const options = new Set(["--configuration", "-c", "--framework", "-f", "--runtime", "-r", "--verbosity", "-v"]); + for (let i = 0; i < extra.length; i++) { + const arg = extra[i]!; + if (options.has(arg)) line.args.push(arg, extra[++i] ?? ""); + else if (/^(?:--no-build$|--no-restore$|-p:|-property:|--property:)/.test(arg)) line.args.push(arg); + else runner.push(arg); + } + extra = runner; + } + } + if (p.testMethods) { + const methods = JSON.parse(p.testMethods) as string[]; + const flags = new Set(["--filter", "--filter-method"]); + extra = extra.filter((a, i, all) => !flags.has(a) && !flags.has(all[i - 1] ?? "") && !a.startsWith("--filter=")); + const selection = + p.runner !== "vstest" && p.runner && p.adapter === "xunit" + ? methods.flatMap((m) => ["--filter-method", m.includes(".") ? m : `*.${m}`]) + : [ + "--filter", + methods + .map((m) => `${m.includes(".") ? "FullyQualifiedName" : "Name"}=${m.replace(/[\\()&|=!~]/g, "\\$&")}`) + .join("|"), + ]; + const boundary = extra.indexOf("--"); + extra.splice(boundary < 0 ? extra.length : boundary, 0, ...selection); + } + return { + ...line, + args: [...line.args, ...(p.runner === "mtp-legacy" && extra[0] !== "--" ? ["--"] : []), ...extra], + }; }, async prepareLaunch(config) { - const resolved = await call(commands.runDotnetResolve(config, false)); + const resolved = await prepareDotnetTest(await call(commands.runDotnetResolve(config, false))); return { ...resolved, ...dotnetTestType.commandLine(resolved.params ?? {}) }; }, }; diff --git a/src/features/run/producers.ts b/src/features/run/producers.ts index 4bea19ef5..d02ca572b 100644 --- a/src/features/run/producers.ts +++ b/src/features/run/producers.ts @@ -126,6 +126,9 @@ function scopedInput>(input: T) ...c, cwd: c.cwd == null ? c.cwd : relative(c.cwd), ...(c.module == null ? {} : { module: relative(c.module) }), + ...(c.kind.startsWith("dotnet") && c.params?.project + ? { params: { ...c.params, project: relative(c.params.project) } } + : {}), ...(c.kind === "go" && c.params?.module != null ? { params: { ...c.params, module: relative(c.params.module) } } : {}), @@ -743,7 +746,7 @@ export function produceFile(input: ContextInput, env: ToolEnv): Produced | null // Project-aware: test files through the project's test runner, Cargo / Go / Gradle / Maven mains. const role = fileRole(rel, input.content, undefined, hasDjangoTests(configs)); if (role === "test") { - const found = testConfigFor(rel, configs); + const found = testConfigFor(rel, configs, undefined, input.content); const test = found && goTestNamed(found, input); if (test) return { name: test.name, scope: "file", config: test }; } @@ -1102,7 +1105,7 @@ export function produceContextChoices(input: ContextInput, env: ToolEnv): Produc for (const base of bases) { const found = element ? targetConfig(element, rel, [...input.configs], input.content, base) - : testConfigFor(rel, [...input.configs], base); + : testConfigFor(rel, [...input.configs], base, input.content); const config = found && goTestNamed(found, input, element?.name); if (!config || out.some((o) => o.config?.id === config.id)) continue; out.push({ name: config.name, scope: produced.scope, config, detail: base.name }); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 410dc9adb..e8559d149 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -45,6 +45,7 @@ import { } from "./testing"; import { PluginTestOutput } from "./testing/plugin"; import { GoTestOutput } from "./testing/go"; +import { DotnetReports } from "./testing/dotnet"; import { moveToReportedClasses } from "./testing/jvm"; import { RubyTestOutput } from "./testing/ruby"; import { TeamcityOutput } from "./testing/teamcity"; @@ -94,6 +95,7 @@ export interface RunSession { } interface SessionRuntime { + dotnetReports?: DotnetReports; resolveRequest?: { id: RequestId; controller: AbortController }; console: ConsoleModel; tests: TestResults | null; @@ -653,6 +655,7 @@ function newRuntime(config: RunConfig): SessionRuntime { if (pluginTests && kind !== "system") pluginTests.feed(text); if (parser && tests && kind !== "system" && !(kind === "stdout" && stdout)) tests.apply(parser.feed(text)); if (kind !== "system") for (const l of runtime.lineListeners) l(text); + if (kind !== "system") runtime.dotnetReports?.feed(text); }, }), queue: [], @@ -894,12 +897,16 @@ export async function startRun( if ((options.launch || launch.isTest) && launch.emulateTerminal) launch = { ...launch, emulateTerminal: false }; if (launch.emulateTerminal) rt.terminal = new TerminalOutput(); patchSession(sessionId, { terminal: !!launch.emulateTerminal }); - const started = + let started = launch.params?.framework === "plugin" ? launch : launch.isTest ? await gradleTestLaunch(withTestReporting(launch)) : await gradleBootLaunch(launch); + if (launch.isTest && frameworkFor(launch) === "dotnet" && launch.params?.framework !== "plugin") { + rt.dotnetReports = new DotnetReports(); + started = rt.dotnetReports.launch(started); + } if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null; const processStarted = performance.now(); const info = await startWithRemedies(started, () => call(commands.runStart(started, channel))); @@ -1028,6 +1035,15 @@ function finishSession(sessionId: string, code: number | null) { if (runtimes.get(sessionId) !== rt) return; if (rt.tests && (rt.parser || rt.pluginTests)) { rt.tests.apply(rt.parser?.end?.() ?? []); + if (rt.dotnetReports) { + try { + const events = await rt.dotnetReports.read((path) => call(commands.fsReadText(path))); + if (runtimes.get(sessionId) !== rt) return; + rt.tests.apply(events); + } catch (e) { + rt.console.append({ kind: "system", text: tx`Could not read .NET test results: ${String(e)}\n` }); + } + } // Surefire / Gradle print only failures and class totals: per-test results come from the XML reports. if (frameworkFor(session.config) === "jvm") { const reports = await jvmReports(session); @@ -1120,6 +1136,8 @@ export function onRunExited(runId: RunId, code: number | null) { const session = useRun.getState().sessions.find((s) => s.runId === runId); if (!session) { pendingExits.set(runId, code); + // Background project queries also use the process host without attaching a Run tab. + if (pendingExits.size > 128) pendingExits.delete(pendingExits.keys().next().value!); return; } finishSession(session.id, code); diff --git a/src/features/run/targets.ts b/src/features/run/targets.ts index b98f7837b..73a3c3b2f 100644 --- a/src/features/run/targets.ts +++ b/src/features/run/targets.ts @@ -7,6 +7,7 @@ * one target derived from the project's detected test configurations. */ import { goTargets } from "./goTargets"; +import { csharpTargets } from "./csharpTargets"; import { formatArgs } from "./configs"; import type { RunConfig } from "@/platform/ipc"; import { applicationConfig, jvmTestConfig } from "./application"; @@ -43,9 +44,10 @@ export interface RunTarget { /** Above this size we skip detection (generated / minified files). */ export const MAX_SCAN = 400_000; -type Lang = "rust" | "js" | "python" | "php" | "go" | "jvm" | "ruby" | null; +type Lang = "rust" | "js" | "python" | "php" | "go" | "jvm" | "ruby" | "csharp" | null; export function targetLanguage(relPath: string): Lang { + if (/\.cs$/i.test(relPath)) return "csharp"; if (/\.rs$/.test(relPath)) return "rust"; if (/\.[cm]?[jt]sx?$/.test(relPath)) return "js"; if (/\.py$/.test(relPath)) return "python"; @@ -355,6 +357,8 @@ export function detectRunTargets(text: string, relPath: string, django = false): if (text.length > MAX_SCAN) return []; const lines = text.split("\n"); switch (targetLanguage(relPath)) { + case "csharp": + return csharpTargets(text); case "rust": return rustTargets(lines, relPath); case "js": @@ -409,7 +413,7 @@ export function targetConfig( return applicationConfig({ className: target.className, spring: target.spring }, relPath, configs); return contextConfig(relPath, configs, content); } - const file = testConfigFor(relPath, configs, base); + const file = testConfigFor(relPath, configs, base, content); if (!file) return null; if (target.className && (file.kind === "maven" || file.kind === "gradle")) return jvmTestConfig( @@ -427,6 +431,15 @@ export function targetConfig( detected: true, }); switch (file.kind) { + case "dotnet-test": { + const methods = + target.kind === "suite" && content + ? csharpTargets(content) + .filter((t) => t.kind === "test" && t.path[0] === target.path[0]) + .map((t) => t.path.join(".")) + : [target.path.join(".")]; + return { ...named(file.args), params: { ...file.params, testMethods: JSON.stringify(methods) } }; + } case "npm": case "bun": case "deno": diff --git a/src/features/run/testing/dotnet.test.ts b/src/features/run/testing/dotnet.test.ts new file mode 100644 index 000000000..537e957c2 --- /dev/null +++ b/src/features/run/testing/dotnet.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, vi } from "vitest"; +import type { RunConfig } from "@/platform/ipc"; +import { DotnetReports, trxEvents } from "./dotnet"; +import { frameworkFor, TestResults } from "./index"; +import { failedTestPaths, rerunFailedConfig } from "./rerun"; +import { dotnetTestType } from "../dotnetTypes"; +import xunitReport from "./fixtures/xunit.trx?raw"; +import nunitReport from "./fixtures/nunit.trx?raw"; +import mstestReport from "./fixtures/mstest.trx?raw"; +import mtpReport from "./fixtures/mtp.trx?raw"; + +const config: RunConfig = { + id: "dotnet:tests", + name: "Tests", + kind: "dotnet-test", + command: "dotnet", + args: ["test", "Tests.csproj"], + cwd: null, + env: {}, + detected: true, + isTest: true, + debugAdapter: null, + params: { + project: "Tests.csproj", + framework: "net9.0", + programArgs: "--filter Category=Unit --no-restore -- RunConfiguration.MaxCpuCount=1", + }, +}; + +function report(adapter: string, source = "C:/My Project/bin/net9.0/Tests.dll") { + return ` + + + + Expected <3>at Sample.Tests.Adds(Int32 value) in C:\\My Project\\Tests.cs:line 12 + captured output + + + + + + + + `; +} + +describe(".NET test reports", () => { + it("finds source locations in MTP stack traces without the VSTest line prefix", () => { + expect(trxEvents(mtpReport)).toContainEqual( + expect.objectContaining({ + type: "location", + location: { path: "C:\\Fixture\\Native\\Test1.cs", line: 5, resolve: "path" }, + }), + ); + }); + it.each([xunitReport, nunitReport, mstestReport, mtpReport])( + "reads actual VSTest reports and selects failed methods", + async (xml) => { + const results = new TestResults(); + results.apply(trxEvents(xml)); + expect(results.counts()).toEqual({ passed: 2, failed: 2, skipped: 1, running: 0, total: 5 }); + const rerun = await rerunFailedConfig(config, failedTestPaths(results)); + const args = dotnetTestType.commandLine(rerun!.params!)!.args; + expect(args[args.indexOf("--filter") + 1]!.split("|").sort()).toEqual([ + "FullyQualifiedName=Sample.CalculatorTests.Fails", + "FullyQualifiedName=Sample.CalculatorTests.Parameterized", + ]); + }, + ); + it.each([ + "executor://xunit/VsTestRunner2/netcoreapp", + "executor://nunit3testexecutor/", + "executor://mstestadapter/v2", + ])("reads outcomes, parameterized names, duration and source from %s", (adapter) => { + const results = new TestResults(); + results.apply(trxEvents(report(adapter))); + expect(results.counts()).toEqual({ passed: 1, failed: 1, skipped: 1, running: 0, total: 3 }); + const failed = results.firstFailure()!; + expect(failed.name).toBe("adds (2)"); + expect(failed.durationMs).toBe(62500); + expect(failed.output).toContain("Expected <3>"); + expect(failed.output).toContain("captured output"); + expect(failed.location).toEqual({ path: "C:\\My Project\\Tests.cs", line: 12, resolve: "path" }); + }); + + it("keeps assemblies and target frameworks separate", () => { + const results = new TestResults(); + for (const source of ["/a/bin/net9.0/Tests.dll", "/a/bin/net8.0/Tests.dll", "/b/bin/net9.0/Tests.dll"]) + results.apply(trxEvents(report("adapter", source))); + expect(results.roots).toHaveLength(3); + expect(results.counts().total).toBe(9); + }); + + it("rejects incomplete reports", () => { + expect(() => trxEvents("")).toThrow("Invalid TRX"); + expect(() => trxEvents("")).toThrow("Invalid TRX"); + }); + + it("inserts the logger before runsettings and reads only this invocation's announced paths", async () => { + const reports = new DotnetReports(); + const launched = reports.launch({ ...config, args: ["test", "Tests.csproj", "--", "x=y"] }); + expect(launched.args).toEqual([ + "test", + "Tests.csproj", + "--logger", + `trx;LogFilePrefix=${reports.prefix}`, + "--", + "x=y", + ]); + const windows = `C:\\My Project\\TestResults\\${reports.prefix}_net9.0.trx`; + const unix = `/tmp/My Project/${reports.prefix}_net8.0.trx`; + reports.feed("Results File: C:\\old.trx"); + reports.feed(`结果文件: ${windows}`); + reports.feed(`Results File: ${windows}`); + reports.feed(`Results File: ${unix}`); + const read = vi.fn().mockResolvedValue(report("adapter")); + expect(await reports.read(read)).not.toHaveLength(0); + expect(read.mock.calls).toEqual([[windows], [unix]]); + }); + + it("detects typed configurations and direct dotnet test commands", () => { + expect(frameworkFor(config)).toBe("dotnet"); + expect(frameworkFor({ ...config, kind: "shell", command: "C:\\Program Files\\dotnet\\dotnet.exe" })).toBe("dotnet"); + expect(frameworkFor({ ...config, kind: "shell", args: ["build"] })).toBe("auto"); + }); + + it("keeps a method filter through .NET command regeneration and preserves runsettings", async () => { + const results = new TestResults(); + results.apply(trxEvents(report("adapter"))); + const selected = await rerunFailedConfig(config, failedTestPaths(results)); + expect(selected).not.toBeNull(); + const line = dotnetTestType.commandLine(selected!.params!); + expect(line?.args).toEqual([ + "test", + "Tests.csproj", + "--framework", + "net9.0", + "--no-restore", + "--filter", + "FullyQualifiedName=Sample.Tests.Adds", + "--", + "RunConfiguration.MaxCpuCount=1", + ]); + expect(config.params?.programArgs).toContain("Category=Unit"); + }); +}); diff --git a/src/features/run/testing/dotnet.ts b/src/features/run/testing/dotnet.ts new file mode 100644 index 000000000..95308cdb9 --- /dev/null +++ b/src/features/run/testing/dotnet.ts @@ -0,0 +1,95 @@ +import type { RunConfig } from "@/platform/ipc"; +import type { TestEvent } from "./types"; + +/** Only this invocation's reports are read, including each target framework's report. */ +export class DotnetReports { + readonly prefix = `openidea-${crypto.randomUUID()}`; + private readonly paths = new Set(); + private mtp = false; + + launch(config: RunConfig): RunConfig { + this.mtp = config.params?.runner === "mtp" || config.params?.runner === "mtp-legacy"; + if (this.mtp) { + if (config.params?.trx === "false") return config; + const args = [...config.args]; + if (config.params?.runner === "mtp-legacy" && !args.includes("--")) args.push("--"); + if (!args.includes("--report-trx")) args.push("--report-trx"); + return { ...config, args }; + } + const boundary = config.args.indexOf("--"); + const at = boundary < 0 ? config.args.length : boundary; + return { + ...config, + args: [...config.args.slice(0, at), "--logger", `trx;LogFilePrefix=${this.prefix}`, ...config.args.slice(at)], + }; + } + + feed(line: string) { + // Match the absolute report path, independently of VSTest's UI language. + const path = /(?:[A-Za-z]:[\\/]|\/).*\.trx\s*$/i.exec(line)?.[0].trim(); + if (path && (this.mtp || path.replace(/\\/g, "/").split("/").at(-1)?.startsWith(this.prefix))) this.paths.add(path); + } + + async read(readText: (path: string) => Promise): Promise { + const events: TestEvent[] = []; + for (const path of this.paths) events.push(...trxEvents(await readText(path))); + return events; + } +} + +const elements = (node: Document | Element, name: string) => Array.from(node.getElementsByTagNameNS("*", name)); +const attr = (node: Element, name: string) => node.getAttribute(name) ?? ""; + +export function trxEvents(xml: string): TestEvent[] { + const doc = new DOMParser().parseFromString(xml, "application/xml"); + if (elements(doc, "parsererror").length || doc.documentElement.localName !== "TestRun") + throw new Error("Invalid TRX report"); + const definitions = new Map( + elements(doc, "UnitTest").map((test) => [attr(test, "id"), elements(test, "TestMethod")[0]]), + ); + const events: TestEvent[] = []; + for (const result of elements(doc, "UnitTestResult")) { + const method = definitions.get(attr(result, "testId")); + if (!method) continue; + const className = attr(method, "className").split(",")[0]!; + const name = attr(method, "name").replace(/\(.*$/s, "").trim(); + const source = attr(method, "codeBase").replace(/\\/g, "/"); + if (!className || !name || !source) continue; + const display = (attr(result, "testName") || name).replace( + new RegExp(`^${className.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\.`), + "", + ); + const path = [source, className, name, ...(display === name ? [] : [display])]; + const outcome = attr(result, "outcome"); + const status = + outcome === "Passed" ? "passed" : /^(NotExecuted|Inconclusive|Pending)$/.test(outcome) ? "skipped" : "failed"; + const duration = /^(\d+):(\d+):(\d+(?:\.\d+)?)$/.exec(attr(result, "duration")); + const durationMs = duration + ? (Number(duration[1]) * 3600 + Number(duration[2]) * 60 + Number(duration[3])) * 1000 + : undefined; + events.push({ type: "result", path, status, durationMs }); + for (const tag of ["Message", "StackTrace", "StdOut", "StdErr"]) { + for (const node of elements(result, tag)) { + for (const line of (node.textContent ?? "").split(/\r?\n/).slice(0, 200)) { + events.push({ type: "output", path, line }); + const location = /\bin (.+\.(?:cs|fs|vb)):(?:line )?(\d+)/.exec(line); + if (location) + events.push({ + type: "location", + path, + location: { path: location[1]!, line: Number(location[2]), resolve: "path" }, + }); + } + } + } + } + return events; +} + +/** A parameterized case reruns its method, using adapter-independent TRX method identities. */ +export function dotnetFilter(paths: string[][]): string | null { + if (paths.some((path) => path.length < 3)) return null; + const names = [...new Set(paths.map((path) => `${path[1]}.${path[2]}`))]; + if (!names.length) return null; + return names.map((name) => `FullyQualifiedName=${name.replace(/[\\()&|=!~]/g, "\\$&")}`).join("|"); +} diff --git a/src/features/run/testing/fixtures/mstest.trx b/src/features/run/testing/fixtures/mstest.trx new file mode 100644 index 000000000..83b415d5d --- /dev/null +++ b/src/features/run/testing/fixtures/mstest.trx @@ -0,0 +1,81 @@ + + + + + + + + + + + + demonstration + + + + + + + Assert.AreEqual failed. Expected:<3>. Actual:<2>. + at Sample.CalculatorTests.Fails() in C:\fixtures\Mstest\Test1.cs:line 5 + at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) + at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) + + + + + + + + Assert.AreEqual failed. Expected:<1>. Actual:<2>. + at Sample.CalculatorTests.Parameterized(Int32 value) in C:\fixtures\Mstest\Test1.cs:line 7 + at InvokeStub_CalculatorTests.Parameterized(Object, Span`1) + at System.Reflection.MethodBaseInvoker.InvokeWithOneArg(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Test Parallelization enabled for C:\fixtures\Mstest\bin\Debug\net9.0\OpenIdea.Mstest.Tests.dll (Workers: 32, Scope: MethodLevel) +Test 'Skipped' was skipped in the test run. + + + + \ No newline at end of file diff --git a/src/features/run/testing/fixtures/mtp.trx b/src/features/run/testing/fixtures/mtp.trx new file mode 100644 index 000000000..ced792228 --- /dev/null +++ b/src/features/run/testing/fixtures/mtp.trx @@ -0,0 +1,85 @@ + + + + + + + + + + + + Assert.AreEqual failed. Expected:<3>. Actual:<2>. 'expected' expression: '3', 'actual' expression: '1 + 1'. + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.ThrowAssertAreEqualFailed(Object expected, Object actual, String userMessage) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 665 + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEqual[T](T expected, T actual, IEqualityComparer`1 comparer, String message, String expectedExpression, String actualExpression) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 492 + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEqual[T](T expected, T actual, String message, String expectedExpression, String actualExpression) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 405 + at Sample.CalculatorTests.Fails() in C:\Fixture\Native\Test1.cs:line 5 + at System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object obj, IntPtr* args) + at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) + + + + + + + demonstration + + + + + + + + Assert.AreEqual failed. Expected:<1>. Actual:<2>. 'expected' expression: '1', 'actual' expression: 'value'. + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.ThrowAssertAreEqualFailed(Object expected, Object actual, String userMessage) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 665 + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEqual[T](T expected, T actual, IEqualityComparer`1 comparer, String message, String expectedExpression, String actualExpression) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 492 + at Microsoft.VisualStudio.TestTools.UnitTesting.Assert.AreEqual[T](T expected, T actual, String message, String expectedExpression, String actualExpression) in /_/src/TestFramework/TestFramework/Assertions/Assert.AreEqual.cs:line 405 + at Sample.CalculatorTests.Parameterized(Int32 value) in C:\Fixture\Native\Test1.cs:line 7 + at InvokeStub_CalculatorTests.Parameterized(Object, Span`1) + at System.Reflection.MethodBaseInvoker.InvokeWithOneArg(Object obj, BindingFlags invokeAttr, Binder binder, Object[] parameters, CultureInfo culture) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Exit code indicates failure: '2'. Please refer to https://aka.ms/testingplatform/exitcodes for more information. + + + + \ No newline at end of file diff --git a/src/features/run/testing/fixtures/nunit.trx b/src/features/run/testing/fixtures/nunit.trx new file mode 100644 index 000000000..04b17aaad --- /dev/null +++ b/src/features/run/testing/fixtures/nunit.trx @@ -0,0 +1,96 @@ + + + + + + + + + + + Assert.That(value, NUnit.Framework.Is.EqualTo(1)) + Expected: 1 + But was: 2 + + at Sample.CalculatorTests.Parameterized(Int32 value) in C:\fixtures\Nunit\UnitTest1.cs:line 6 + at InvokeStub_CalculatorTests.Parameterized(Object, Span`1) + +1) at Sample.CalculatorTests.Parameterized(Int32 value) in C:\fixtures\Nunit\UnitTest1.cs:line 6 + at InvokeStub_CalculatorTests.Parameterized(Object, Span`1) + + + + + + + + + Assert.That(1 + 1, NUnit.Framework.Is.EqualTo(3)) + Expected: 3 + But was: 2 + + at Sample.CalculatorTests.Fails() in C:\fixtures\Nunit\UnitTest1.cs:line 4 + +1) at Sample.CalculatorTests.Fails() in C:\fixtures\Nunit\UnitTest1.cs:line 4 + + + + + + + + + + demonstration + + demonstration + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + NUnit Adapter 4.6.0.0: Test execution started +Running all tests in C:\fixtures\Nunit\bin\Debug\net9.0\OpenIdea.Nunit.Tests.dll + NUnit3TestExecutor discovered 5 of 5 NUnit test cases using Current Discovery mode, Non-Explicit run +Skipped: demonstration +NUnit Adapter 4.6.0.0: Test execution complete +Test 'Skipped' was skipped in the test run. + + + + \ No newline at end of file diff --git a/src/features/run/testing/fixtures/xunit.trx b/src/features/run/testing/fixtures/xunit.trx new file mode 100644 index 000000000..05828d592 --- /dev/null +++ b/src/features/run/testing/fixtures/xunit.trx @@ -0,0 +1,113 @@ + + + + + + + + + + + + Assert.Equal() Failure: Values differ +Expected: 3 +Actual: 2 + at Sample.CalculatorTests.Fails() in C:\fixtures\Xunit\UnitTest1.cs:line 4 + at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) + at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) + + + + + + + + demonstration + + + + + + + Assert.Equal() Failure: Values differ +Expected: 1 +Actual: 2 + at Sample.CalculatorTests.Parameterized(Int32 value) in C:\fixtures\Xunit\UnitTest1.cs:line 6 + at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) + at System.Reflection.MethodBaseInvoker.InvokeDirectByRefWithFewArgs(Object obj, Span`1 copyOfArgs, BindingFlags invokeAttr) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 9.0.17) +[xUnit.net 00:00:00.05] Discovering: OpenIdea.Xunit.Tests +[xUnit.net 00:00:00.08] Discovered: OpenIdea.Xunit.Tests +[xUnit.net 00:00:00.08] Starting: OpenIdea.Xunit.Tests +[xUnit.net 00:00:00.17] Assert.Equal() Failure: Values differ +[xUnit.net 00:00:00.17] Expected: 1 +[xUnit.net 00:00:00.17] Actual: 2 +[xUnit.net 00:00:00.17] Stack Trace: +[xUnit.net 00:00:00.17] C:\fixtures\Xunit\UnitTest1.cs(6,0): at Sample.CalculatorTests.Parameterized(Int32 value) +[xUnit.net 00:00:00.17] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) +[xUnit.net 00:00:00.17] at System.Reflection.MethodBaseInvoker.InvokeDirectByRefWithFewArgs(Object obj, Span`1 copyOfArgs, BindingFlags invokeAttr) +[xUnit.net 00:00:00.17] Assert.Equal() Failure: Values differ +[xUnit.net 00:00:00.17] Expected: 3 +[xUnit.net 00:00:00.17] Actual: 2 +[xUnit.net 00:00:00.17] Stack Trace: +[xUnit.net 00:00:00.17] C:\fixtures\Xunit\UnitTest1.cs(4,0): at Sample.CalculatorTests.Fails() +[xUnit.net 00:00:00.17] at System.RuntimeMethodHandle.InvokeMethod(Object target, Void** arguments, Signature sig, Boolean isConstructor) +[xUnit.net 00:00:00.17] at System.Reflection.MethodBaseInvoker.InvokeWithNoArgs(Object obj, BindingFlags invokeAttr) +[xUnit.net 00:00:00.17] demonstration +[xUnit.net 00:00:00.17] Finished: OpenIdea.Xunit.Tests +Test 'Sample.CalculatorTests.Skipped' was skipped in the test run. + + + + + [xUnit.net 00:00:00.17] Sample.CalculatorTests.Parameterized(value: 2) [FAIL] + + + [xUnit.net 00:00:00.17] Sample.CalculatorTests.Fails [FAIL] + + + [xUnit.net 00:00:00.17] Sample.CalculatorTests.Skipped [SKIP] + + + + \ No newline at end of file diff --git a/src/features/run/testing/index.ts b/src/features/run/testing/index.ts index 453242559..2fce27bb5 100644 --- a/src/features/run/testing/index.ts +++ b/src/features/run/testing/index.ts @@ -23,11 +23,13 @@ export function combinedParser(parsers: TestOutputParser[]): TestOutputParser { }; } -export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "ruby" | "auto"; +export type Framework = "cargo" | "js" | "pytest" | "unittest" | "phpunit" | "go" | "jvm" | "ruby" | "dotnet" | "auto"; export function frameworkFor(config: Pick): Framework { const cmd = [config.command, ...config.args].join(" "); switch (config.kind) { + case "dotnet-test": + return "dotnet"; case "cargo": return "cargo"; case "npm": @@ -53,6 +55,7 @@ export function frameworkFor(config: Pick): TestOutputParser { switch (frameworkFor(config)) { + case "dotnet": + return { feed: () => [] }; case "cargo": return cargoParser(); case "js": diff --git a/src/features/run/testing/rerun.ts b/src/features/run/testing/rerun.ts index 83e58423f..2a5d68c5a 100644 --- a/src/features/run/testing/rerun.ts +++ b/src/features/run/testing/rerun.ts @@ -5,11 +5,12 @@ import { selectPluginTests } from "@/workbench/contributions"; import { phpunitArgs, phpunitFilter } from "./phpunit"; import type { RunConfig } from "@/platform/ipc"; -import { formatArgs } from "../configs"; +import { formatArgs, parseArgs } from "../configs"; import { appendScriptArgs } from "../scriptArgs"; import { djangoTestsOf } from "../djangoTypes"; import { goTestParts, withoutGoSelection } from "../goArguments"; import { frameworkFor } from "./index"; +import { dotnetFilter } from "./dotnet"; import { nodeStatus, type TestNode, type TestResults } from "./results"; /** Paths of failed tests: failed leaves, plus failed suites without any failed children reported. */ @@ -67,6 +68,11 @@ function cargoArgs(base: string[], paths: string[][]): string[] | null { return [...before, "--", ...rest, ...names, "--exact"]; } +function dotnetArgs(base: string[], filter: string): string[] { + const { before, after } = splitDashDash(base); + return [...dropFlag(before, ["--filter"]), "--filter", filter, ...(after ? ["--", ...after] : [])]; +} + function jsArgs(config: RunConfig, paths: string[][]): string[] | null { const base = config.args; const files = unique(paths.map((p) => p[0]!).filter(Boolean)); @@ -202,6 +208,26 @@ function builtinTestsConfig(config: RunConfig, paths: string[][], name: string): if (!paths.length) return null; let args: string[] | null; switch (frameworkFor(config)) { + case "dotnet": { + const filter = dotnetFilter(paths); + if (!filter) return null; + args = dotnetArgs(config.args, filter); + if (config.kind === "dotnet-test") { + return { + ...config, + args, + name, + detected: true, + env: { ...config.env }, + params: { + ...config.params, + testMethods: JSON.stringify([...new Set(paths.map((p) => `${p[1]}.${p[2]}`))]), + programArgs: formatArgs(dotnetArgs(parseArgs(config.params?.programArgs || ""), filter)), + }, + }; + } + break; + } case "cargo": args = cargoArgs(config.args, paths); break; diff --git a/src/platform/dotnet.test.ts b/src/platform/dotnet.test.ts new file mode 100644 index 000000000..b67336754 --- /dev/null +++ b/src/platform/dotnet.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from "vitest"; +import { solutionPaths, jsonOutput, dotnetTestArgs, globalRunner, type DotnetProject } from "./dotnet"; +import { dotnetTestType } from "@/features/run/dotnetTypes"; +import { DotnetReports } from "@/features/run/testing/dotnet"; + +const project: DotnetProject = { + path: "C:/My Repo/Tests.csproj", + name: "Tests", + frameworks: ["net9.0"], + configurations: ["Debug", "Release"], + references: [], + packages: [], + test: true, + runner: "vstest", + adapter: "mstest", + trx: true, +}; +describe(".NET workspace formats and runners", () => { + it("reads global.json comments and trailing commas without changing string values", () => { + expect( + globalRunner( + '{ // comment\n "test": { "runner": "Microsoft.Testing.Platform", }, "unused": "https://example.com/,}", }', + ), + ).toBe("mtp"); + }); + it("keeps SDK options before the legacy MTP argument boundary", () => { + const line = dotnetTestType.commandLine({ + project: project.path, + runner: "mtp-legacy", + programArgs: "--no-build --filter Name=One", + testMethods: '["A.Tests.Two"]', + })!; + expect(line.args.indexOf("--no-build")).toBeLessThan(line.args.indexOf("--")); + expect(line.args.slice(line.args.indexOf("--") + 1)).toEqual(["--filter", "FullyQualifiedName=A.Tests.Two"]); + }); + it("resolves solution projects, XML entities and external references", () => { + expect( + solutionPaths( + 'Project("id") = "Tests", "..\\outside\\Tests.csproj", "id"\nProject("id") = "Folder", "Folder", "id"', + false, + "C:/repo", + ), + ).toEqual(["C:/outside/Tests.csproj"]); + expect( + solutionPaths( + '', + true, + "/repo", + ), + ).toEqual(["/repo/A&B/App.csproj"]); + }); + it("reads JSON despite SDK banners", () => + expect(jsonOutput('Welcome\n{"Properties":{"TargetFramework":"net9.0"}}\n')).toEqual({ + Properties: { TargetFramework: "net9.0" }, + })); + it.each(["vstest", "mtp", "mtp-legacy"] as const)( + "keeps %s discovery arguments in the correct command layer", + (runner) => { + const args = dotnetTestArgs({ ...project, runner }, "net9.0", ["--list-tests"], "Release"); + expect(args.includes("--project")).toBe(runner === "mtp"); + expect(args.includes("--")).toBe(runner === "mtp-legacy"); + expect(args.at(-1)).toBe("--list-tests"); + }, + ); + it.each(["mtp", "mtp-legacy"])("launches %s with TRX and adapter-native selectors", (runner) => { + const p = { + project: project.path, + framework: "net9.0", + runner, + adapter: "xunit", + testMethods: JSON.stringify(["A.Tests.One", "A.Tests.Two"]), + }; + const line = dotnetTestType.commandLine(p)!; + expect(line.args).toContain("--filter-method"); + expect(line.args).not.toContain("--filter"); + const reporting = new DotnetReports().launch({ + id: "test", + name: "test", + kind: "dotnet-test", + ...line, + params: p, + cwd: null, + env: {}, + isTest: true, + detected: true, + debugAdapter: null, + }); + expect(reporting.args).toContain("--report-trx"); + expect(reporting.args).not.toContain("--logger"); + }); +}); diff --git a/src/platform/dotnet.ts b/src/platform/dotnet.ts new file mode 100644 index 000000000..b6584f58b --- /dev/null +++ b/src/platform/dotnet.ts @@ -0,0 +1,285 @@ +import { Channel } from "@tauri-apps/api/core"; +import { call, commands, ideEventsReady, onIdeEvent, type OutputChunk, type RunConfig, type RunId } from "./ipc"; +import { dirname, useProject } from "./project"; +import { tx } from "./i18n"; + +export type DotnetRunner = "vstest" | "mtp" | "mtp-legacy"; +export interface DotnetProject { + path: string; + name: string; + frameworks: string[]; + configurations: string[]; + references: string[]; + packages: Array<{ id: string; version: string }>; + test: boolean; + runner: DotnetRunner; + adapter: string; + trx: boolean; + platformVersion?: string; +} +export interface DotnetSolution { + path: string; + projects: string[]; +} +export interface DotnetWorkspace { + projects: DotnetProject[]; + solutions: DotnetSolution[]; + errors?: string[]; +} + +export function dotnetConfig(args: string[], cwd: string): RunConfig { + return { + id: `dotnet-task:${crypto.randomUUID()}`, + name: `dotnet ${args[0]}`, + kind: "shell", + command: "dotnet", + args, + cwd, + env: { DOTNET_CLI_UI_LANGUAGE: "en" }, + detected: true, + isTest: false, + debugAdapter: null, + emulateTerminal: false, + }; +} + +/** Uses the same trusted, remote-aware process host and cancellation as the Run window. */ +export async function dotnet(args: string[], cwd: string, signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + await ideEventsReady(); + let id: RunId | undefined; + let output = ""; + let overflow = false; + const exits = new Map(); + let finish!: (code: number | null) => void; + const exited = new Promise((resolve) => { + finish = resolve; + }); + const off = onIdeEvent("runExited", (event) => { + if (id === undefined) exits.set(event.run, event.code); + else if (event.run === id) finish(event.code); + }); + const stop = () => { + if (id !== undefined) void call(commands.runStop(id)).catch(() => {}); + }; + const channel = new Channel(); + channel.onmessage = (chunk) => { + if (output.length + chunk.text.length > 8_000_000) { + overflow = true; + stop(); + } else output += chunk.text; + }; + signal?.addEventListener("abort", stop); + try { + const info = await call(commands.runStart(dotnetConfig(args, cwd), channel)); + id = info.id; + if (signal?.aborted || overflow) stop(); + if (exits.has(id)) finish(exits.get(id)!); + const code = await exited; + signal?.throwIfAborted(); + if (overflow) throw new Error(tx(".NET output exceeded the size limit")); + if (code !== 0) throw new Error(output.trim() || tx`dotnet exited with code ${code}`); + return output; + } finally { + off(); + signal?.removeEventListener("abort", stop); + } +} + +export function jsonOutput>(text: string): T { + const start = text.indexOf("{"); + const end = text.lastIndexOf("}"); + return JSON.parse(text.slice(start, end + 1)); +} + +function absolute(base: string, relative: string): string { + const path = relative.replace(/\\/g, "/"); + if (/^(?:[A-Za-z]:\/|\/)/.test(path)) return path; + const parts = base.replace(/\\/g, "/").split("/"); + for (const part of path.split("/")) { + if (part === "..") parts.pop(); + else if (part && part !== ".") parts.push(part); + } + return parts.join("/"); +} + +export function solutionPaths(text: string, xml: boolean, dir: string): string[] { + const names = xml + ? Array.from(new DOMParser().parseFromString(text, "application/xml").getElementsByTagName("Project")).map( + (p) => p.getAttribute("Path") || "", + ) + : Array.from(text.matchAll(/^Project\([^\r\n]+?=\s*"[^"]*",\s*"([^"]+)"/gm), (m) => m[1]!); + return [...new Set(names.filter((p) => /\.(?:cs|fs|vb)proj$/i.test(p)).map((p) => absolute(dir, p)))]; +} + +export function globalRunner(text: string): DotnetRunner { + const json = JSON.parse( + text.replace(/"(?:\\.|[^"\\])*"|\/\*[\s\S]*?\*\/|\/\/[^\r\n]*|,\s*(?=[}\]])/g, (token) => + token.startsWith('"') ? token : "", + ), + ); + return json.test?.runner === "Microsoft.Testing.Platform" ? "mtp" : "vstest"; +} + +export async function runnerMode(dir: string): Promise { + for (let current = dir; current;) { + try { + return globalRunner(await call(commands.fsReadText(`${current}/global.json`))); + } catch { + /* No global.json at this level. */ + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + return "vstest"; +} + +export async function evaluateProject( + path: string, + configuration = "Debug", + framework = "", + signal?: AbortSignal, +): Promise { + const data = jsonOutput<{ Properties: Record; Items: Record>> }>( + await dotnet( + [ + "msbuild", + path, + "-nologo", + `-p:Configuration=${configuration}`, + ...(framework ? [`-p:TargetFramework=${framework}`] : []), + "-getProperty:MSBuildProjectName,TargetFramework,TargetFrameworks,Configurations,IsTestProject,IsTestingPlatformApplication,TestingPlatformDotnetTestSupport,EnableMSTestRunner,ProjectAssetsFile", + "-getItem:ProjectReference,PackageReference", + ], + dirname(path), + signal, + ), + ); + const p = data.Properties ?? {}; + if (!framework && p.TargetFrameworks) + return evaluateProject(path, configuration, p.TargetFrameworks.split(";")[0], signal); + const packages = (data.Items?.PackageReference ?? []).map((x) => ({ + id: String(x.Identity), + version: String(x.Version ?? ""), + })); + const mode = await runnerMode(dirname(path)); + const mtp = + [p.IsTestingPlatformApplication, p.TestingPlatformDotnetTestSupport, p.EnableMSTestRunner].includes("true") || + packages.some((x) => /xunit\.v3\.mtp/i.test(x.id)); + let trx = packages.some((x) => /^Microsoft.Testing.Extensions.TrxReport$/i.test(x.id)); + let platformVersion: string | undefined; + if (p.ProjectAssetsFile) { + try { + const assets = JSON.parse(await call(commands.fsReadText(p.ProjectAssetsFile))); + const libraries = Object.keys(assets.libraries ?? {}); + trx ||= libraries.some((key) => /^Microsoft.Testing.Extensions.TrxReport\//i.test(key)); + platformVersion = libraries.find((key) => /^Microsoft.Testing.Platform\//i.test(key))?.split("/")[1]; + } catch { + /* Not restored yet. */ + } + } + return { + path, + name: p.MSBuildProjectName || path.split("/").at(-1) || path, + frameworks: (p.TargetFrameworks || p.TargetFramework || "").split(";").filter(Boolean), + configurations: (p.Configurations || "Debug;Release").split(";"), + references: (data.Items?.ProjectReference ?? []).map((x) => absolute(dirname(path), x.Identity!)), + packages, + test: + p.IsTestProject === "true" || + mtp || + (p.IsTestProject !== "false" && + packages.some((x) => /^(?:Microsoft.NET.Test.Sdk|MSTest|NUnit|xunit(?:\.v3(?:\.mtp-v[12])?)?)$/i.test(x.id))), + runner: mode === "mtp" ? mode : mtp ? "mtp-legacy" : "vstest", + adapter: packages.some((x) => /^xunit/i.test(x.id)) + ? "xunit" + : packages.some((x) => /^nunit/i.test(x.id)) + ? "nunit" + : "mstest", + trx, + platformVersion, + }; +} + +export async function dotnetWorkspace(root: string, signal?: AbortSignal): Promise { + const paths = new Set(); + const solutions: DotnetSolution[] = []; + const queue = [root]; + while (queue.length) { + signal?.throwIfAborted(); + const dir = queue.shift()!; + for (const entry of await call(commands.fsReadDir(dir))) { + if ( + entry.kind === "dir" && + !entry.excluded && + !/^(?:\.|bin$|obj$|node_modules$|target$|packages$)/.test(entry.name) + ) + queue.push(entry.path); + else if (/\.(?:cs|fs|vb)proj$/i.test(entry.name)) paths.add(entry.path); + else if (/\.slnx?$/i.test(entry.name)) { + const projects = solutionPaths( + await call(commands.fsReadText(entry.path)), + entry.name.endsWith(".slnx"), + dirname(entry.path), + ); + solutions.push({ path: entry.path, projects }); + projects.forEach((p) => paths.add(p)); + } + } + } + const projects: DotnetProject[] = []; + const errors: string[] = []; + for (const path of paths) { + try { + const project = await evaluateProject(path, "Debug", "", signal); + projects.push(project); + project.references.forEach((reference) => paths.add(reference)); + } catch (e) { + signal?.throwIfAborted(); + errors.push(`${path}: ${String(e)}`); + } + } + return { projects, solutions, errors }; +} + +export function dotnetTestArgs( + project: DotnetProject, + framework: string, + extra: string[] = [], + configuration = "Debug", +): string[] { + return [ + "test", + ...(project.runner === "mtp" ? ["--project", project.path] : [project.path]), + "--configuration", + configuration, + ...(framework ? ["--framework", framework] : []), + ...(project.runner === "mtp-legacy" + ? [ + "-p:TestingPlatformCaptureOutput=false", + "-p:TestingPlatformDotnetTestSupport=true", + ...(extra.length ? ["--"] : []), + ] + : []), + ...extra, + ]; +} + +export async function prepareDotnetTest(config: RunConfig): Promise { + const root = useProject.getState().project?.root; + if (!root || !config.params?.project) return config; + let project = await evaluateProject( + absolute(root, config.params.project), + config.params.configuration, + config.params.framework, + ); + if (project.runner !== "vstest" && !project.platformVersion) { + await dotnet(["restore", project.path], dirname(project.path)); + project = await evaluateProject(project.path, config.params.configuration, config.params.framework); + } + return { + ...config, + params: { ...config.params, runner: project.runner, adapter: project.adapter, trx: String(project.trx) }, + }; +} diff --git a/src/platform/dotnetProcess.test.ts b/src/platform/dotnetProcess.test.ts new file mode 100644 index 000000000..4f7810ca7 --- /dev/null +++ b/src/platform/dotnetProcess.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +const host = vi.hoisted(() => ({ + start: vi.fn(), + stop: vi.fn(), + listeners: new Set<(e: { run: number; code: number | null }) => void>(), +})); +vi.mock("@tauri-apps/api/core", () => ({ + Channel: class { + onmessage = (_chunk: unknown) => {}; + }, +})); +vi.mock("./ipc", () => ({ + commands: { runStart: host.start, runStop: host.stop }, + call: (promise: Promise) => promise, + ideEventsReady: () => Promise.resolve(), + onIdeEvent: (_kind: string, fn: (e: { run: number; code: number | null }) => void) => { + host.listeners.add(fn); + return () => host.listeners.delete(fn); + }, +})); +vi.mock("./project", () => ({ + dirname: (p: string) => p.slice(0, p.lastIndexOf("/")), + useProject: { getState: () => ({ project: null }) }, +})); +import { dotnet } from "./dotnet"; +const exit = (run: number, code: number | null) => host.listeners.forEach((fn) => fn({ run, code })); +afterEach(() => { + host.start.mockReset(); + host.stop.mockReset(); + expect(host.listeners.size).toBe(0); +}); +describe(".NET query lifecycle", () => { + it("captures a fast exit before runStart resolves, ignoring another run", async () => { + host.start.mockImplementation(async (_config, channel) => { + channel.onmessage({ text: '{"ok":true}' }); + exit(99, 1); + exit(7, 0); + return { id: 7 }; + }); + expect(await dotnet(["msbuild"], "/repo")).toBe('{"ok":true}'); + }); + it("reports process failures and releases its event subscription", async () => { + host.start.mockImplementation(async () => { + exit(7, 1); + return { id: 7 }; + }); + await expect(dotnet(["msbuild"], "/repo")).rejects.toThrow("dotnet exited with code 1"); + }); + it("stops a process cancelled while runStart is pending", async () => { + const abort = new AbortController(); + host.start.mockImplementation(async () => { + abort.abort(); + return { id: 7 }; + }); + host.stop.mockImplementation(async (id) => exit(id, null)); + await expect(dotnet(["msbuild"], "/repo", abort.signal)).rejects.toThrow(); + expect(host.stop).toHaveBeenCalledWith(7); + }); + it("releases its subscription if starting the process fails", async () => { + host.start.mockRejectedValue(new Error("SDK missing")); + await expect(dotnet(["msbuild"], "/repo")).rejects.toThrow("SDK missing"); + }); +}); diff --git a/src/platform/locales/text/en.json b/src/platform/locales/text/en.json index bcf4f7005..411d4aedb 100644 --- a/src/platform/locales/text/en.json +++ b/src/platform/locales/text/en.json @@ -681,5 +681,36 @@ "Untagged Images": "Untagged Images", "Show Processes": "Show Processes", "Open Log in Editor": "Open Log in Editor", - "Open Container Log in Editor": "Open Container Log in Editor" + "Open Container Log in Editor": "Open Container Log in Editor", + "Could not read .NET test results: {error}\n": "Could not read .NET test results: {error}\n", + "Invalid TRX report": "Invalid TRX report", + ".NET Workspace": ".NET Workspace", + "Reload .NET Workspace": "Reload .NET Workspace", + "Cancel .NET Operation": "Cancel .NET Operation", + "Select .NET Project": "Select .NET Project", + "Build .NET Project": "Build .NET Project", + "Restore .NET Project": "Restore .NET Project", + "Clean .NET Project": "Clean .NET Project", + "Refresh NuGet Packages": "Refresh NuGet Packages", + "Search NuGet Packages": "Search NuGet Packages", + "Change NuGet Package": "Change NuGet Package", + "Discover .NET Tests": "Discover .NET Tests", + "Run .NET Tests": "Run .NET Tests", + "Install TRX reporting": "Install TRX reporting", + "Project references": "Project references", + "NuGet packages": "NuGet packages", + "Refresh packages": "Refresh packages", + "Search NuGet packages": "Search NuGet packages", + "Install / Update": "Install / Update", + "Versions": "Versions", + "Transitive": "Transitive", + "Run all tests": "Run all tests", + "Install the TRX reporting extension to show MTP results in the test panel.": "Install the TRX reporting extension to show MTP results in the test panel.", + ".NET output exceeded the size limit": ".NET output exceeded the size limit", + "Clean": "Clean", + "Run test": "Run test", + "Select a .NET project": "Select a .NET project", + "Project evaluation failed": "Project evaluation failed", + "dotnet exited with code {0}": "dotnet exited with code {0}", + "Microsoft.Testing.Platform was not found in the restored project.": "Microsoft.Testing.Platform was not found in the restored project." } diff --git a/src/platform/locales/text/zh.json b/src/platform/locales/text/zh.json index cdc3ef9d3..59c0100d1 100644 --- a/src/platform/locales/text/zh.json +++ b/src/platform/locales/text/zh.json @@ -7021,5 +7021,35 @@ "Untagged Images": "未标记的镜像", "Show Processes": "显示进程", "Open Log in Editor": "在编辑器中打开日志", - "Open Container Log in Editor": "在编辑器中打开容器日志" + "Open Container Log in Editor": "在编辑器中打开容器日志", + "Could not read .NET test results: {error}\n": "无法读取 .NET 测试结果:{error}\n", + "Invalid TRX report": "无效的 TRX 报告", + ".NET Workspace": ".NET 工作区", + "Reload .NET Workspace": "重新加载 .NET 工作区", + "Cancel .NET Operation": "取消 .NET 操作", + "Select .NET Project": "选择 .NET 项目", + "Build .NET Project": "构建 .NET 项目", + "Restore .NET Project": "还原 .NET 项目", + "Clean .NET Project": "清理 .NET 项目", + "Refresh NuGet Packages": "刷新 NuGet 包", + "Search NuGet Packages": "搜索 NuGet 包", + "Change NuGet Package": "修改 NuGet 包", + "Discover .NET Tests": "发现 .NET 测试", + "Run .NET Tests": "运行 .NET 测试", + "Install TRX reporting": "安装 TRX 报告支持", + "Project references": "项目引用", + "NuGet packages": "NuGet 包", + "Refresh packages": "刷新包列表", + "Search NuGet packages": "搜索 NuGet 包", + "Install / Update": "安装 / 更新", + "Versions": "版本", + "Transitive": "间接依赖", + "Run all tests": "运行所有测试", + "Install the TRX reporting extension to show MTP results in the test panel.": "安装 TRX 报告扩展,以在测试面板中显示 MTP 结果。", + ".NET output exceeded the size limit": ".NET 输出超过大小限制", + "Clean": "清理", + "Run test": "运行测试", + "Project evaluation failed": "项目求值失败", + "dotnet exited with code {0}": "dotnet 已退出,退出码 {0}", + "Microsoft.Testing.Platform was not found in the restored project.": "还原后的项目中未找到 Microsoft.Testing.Platform。" }