From 6d103df5cf21c1916a1e3d57d7068d85b3f1668d Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Sat, 14 Nov 2026 05:51:58 +0800 Subject: [PATCH] feat(run): show what each Java test printed, for Maven and Gradle (#3256) Selecting a test in the Run tool window's test tree shows only that test's output, and a class shows its own output followed by its tests'. - Maven: Surefire's console names failures but not what passed tests printed. The JUnit XML reports carry each test case's /; they are now read into a separate `stdout` field and shown for every test, before the failure details. Failures already reported on the console get the printed lines in front of their stack trace. - Gradle: output of a class's own setup and teardown (@BeforeAll, @AfterAll) arrived under the class descriptor and showed up as an extra child named after the class. The init script now sends it without a test name and it belongs to the class node. --- crates/ide-run/src/junit.rs | 18 +++++-- docs/en/guide/testing.mdx | 2 +- docs/zh/guide/testing.mdx | 2 +- src/bindings.ts | 4 +- src/features/run/gradleTests.gradle | 4 +- src/features/run/java.test.ts | 61 ++++++++++++++++++++++-- src/features/run/store.ts | 25 ++++++++-- src/features/run/testing/jvm.ts | 14 ++++-- src/features/run/testing/parsers.test.ts | 17 +++++++ src/features/run/testing/results.ts | 7 +++ 10 files changed, 134 insertions(+), 20 deletions(-) diff --git a/crates/ide-run/src/junit.rs b/crates/ide-run/src/junit.rs index efc1010e6..9371ad622 100644 --- a/crates/ide-run/src/junit.rs +++ b/crates/ide-run/src/junit.rs @@ -26,8 +26,10 @@ pub struct TestCaseReport { pub duration_ms: Option, /// Failure / error / skip message. pub message: Option, - /// Failure stack trace and captured output. + /// Failure / error stack trace. pub output: Option, + /// What the test printed (``, ``). + pub stdout: Option, } fn decode(s: &str) -> String { @@ -124,11 +126,13 @@ pub fn parse_report(xml: &str) -> Vec { if let Some(name) = tag.strip_prefix('/') { match name.trim() { "testcase" => out.extend(current.take()), - "failure" | "error" | "rerunFailure" | "rerunError" | "skipped" | "system-out" | "system-err" => { + name @ ("failure" | "error" | "rerunFailure" | "rerunError" | "skipped" | "system-out" + | "system-err") => { if let (Some(t), Some(c)) = (text.take(), current.as_mut()) { let t = t.trim_matches('\n'); if !t.trim().is_empty() { - let o = c.output.get_or_insert_with(String::new); + let field = if name.starts_with("system-") { &mut c.stdout } else { &mut c.output }; + let o = field.get_or_insert_with(String::new); if !o.is_empty() { o.push('\n'); } @@ -160,6 +164,7 @@ pub fn parse_report(xml: &str) -> Vec { duration_ms, message: None, output: None, + stdout: None, }; if self_closing { out.push(case); @@ -238,6 +243,8 @@ mod tests { but was: <2> at com.example.demo.todo.TodoServiceTests.fails(TodoServiceTests.java:30) ]]> + @@ -254,10 +261,13 @@ mod tests { assert_eq!(cases[0].status, TestCaseStatus::Passed); assert_eq!(cases[0].duration_ms, Some(31.0)); assert_eq!(cases[1].name, "deleteReturnsFalseForUnknownId"); - assert_eq!(cases[1].output.as_deref(), Some("hello ")); + assert_eq!(cases[1].stdout.as_deref(), Some("hello ")); + assert_eq!(cases[1].output, None); assert_eq!(cases[2].status, TestCaseStatus::Failed); assert_eq!(cases[2].message.as_deref(), Some("expected: <1> but was: <2>")); assert!(cases[2].output.as_deref().unwrap().contains("TodoServiceTests.java:30")); + assert!(!cases[2].output.as_deref().unwrap().contains("printed")); + assert_eq!(cases[2].stdout.as_deref(), Some("printed before failing")); assert_eq!(cases[2].duration_ms, Some(1_234_500.0)); assert_eq!(cases[3].status, TestCaseStatus::Skipped); assert_eq!(cases[3].message.as_deref(), Some("not yet")); diff --git a/docs/en/guide/testing.mdx b/docs/en/guide/testing.mdx index 16898683c..2972a79bf 100644 --- a/docs/en/guide/testing.mdx +++ b/docs/en/guide/testing.mdx @@ -51,7 +51,7 @@ RSpec has its own configuration type (**RSpec**) and a detected `rspec` configur A test run opens a tab in the **Run** tool window with the test tree next to the console. Failed suites expand automatically. -- Click a test to see its own output in the console. +- Click a test to see its own output in the console, or a class or suite to see its setup and teardown output followed by its tests'. **Show all output** above the console goes back to the whole run. - Double-click, `Enter` or `F4` jumps to the test's source (**Jump to Source**). - **Show Passed** and **Show Ignored** filter the tree. Passed tests are hidden by default; the filters are remembered. - Right-click a test or a selection for **Run '…'**, **Debug '…'** and **Jump to Source**. diff --git a/docs/zh/guide/testing.mdx b/docs/zh/guide/testing.mdx index 737502861..200a7af95 100644 --- a/docs/zh/guide/testing.mdx +++ b/docs/zh/guide/testing.mdx @@ -47,7 +47,7 @@ RSpec 有自己的配置类型(**RSpec 测试**)和检测到的 `rspec` 配 测试运行会在 **运行** 工具窗口中打开一个标签页,控制台旁边是测试树。失败的测试套件会自动展开。 -- 单击某个测试,控制台只显示它自己的输出。 +- 单击某个测试,控制台只显示它自己的输出;单击类或套件,先显示它自己的准备和清理输出,再显示其下各个测试的输出。控制台上方的 **显示所有输出** 可回到整个运行的输出。 - 双击、按 `Enter` 或 `F4` 跳到测试源码(**跳转到源代码**)。 - **显示已通过** 和 **显示已忽略** 用于筛选测试树。默认隐藏已通过的测试,筛选状态会被记住。 - 右键单个测试或多选后,可以 **运行 '…'**、**调试 '…'** 或 **跳转到源代码**。 diff --git a/src/bindings.ts b/src/bindings.ts index 442de8551..500a0e4d5 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -10021,8 +10021,10 @@ export type TestCaseReport = { durationMs: number | null, /** Failure / error / skip message. */ message: string | null, - /** Failure stack trace and captured output. */ + /** Failure / error stack trace. */ output: string | null, + /** What the test printed (``, ``). */ + stdout: string | null, }; export type TestCaseStatus = "passed" | "failed" | "skipped"; diff --git a/src/features/run/gradleTests.gradle b/src/features/run/gradleTests.gradle index 0ab21c18a..2e041440a 100644 --- a/src/features/run/gradleTests.gradle +++ b/src/features/run/gradleTests.gradle @@ -37,7 +37,9 @@ allprojects { task.addTestOutputListener(new TestOutputListener() { void onOutput(TestDescriptor test, TestOutputEvent output) { if (test.className == null) return - println "##teamcity[testStdOut name='${tc(test.name)}' className='${tc(test.className)}' out='${tc(output.message)}']" + // A class's own output (`@BeforeAll`, `@AfterAll`) carries no test name. + def name = test.composite ? '' : " name='${tc(test.name)}'" + println "##teamcity[testStdOut${name} className='${tc(test.className)}' out='${tc(output.message)}']" } }) } diff --git a/src/features/run/java.test.ts b/src/features/run/java.test.ts index 219e82d51..991fe44bd 100644 --- a/src/features/run/java.test.ts +++ b/src/features/run/java.test.ts @@ -1,5 +1,5 @@ import { beforeEach, describe, expect, it } from "vitest"; -import type { JavaLaunchInfo, RunConfig } from "@/platform/ipc"; +import type { JavaLaunchInfo, RunConfig, TestCaseReport } from "@/platform/ipc"; import { formatCompileErrors, joinJavaArgs, @@ -16,7 +16,8 @@ import { buildRows } from "./ConfigChooser"; import { jdwpLaunch } from "./javaDebug"; import { DELEGATED_DEBUG } from "./gradleRun"; import { firstTopLevelType, jvmMainCandidates, scanJvmTargets } from "./jvmTargets"; -import { adoptTemporary, MAX_TEMPORARY, saveTemporary, testReportEvents, useRun } from "./store"; +import { adoptTemporary, applyTestReports, MAX_TEMPORARY, saveTemporary, testReportEvents, useRun } from "./store"; +import { TestResults } from "./testing"; import { detectRunTargets, targetConfig } from "./targets"; const cfg = (over: Partial & { id: string; kind: string }): RunConfig => ({ @@ -484,7 +485,7 @@ describe("debugging build tool configurations", () => { describe("JUnit XML reports", () => { it("become tree results with failure output and source location", () => { const events = testReportEvents([ - { className: "a.B", name: "ok", status: "passed", durationMs: 3, message: null, output: null }, + { className: "a.B", name: "ok", status: "passed", durationMs: 3, message: null, output: null, stdout: null }, { className: "a.B$Inner", name: "bad", @@ -492,6 +493,7 @@ describe("JUnit XML reports", () => { durationMs: null, message: "expected 1", output: "AssertionError\n\tat org.junit.Assert.fail(Assert.java:1)\n\tat a.B$Inner.bad(B.java:42)", + stdout: null, }, ]); expect(events[0]).toEqual({ type: "result", path: ["a.B", "ok"], status: "passed", durationMs: 3 }); @@ -512,6 +514,7 @@ describe("JUnit XML reports", () => { durationMs: null, message: null, output: "AssertionFailedError\n\tat app//a.LibraryTest.cannot lend twice(LibraryTest.kt:43)", + stdout: null, }, ]); expect(events).toContainEqual({ @@ -522,6 +525,58 @@ describe("JUnit XML reports", () => { }); }); +describe("what JUnit XML reports say a test printed", () => { + const report = (over: Partial & { name: string }): TestCaseReport => ({ + className: "a.B", + status: "passed", + durationMs: null, + message: null, + output: null, + stdout: null, + ...over, + }); + const output = (r: TestResults, name: string) => r.get(["a.B", name].join("\u0000"))?.output; + + it("is shown for passed tests, and before a failure", () => { + const r = new TestResults(); + applyTestReports(r, [ + report({ name: "ok", stdout: "setUp\nok says hi" }), + report({ + name: "bad", + status: "failed", + message: "boom", + output: "Error: boom\n\tat a.B.bad(B.java:3)", + stdout: "bad says hi", + }), + ]); + expect(output(r, "ok")).toEqual(["setUp", "ok says hi"]); + expect(output(r, "bad")).toEqual(["bad says hi", "boom", "Error: boom", "\tat a.B.bad(B.java:3)"]); + }); + + it("goes before the failure Surefire's console reported, once", () => { + const r = new TestResults(); + r.apply([ + { type: "result", path: ["a.B", "bad"], status: "failed" }, + { type: "output", path: ["a.B", "bad"], line: "Error: boom" }, + ]); + const reports = [report({ name: "bad", status: "failed", output: "Error: boom", stdout: "bad says hi" })]; + applyTestReports(r, reports); + expect(output(r, "bad")).toEqual(["bad says hi", "Error: boom"]); + applyTestReports(r, reports); + expect(output(r, "bad")).toEqual(["bad says hi", "Error: boom"]); + }); + + it("is not repeated for tests the Gradle init script reported with their output", () => { + const r = new TestResults(); + r.apply([ + { type: "output", path: ["a.B", "ok"], line: "ok says hi" }, + { type: "result", path: ["a.B", "ok"], status: "passed" }, + ]); + applyTestReports(r, [report({ name: "ok", stdout: "ok says hi" })]); + expect(output(r, "ok")).toEqual(["ok says hi"]); + }); +}); + describe("temporary configurations", () => { beforeEach(() => { localStorage.clear(); diff --git a/src/features/run/store.ts b/src/features/run/store.ts index 40a77e783..44c893a53 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -1076,9 +1076,7 @@ function finishSession(sessionId: string, code: number | null) { // 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); - const tests = rt.tests; - // Tests the run reported already (Gradle's init script, Surefire failures) keep their output. - tests.apply(testReportEvents(reports).filter((e) => !("path" in e) || !tests.hasResult(e.path))); + applyTestReports(rt.tests, reports); moveToReportedClasses( rt.tests, reports.map((r) => r.className), @@ -1110,13 +1108,32 @@ async function jvmReports(session: RunSession): Promise { } } -/** JUnit XML report cases → test tree events (`[class, method]`, failure output and source line). */ +/** + * Adds the reports' results to the run's tree. Tests the run reported already (Gradle's init script, + * Surefire failures) keep their result and output, but Surefire's console shows a failure without + * what the test printed before it. + */ +export function applyTestReports(tests: TestResults, reports: readonly TestCaseReport[]) { + const reported = reports.filter((r) => tests.hasResult([r.className, r.name])); + tests.apply(testReportEvents(reports).filter((e) => !("path" in e) || !tests.hasResult(e.path))); + for (const r of reported) { + const path = [r.className, r.name]; + const printed = printedLines(r); + const shown = new Set(tests.node(path).output); + if (printed.length && !printed.every((l) => shown.has(l))) tests.prependOutput(path, printed); + } +} + +const printedLines = (r: TestCaseReport) => (r.stdout ? r.stdout.split("\n").slice(0, 200) : []); + +/** JUnit XML report cases → test tree events (`[class, method]`, printed and failure output, source line). */ export function testReportEvents(reports: readonly TestCaseReport[]): TestEvent[] { const events: TestEvent[] = []; for (const r of reports) { if (!r.className || !r.name) continue; const path = [r.className, r.name]; events.push({ type: "result", path, status: r.status, durationMs: r.durationMs ?? undefined }); + for (const line of printedLines(r)) events.push({ type: "output", path, line }); if (r.status !== "failed") continue; const text = [r.message, r.output].filter(Boolean).join("\n"); for (const line of text.split("\n").slice(0, 200)) events.push({ type: "output", path, line }); diff --git a/src/features/run/testing/jvm.ts b/src/features/run/testing/jvm.ts index 41cd021f7..60b64bada 100644 --- a/src/features/run/testing/jvm.ts +++ b/src/features/run/testing/jvm.ts @@ -35,7 +35,13 @@ function frameLocation(line: string): TestLocation | null { * (`add(int, int)[1]` → `add`); `outcomes` keeps each test's failure or skip until it finishes. */ function initScriptEvents({ kind, attrs }: ServiceMessage, outcomes: Map): TestEvent[] { - if (!attrs.className || !attrs.name) return []; + if (!attrs.className) return []; + const printed = (path: string[]): TestEvent[] => { + const text = (attrs.out ?? "").replace(/\r?\n$/, ""); + return text ? text.split(/\r?\n/).map((line) => ({ type: "output", path, line })) : []; + }; + // A class's own output (`@BeforeAll`, `@AfterAll`) belongs to the class. + if (!attrs.name) return kind === "testStdOut" || kind === "testStdErr" ? printed([attrs.className]) : []; const path = [attrs.className, attrs.name.split("(")[0]!.trim() || attrs.name]; const key = path.join("\0"); // A parameterized method's invocations share its node: one failure fails the method. @@ -45,10 +51,8 @@ function initScriptEvents({ kind, attrs }: ServiceMessage, outcomes: Map ({ type: "output", path, line })) : []; - } + case "testStdErr": + return printed(path); case "testFailed": { outcomes.set(key, "failed"); const lines = (attrs.details || attrs.message || "").split("\n"); diff --git a/src/features/run/testing/parsers.test.ts b/src/features/run/testing/parsers.test.ts index 5241d9109..0d0ee0977 100644 --- a/src/features/run/testing/parsers.test.ts +++ b/src/features/run/testing/parsers.test.ts @@ -501,6 +501,23 @@ CalcTest > fails() FAILED expect(r.hasResult(["demo.CalcTest", "other"])).toBe(false); }); + it("gives a class's own output from the gradle init script to the class", () => { + const r = results( + jvmParser(), + `##teamcity[testStdOut className='demo.CalcTest' out='class setUp|n'] +##teamcity[testStarted name='adds()' className='demo.CalcTest'] +##teamcity[testStdOut name='adds()' className='demo.CalcTest' out='adding|n'] +##teamcity[testFinished name='adds()' className='demo.CalcTest' duration='1'] +##teamcity[testStdOut className='demo.CalcTest' out='class tearDown|n'] +`, + ); + const cls = r.get("demo.CalcTest")!; + expect(cls.children.map((c) => c.name)).toEqual(["adds"]); + expect(cls.output).toEqual(["class setUp", "class tearDown"]); + expect(nodeOutput(cls)).toEqual(["class setUp", "class tearDown", "adding"]); + expect(leaf(r, "demo.CalcTest", "adds")?.output).toEqual(["adding"]); + }); + it("shows a test as running from its start", () => { const r = new TestResults(); r.apply(jvmParser().feed("##teamcity[testStarted name='slow()' className='demo.SlowTest']")); diff --git a/src/features/run/testing/results.ts b/src/features/run/testing/results.ts index 9d7008df5..fab9e32ae 100644 --- a/src/features/run/testing/results.ts +++ b/src/features/run/testing/results.ts @@ -79,6 +79,13 @@ export class TestResults { return !!own && own !== "running"; } + /** Puts lines before a test's output (what it printed, read after the run, goes before its failure). */ + prependOutput(path: string[], lines: readonly string[]) { + const n = this.node(path); + n.output = [...lines, ...n.output].slice(0, MAX_OUTPUT_LINES); + this.version++; + } + /** Finds a test by a suffix of its path (pytest failure headers name `Class.test`). */ findBySuffix(suffix: string[]): TestNode | undefined { let best: TestNode | undefined;