fix(run): report Gradle tests in the test tree as they run (#1339)

Gradle prints only failed tests, so the live test tree only filled in
after the run from the XML reports, and stayed empty when the test task
was UP-TO-DATE and wrote no new reports.

Gradle test runs now get an init script that reports each test's start,
output, failure (with the stack trace), skip and finish as TeamCity
service messages and keeps test tasks from being UP-TO-DATE. The console
hides those lines; the JVM parser turns them into `[class, method]`
results, named as in the XML reports, and drops Gradle's own repeated
`Class > test() FAILED` lines. Tests the run already reported keep their
output instead of getting the report's copy appended. The TeamCity
message parsing moves out of the PHPUnit parser so both share it.

Fixes #1268
Цей коміт міститься в:
l0ng-ai authored and GitHub committed 2026-11-05 02:02:06 +08:00
1 джерело 70a7c6dd2c
коміт c219c81bd8
10 files changed
+293 -66

No files matched your search

+71 -15
Переглянути файл
@@ -1,7 +1,11 @@
/** Gradle test logging (`Class > method() PASSED`) and Maven Surefire reports. */
/**
* Gradle test logging (`Class > method() PASSED`), the Gradle test init script's service messages
* (`gradleTests.gradle`) and Maven Surefire reports.
*/
import { jvmFrameTarget } from "../links";
import type { TestResults } from "./results";
import { parseDuration, type TestEvent, type TestOutputParser } from "./types";
import { serviceMessage, type ServiceMessage } from "./teamcity";
import { parseDuration, type TestEvent, type TestLocation, type TestOutputParser } from "./types";
const GRADLE_RE = /^(\S[^>]*?(?:\s>\s[^>]+?)+)\s+(PASSED|FAILED|SKIPPED)(?:\s+\(([\d.]+\s*m?s)\))?$/;
const GRADLE_SUMMARY_RE = /^(\d+) tests? completed(?:, (\d+) failed)?(?:, (\d+) skipped)?/;
@@ -15,10 +19,62 @@ const SUREFIRE2_RE = /^(?:\[\w+\]\s+)?([\w$]+)\(([\w$.]+)\)\s+Time elapsed: ([\d
const SUREFIRE_CLASS_RE =
/^(?:\[\w+\]\s+)?Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+), Time elapsed: ([\d.]+ s(?:ec)?).*?(?:-- in |- in )([\w$.]+)\s*$/;
const SUREFIRE_TOTAL_RE = /^(?:\[\w+\]\s+)?Tests run: (\d+), Failures: (\d+), Errors: (\d+), Skipped: (\d+)\s*$/;
// `at org.junit…`, also behind a class loader or module (`at app//org.junit…`, `at java.base@21/java.lang…`).
const LIBRARY_FRAME_RE =
/^\s*at\s+(?:\S*\/)?(?:org\.junit|junit\.|org\.opentest4j|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/;
/** The source position of a stack frame in the project's code, null for library frames. */
function frameLocation(line: string): TestLocation | null {
const f = FRAME_RE.exec(line);
if (!f || LIBRARY_FRAME_RE.test(line)) return null;
return f[2] ? jvmFrameTarget(f[1], f[2], Number(f[3])) : { path: f[4]!, line: Number(f[5]), resolve: "name" };
}
/**
* Test events of the init script's messages. Tests are `[class, method]`, named as in the XML reports
* (`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 [];
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.
const failed = outcomes.get(key) === "failed";
switch (kind) {
case "testStarted":
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 "testFailed": {
outcomes.set(key, "failed");
const lines = (attrs.details || attrs.message || "").split("\n");
const events: TestEvent[] = lines.map((line) => ({ type: "output", path, line }));
const location = lines.map(frameLocation).find((l) => l);
if (location) events.push({ type: "location", path, location });
return events;
}
case "testIgnored":
if (!failed) outcomes.set(key, "skipped");
return [];
case "testFinished": {
const status = outcomes.get(key) ?? "passed";
const durationMs = attrs.duration ? Number(attrs.duration) : undefined;
return [{ type: "result", path, status, ...(durationMs === undefined ? {} : { durationMs }) }];
}
}
return [];
}
export function jvmParser(): TestOutputParser {
let failure: string[] | null = null;
let located = false;
/** The init script reports every test: Gradle's own test logging would repeat them by simple class name. */
let reported = false;
const outcomes = new Map<string, "failed" | "skipped">();
const startFailure = (path: string[]) => {
failure = path;
@@ -28,8 +84,18 @@ export function jvmParser(): TestOutputParser {
return {
feed(line) {
const events: TestEvent[] = [];
const message = serviceMessage(line);
if (message) {
reported = true;
failure = null;
return initScriptEvents(message, outcomes);
}
let m: RegExpExecArray | null;
if ((m = GRADLE_RE.exec(line.trim())) && !line.startsWith(" ")) {
if (reported) {
failure = null;
return events;
}
// `method()` is a JUnit 5 display name; the reports name the method.
const path = m[1]!.split(/\s+>\s+/).map((p, i, all) => (i === all.length - 1 ? p.replace(/\(\)$/, "") : p));
const status = m[2] === "PASSED" ? "passed" : m[2] === "FAILED" ? "failed" : "skipped";
@@ -81,20 +147,10 @@ export function jvmParser(): TestOutputParser {
return events;
}
events.push({ type: "output", path: failure, line });
const f = FRAME_RE.exec(line);
const libraryFrame =
/^\s*at\s+(?:org\.junit|junit\.|org\.opentest4j|kotlin\.|jdk\.internal|java\.|javax\.|sun\.|org\.gradle)/.test(
line,
);
if (f && !located && !libraryFrame) {
const location = located ? null : frameLocation(line);
if (location) {
located = true;
events.push({
type: "location",
path: failure,
location: f[2]
? jvmFrameTarget(f[1], f[2], Number(f[3]))
: { path: f[4]!, line: Number(f[5]), resolve: "name" },
});
events.push({ type: "location", path: failure, location });
}
}
return events;
+47
Переглянути файл
@@ -399,6 +399,53 @@ OrderControllerTest > Paging > firstPage() SKIPPED
expect(r.get("OrderTest")).toBeUndefined();
});
it("reports each test from the gradle init script's service messages", () => {
const parser = jvmParser();
const r = results(
parser,
`> Task :test
##teamcity[testStarted name='addsZero()' className='demo.CalcTest']
##teamcity[testStdOut name='addsZero()' className='demo.CalcTest' out='adding zero|n']
##teamcity[testFinished name='addsZero()' className='demo.CalcTest' duration='11']
##teamcity[testStarted name='fails()' className='demo.CalcTest']
##teamcity[testFailed name='fails()' className='demo.CalcTest' message='boom' details='org.opentest4j.AssertionFailedError: boom|n\tat app//org.junit.jupiter.api.AssertEquals.failNotEqual(AssertEquals.java:197)|n\tat app//demo.CalcTest.fails(CalcTest.java:21)|n']
##teamcity[testFinished name='fails()' className='demo.CalcTest' duration='4']
CalcTest > fails() FAILED
org.opentest4j.AssertionFailedError at CalcTest.java:21
##teamcity[testStarted name='add(int)|[1|]' className='demo.ParamTest']
##teamcity[testFailed name='add(int)|[1|]' className='demo.ParamTest' message='no' details='no']
##teamcity[testFinished name='add(int)|[1|]' className='demo.ParamTest' duration='1']
##teamcity[testStarted name='add(int)|[2|]' className='demo.ParamTest']
##teamcity[testFinished name='add(int)|[2|]' className='demo.ParamTest' duration='1']
##teamcity[testStarted name='later()' className='demo.ParamTest']
##teamcity[testIgnored name='later()' className='demo.ParamTest']
##teamcity[testFinished name='later()' className='demo.ParamTest' duration='0']
`,
);
// Named like the XML reports, and not repeated under Gradle's simple class name.
expect(r.roots.map((n) => n.name)).toEqual(["demo.CalcTest", "demo.ParamTest"]);
expect(leaf(r, "demo.CalcTest", "addsZero")).toMatchObject({
own: "passed",
durationMs: 11,
output: ["adding zero"],
});
const failed = leaf(r, "demo.CalcTest", "fails")!;
expect(failed.own).toBe("failed");
expect(failed.output[0]).toBe("org.opentest4j.AssertionFailedError: boom");
expect(failed.location).toMatchObject({ path: "demo/CalcTest.java", line: 21, className: "demo.CalcTest" });
expect(leaf(r, "demo.ParamTest", "add")?.own).toBe("failed");
expect(leaf(r, "demo.ParamTest", "later")?.own).toBe("skipped");
expect(r.hasResult(["demo.CalcTest", "fails"])).toBe(true);
expect(r.hasResult(["demo.CalcTest", "other"])).toBe(false);
});
it("shows a test as running from its start", () => {
const r = new TestResults();
r.apply(jvmParser().feed("##teamcity[testStarted name='slow()' className='demo.SlowTest']"));
expect(leaf(r, "demo.SlowTest", "slow")?.own).toBe("running");
expect(r.hasResult(["demo.SlowTest", "slow"])).toBe(false);
});
it("parses surefire reports", () => {
const r = results(
jvmParser(),
+2 -1
Переглянути файл
@@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { createTestParser, frameworkFor, withTestReporting } from "./index";
import { phpunitArgs, phpunitFilter, phpunitParser, TeamcityOutput, withTeamcity } from "./phpunit";
import { phpunitArgs, phpunitFilter, phpunitParser, withTeamcity } from "./phpunit";
import { TeamcityOutput } from "./teamcity";
import { TestResults } from "./results";
import type { TestEvent } from "./types";
+5 -29
Переглянути файл
@@ -3,24 +3,8 @@
* suite and test event, with the exact `Class::method` names and failure details. Tests are reported as
* `[...suites, test]`: the configured suite (`Unit`), the class, then the method (with its data set).
*/
import { TestStdout, type TestEvent, type TestOutputParser } from "./types";
const MESSAGE_RE = /^\s*##teamcity\[(\w+)((?:\s+[\w.-]+='(?:[^|']|\|.)*')*)\s*\]\s*$/;
const ATTR_RE = /([\w.-]+)='((?:[^|']|\|.)*)'/g;
/** TeamCity escapes: `|n` `|r` `|'` `|[` `|]` `||` and `|0xNNNN`. */
function unescape(value: string): string {
return value.replace(/\|(0x[0-9a-fA-F]{4}|.)/g, (_, c: string) => {
if (c.startsWith("0x")) return String.fromCharCode(Number.parseInt(c, 16));
return c === "n" ? "\n" : c === "r" ? "\r" : c;
});
}
function attributes(text: string): Record<string, string> {
const out: Record<string, string> = {};
for (const m of text.matchAll(ATTR_RE)) out[m[1]!] = unescape(m[2]!);
return out;
}
import { serviceMessage } from "./teamcity";
import type { TestEvent, TestOutputParser } from "./types";
/** `php_qn:///app/tests/FooTest.php::\App\FooTest::testBar` → the file. */
function hintFile(hint: string | undefined): string | null {
@@ -51,10 +35,9 @@ export function phpunitParser(): TestOutputParser {
return {
feed(line) {
const m = MESSAGE_RE.exec(line);
if (!m) return [];
const kind = m[1]!;
const a = attributes(m[2] ?? "");
const message = serviceMessage(line);
if (!message) return [];
const { kind, attrs: a } = message;
const name = a.name ?? "";
const events: TestEvent[] = [];
switch (kind) {
@@ -188,10 +171,3 @@ export function phpunitFilter(paths: readonly (readonly string[])[]): string {
}
return `/(${[...parts].join("|")})$/`;
}
/** Console text of a `--teamcity` run: the service messages only feed the test tree, as in PhpStorm. */
export class TeamcityOutput extends TestStdout {
constructor(feedLine: (line: string) => void) {
super(feedLine, (line) => (MESSAGE_RE.test(line) ? "" : line + "\n"));
}
}
+6
Переглянути файл
@@ -68,6 +68,12 @@ export class TestResults {
return this.byId.get(id);
}
/** The test at `path` has finished with a result. */
hasResult(path: readonly string[]): boolean {
const own = this.byId.get(path.join(SEP))?.own;
return !!own && own !== "running";
}
/** Finds a test by a suffix of its path (pytest failure headers name `Class.test`). */
findBySuffix(suffix: string[]): TestNode | undefined {
let best: TestNode | undefined;
+37
Переглянути файл
@@ -0,0 +1,37 @@
/**
* TeamCity service messages (`##teamcity[testStarted name='…' …]`): PHPUnit's `--teamcity` output and
* the Gradle test init script (`gradleTests.gradle`) report each test with them.
*/
import { TestStdout } from "./types";
const MESSAGE_RE = /^\s*##teamcity\[(\w+)((?:\s+[\w.-]+='(?:[^|']|\|.)*')*)\s*\]\s*$/;
const ATTR_RE = /([\w.-]+)='((?:[^|']|\|.)*)'/g;
/** TeamCity escapes: `|n` `|r` `|'` `|[` `|]` `||` and `|0xNNNN`. */
function unescape(value: string): string {
return value.replace(/\|(0x[0-9a-fA-F]{4}|.)/g, (_, c: string) => {
if (c.startsWith("0x")) return String.fromCharCode(Number.parseInt(c, 16));
return c === "n" ? "\n" : c === "r" ? "\r" : c;
});
}
export interface ServiceMessage {
kind: string;
attrs: Record<string, string>;
}
/** The service message on `line`, or null for any other output. */
export function serviceMessage(line: string): ServiceMessage | null {
const m = MESSAGE_RE.exec(line);
if (!m) return null;
const attrs: Record<string, string> = {};
for (const a of (m[2] ?? "").matchAll(ATTR_RE)) attrs[a[1]!] = unescape(a[2]!);
return { kind: m[1]!, attrs };
}
/** Console text of a run reporting through service messages: the messages only feed the test tree. */
export class TeamcityOutput extends TestStdout {
constructor(feedLine: (line: string) => void) {
super(feedLine, (line) => (MESSAGE_RE.test(line) ? "" : line + "\n"));
}
}