mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 08:07:49 +00:00
fix(run): report Gradle tests in the test tree as they run
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
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GradleModel, RunConfig } from "@/platform/ipc";
|
||||
import { applicationConfig } from "./application";
|
||||
import { DELEGATED_DEBUG, delegatedGradleConfig, wrapperGradleVersion } from "./gradleRun";
|
||||
import { DELEGATED_DEBUG, delegatedGradleConfig, withTestEvents, wrapperGradleVersion } from "./gradleRun";
|
||||
import { jdwpLaunch } from "./javaDebug";
|
||||
import { isJvmApplication } from "@/platform/javaLaunch";
|
||||
|
||||
@@ -122,3 +122,24 @@ describe("Build and run using: Gradle", () => {
|
||||
expect(jdwpLaunch(debug)!.args.filter((a) => a === DELEGATED_DEBUG)).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Gradle test runs", () => {
|
||||
const script = "/tmp/openidea-gradle-tests.gradle";
|
||||
it("add the init script that reports each test", () => {
|
||||
expect(
|
||||
withTestEvents({ kind: "gradle", command: "./gradlew", args: ["test", "--tests", "*OrderTest"] }, script).args,
|
||||
).toEqual(["--init-script", script, "test", "--tests", "*OrderTest"]);
|
||||
expect(withTestEvents({ kind: "shell", command: "C:\\w\\gradlew.bat", args: ["test"] }, script).args).toEqual([
|
||||
"--init-script",
|
||||
script,
|
||||
"test",
|
||||
]);
|
||||
});
|
||||
|
||||
it("leave other runs and an added script alone", () => {
|
||||
const maven = { kind: "maven", command: "./mvnw", args: ["test"] };
|
||||
expect(withTestEvents(maven, script)).toBe(maven);
|
||||
const twice = withTestEvents({ kind: "gradle", command: "gradle", args: ["test"] }, script);
|
||||
expect(withTestEvents(twice, script)).toBe(twice);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
* the forked JVM (`-Dopenidea.run.debug`), not to the Gradle daemon.
|
||||
*/
|
||||
import RUN_SCRIPT from "./gradleRun.gradle?raw";
|
||||
import TESTS_SCRIPT from "./gradleTests.gradle?raw";
|
||||
import { useBuildModel } from "@/platform/buildModel";
|
||||
import { call, commands, isTauri, type GradleModel, type RunConfig } from "@/platform/ipc";
|
||||
import { findJavaModule, isJvmApplication, parseJavaArgs } from "@/platform/javaLaunch";
|
||||
@@ -84,20 +85,47 @@ export function delegatedGradleConfig(config: RunConfig, module: string, ctx: De
|
||||
};
|
||||
}
|
||||
|
||||
/** Writes the init script where Gradle can read it; it never changes, so once per session. */
|
||||
let scriptPath: Promise<string> | null = null;
|
||||
function writeScript(): Promise<string> {
|
||||
scriptPath ??= (async () => {
|
||||
const { tempDir, join } = await import("@tauri-apps/api/path");
|
||||
const path = await join(await tempDir(), "openidea-gradle-run.gradle");
|
||||
await call(commands.fsDelete(path)).catch(() => {});
|
||||
await call(commands.fsCreateFileBytes(path, Array.from(new TextEncoder().encode(RUN_SCRIPT))));
|
||||
return path;
|
||||
})().catch((e) => {
|
||||
scriptPath = null;
|
||||
throw e;
|
||||
});
|
||||
return scriptPath;
|
||||
/** Writes an init script where Gradle can read it; it never changes, so once per session. */
|
||||
const scriptPaths = new Map<string, Promise<string>>();
|
||||
function writeScript(name: string, text: string): Promise<string> {
|
||||
let written = scriptPaths.get(name);
|
||||
if (!written) {
|
||||
written = (async () => {
|
||||
const { tempDir, join } = await import("@tauri-apps/api/path");
|
||||
const path = await join(await tempDir(), name);
|
||||
await call(commands.fsDelete(path)).catch(() => {});
|
||||
await call(commands.fsCreateFileBytes(path, Array.from(new TextEncoder().encode(text))));
|
||||
return path;
|
||||
})().catch((e) => {
|
||||
scriptPaths.delete(name);
|
||||
throw e;
|
||||
});
|
||||
scriptPaths.set(name, written);
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
const isGradleCommand = (config: Pick<RunConfig, "kind" | "command">) =>
|
||||
config.kind === "gradle" || /(^|[/\\])gradlew?(\.bat)?$/.test(config.command);
|
||||
|
||||
/** `config` with the init script (`script`) that reports each test as it runs, for a Gradle test run. */
|
||||
export function withTestEvents<T extends Pick<RunConfig, "kind" | "command" | "args">>(config: T, script: string): T {
|
||||
if (!isGradleCommand(config) || config.args.includes(script)) return config;
|
||||
return { ...config, args: ["--init-script", script, ...config.args] };
|
||||
}
|
||||
|
||||
/**
|
||||
* A Gradle test run reporting each test as it runs (`gradleTests.gradle`): Gradle prints only
|
||||
* failures, and its XML reports are complete only once a test task ends.
|
||||
*/
|
||||
export async function gradleTestLaunch<T extends RunConfig>(config: T): Promise<T> {
|
||||
if (!isTauri() || !config.isTest || !isGradleCommand(config)) return config;
|
||||
try {
|
||||
return withTestEvents(config, await writeScript("openidea-gradle-tests.gradle", TESTS_SCRIPT));
|
||||
} catch (e) {
|
||||
console.warn("Gradle test init script not written", e);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -121,7 +149,10 @@ export async function gradleDelegation(config: RunConfig): Promise<RunConfig | n
|
||||
configurationCache: false,
|
||||
};
|
||||
if (!delegatedGradleConfig(config, module, ctx)) return null;
|
||||
const [script, configurationCache] = await Promise.all([writeScript(), supportsConfigurationCache(ctx)]);
|
||||
const [script, configurationCache] = await Promise.all([
|
||||
writeScript("openidea-gradle-run.gradle", RUN_SCRIPT),
|
||||
supportsConfigurationCache(ctx),
|
||||
]);
|
||||
return delegatedGradleConfig(config, module, { ...ctx, script, configurationCache });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
// OpenIDEA: reports each test of Gradle's Test tasks as a TeamCity service message on the
|
||||
// build's output, for the Run window's test tree: Gradle itself prints only failures (or a
|
||||
// project's own format), and writes its XML reports when a task ends. Test tasks always run,
|
||||
// so a rerun never ends UP-TO-DATE without results.
|
||||
|
||||
def tc = { String s ->
|
||||
s == null ? '' : s.replace('|', '||').replace("'", "|'").replace('\n', '|n').replace('\r', '|r')
|
||||
.replace('[', '|[').replace(']', '|]')
|
||||
}
|
||||
|
||||
allprojects {
|
||||
tasks.withType(Test).configureEach { Test task ->
|
||||
task.outputs.upToDateWhen { false }
|
||||
task.addTestListener(new TestListener() {
|
||||
void beforeSuite(TestDescriptor suite) {}
|
||||
|
||||
void afterSuite(TestDescriptor suite, TestResult result) {}
|
||||
|
||||
void beforeTest(TestDescriptor test) {
|
||||
println "##teamcity[testStarted name='${tc(test.name)}' className='${tc(test.className)}']"
|
||||
}
|
||||
|
||||
void afterTest(TestDescriptor test, TestResult result) {
|
||||
def id = "name='${tc(test.name)}' className='${tc(test.className)}'"
|
||||
def type = result.resultType.toString()
|
||||
if (type == 'FAILURE') {
|
||||
def error = result.exception
|
||||
def trace = new StringWriter()
|
||||
error?.printStackTrace(new PrintWriter(trace))
|
||||
println "##teamcity[testFailed ${id} message='${tc(error?.toString())}' details='${tc(trace.toString())}']"
|
||||
} else if (type == 'SKIPPED') {
|
||||
println "##teamcity[testIgnored ${id}]"
|
||||
}
|
||||
println "##teamcity[testFinished ${id} duration='${result.endTime - result.startTime}']"
|
||||
}
|
||||
})
|
||||
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)}']"
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
} from "./testing";
|
||||
import { GoTestOutput } from "./testing/go";
|
||||
import { moveToReportedClasses } from "./testing/jvm";
|
||||
import { TeamcityOutput } from "./testing/phpunit";
|
||||
import { TeamcityOutput } from "./testing/teamcity";
|
||||
import type { TestStdout } from "./testing/types";
|
||||
import { recordTestStates } from "./testing/states";
|
||||
import { failedTestPaths, leafTestPaths, rerunFailedConfig, testsConfig } from "./testing/rerun";
|
||||
@@ -54,7 +54,7 @@ import { typeFor } from "./types";
|
||||
import { recordRun } from "./services";
|
||||
import { startedPort } from "./serverPort";
|
||||
import { tomcatBrowserUrl } from "./tomcat";
|
||||
import { gradleDelegation } from "./gradleRun";
|
||||
import { gradleDelegation, gradleTestLaunch } from "./gradleRun";
|
||||
import { openUrl } from "./navigate";
|
||||
import { tx } from "@/platform/i18n";
|
||||
|
||||
@@ -567,8 +567,13 @@ function newRuntime(config: RunConfig): SessionRuntime {
|
||||
if (tests && parser) tests.apply(parser.feed(line));
|
||||
};
|
||||
const framework = tests && parser ? frameworkFor(config) : null;
|
||||
// PHPUnit `--teamcity` and Gradle's test init script report through service messages.
|
||||
const stdout =
|
||||
framework === "go" ? new GoTestOutput(feed) : framework === "phpunit" ? new TeamcityOutput(feed) : null;
|
||||
framework === "go"
|
||||
? new GoTestOutput(feed)
|
||||
: framework === "phpunit" || framework === "jvm"
|
||||
? new TeamcityOutput(feed)
|
||||
: null;
|
||||
const runtime: SessionRuntime = {
|
||||
stdout,
|
||||
tests,
|
||||
@@ -802,7 +807,8 @@ 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 = launch.isTest ? withTestReporting(launch) : launch;
|
||||
const started = launch.isTest ? await gradleTestLaunch(withTestReporting(launch)) : launch;
|
||||
if (runtimes.get(sessionId)?.generation !== generation) return getSession(sessionId) ?? null;
|
||||
const processStarted = performance.now();
|
||||
const info = await startWithRemedies(started, () => call(commands.runStart(started, channel)));
|
||||
console.info("Run process started", { name: config.name, ms: performance.now() - processStarted });
|
||||
@@ -921,7 +927,9 @@ 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);
|
||||
rt.tests.apply(testReportEvents(reports));
|
||||
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)));
|
||||
moveToReportedClasses(
|
||||
rt.tests,
|
||||
reports.map((r) => r.className),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user