mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-15 08:08:18 +00:00
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:
@@ -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: <1> but was: <2>" 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"));
|
||||
|
||||
@@ -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**.
|
||||
|
||||
@@ -47,7 +47,7 @@ RSpec 有自己的配置类型(**RSpec 测试**)和检测到的 `rspec` 配
|
||||
|
||||
测试运行会在 **运行** 工具窗口中打开一个标签页,控制台旁边是测试树。失败的测试套件会自动展开。
|
||||
|
||||
- 单击某个测试,控制台只显示它自己的输出。
|
||||
- 单击某个测试,控制台只显示它自己的输出;单击类或套件,先显示它自己的准备和清理输出,再显示其下各个测试的输出。控制台上方的 **显示所有输出** 可回到整个运行的输出。
|
||||
- 双击、按 `Enter` 或 `F4` 跳到测试源码(**跳转到源代码**)。
|
||||
- **显示已通过** 和 **显示已忽略** 用于筛选测试树。默认隐藏已通过的测试,筛选状态会被记住。
|
||||
- 右键单个测试或多选后,可以 **运行 '…'**、**调试 '…'** 或 **跳转到源代码**。
|
||||
|
||||
+3
-1
@@ -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";
|
||||
|
||||
@@ -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)}']"
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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']"));
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user