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 <system-out>/<system-err>;
  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.
This commit is contained in:
l0ng-ai
2026-11-14 05:51:58 +08:00
committed by GitHub
parent e2d089a0c4
commit 6d103df5cf
10 changed files with 134 additions and 20 deletions
+14 -4
View File
@@ -26,8 +26,10 @@ pub struct TestCaseReport {
pub duration_ms: Option<f64>,
/// Failure / error / skip message.
pub message: Option<String>,
/// Failure stack trace and captured output.
/// Failure / error stack trace.
pub output: Option<String>,
/// What the test printed (`<system-out>`, `<system-err>`).
pub stdout: Option<String>,
}
fn decode(s: &str) -> String {
@@ -124,11 +126,13 @@ pub fn parse_report(xml: &str) -> Vec<TestCaseReport> {
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<TestCaseReport> {
duration_ms,
message: None,
output: None,
stdout: None,
};
if self_closing {
out.push(case);
@@ -238,6 +243,8 @@ mod tests {
<failure message="expected: &lt;1&gt; but was: &lt;2&gt;" type="org.opentest4j.AssertionFailedError"><![CDATA[org.opentest4j.AssertionFailedError: expected: <1> but was: <2>
at com.example.demo.todo.TodoServiceTests.fails(TodoServiceTests.java:30)
]]></failure>
<system-out><![CDATA[printed before failing
]]></system-out>
</testcase>
<testcase name="later" classname="com.example.demo.todo.TodoServiceTests" time="0">
<skipped message="not yet"/>
@@ -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 <world>"));
assert_eq!(cases[1].stdout.as_deref(), Some("hello <world>"));
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"));
+1 -1
View File
@@ -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**.
+1 -1
View File
@@ -47,7 +47,7 @@ RSpec 有自己的配置类型(**RSpec 测试**)和检测到的 `rspec` 配
测试运行会在 **运行** 工具窗口中打开一个标签页,控制台旁边是测试树。失败的测试套件会自动展开。
- 单击某个测试,控制台只显示它自己的输出。
- 单击某个测试,控制台只显示它自己的输出;单击类或套件,先显示它自己的准备和清理输出,再显示其下各个测试的输出。控制台上方的 **显示所有输出** 可回到整个运行的输出。
- 双击、按 `Enter` 或 `F4` 跳到测试源码(**跳转到源代码**)。
- **显示已通过** 和 **显示已忽略** 用于筛选测试树。默认隐藏已通过的测试,筛选状态会被记住。
- 右键单个测试或多选后,可以 **运行 '…'**、**调试 '…'** 或 **跳转到源代码**。
+3 -1
View File
@@ -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 (`<system-out>`, `<system-err>`). */
stdout: string | null,
};
export type TestCaseStatus = "passed" | "failed" | "skipped";
+3 -1
View File
@@ -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)}']"
}
})
}
+58 -3
View File
@@ -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<RunConfig> & { 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<TestCaseReport> & { 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();
+21 -4
View File
@@ -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<TestCaseReport[]> {
}
}
/** 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 });
+9 -5
View File
@@ -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<string, "failed" | "skipped">): 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<string,
if (!failed) outcomes.delete(key);
return [{ type: "start", path }];
case "testStdOut":
case "testStdErr": {
const text = (attrs.out ?? "").replace(/\r?\n$/, "");
return text ? text.split(/\r?\n/).map((line) => ({ type: "output", path, line })) : [];
}
case "testStdErr":
return printed(path);
case "testFailed": {
outcomes.set(key, "failed");
const lines = (attrs.details || attrs.message || "").split("\n");
+17
View File
@@ -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']"));
+7
View File
@@ -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;