diff --git a/src/features/run/gradleRun.test.ts b/src/features/run/gradleRun.test.ts index 27584d19..642091c3 100644 --- a/src/features/run/gradleRun.test.ts +++ b/src/features/run/gradleRun.test.ts @@ -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); + }); +}); diff --git a/src/features/run/gradleRun.ts b/src/features/run/gradleRun.ts index 43a60500..1b1024b7 100644 --- a/src/features/run/gradleRun.ts +++ b/src/features/run/gradleRun.ts @@ -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 | null = null; -function writeScript(): Promise { - 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>(); +function writeScript(name: string, text: string): Promise { + 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) => + 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>(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(config: T): Promise { + 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 + 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)}']" + } + }) + } +} diff --git a/src/features/run/store.ts b/src/features/run/store.ts index ff4ae845..6c542050 100644 --- a/src/features/run/store.ts +++ b/src/features/run/store.ts @@ -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), diff --git a/src/features/run/testing/jvm.ts b/src/features/run/testing/jvm.ts index 71c6cf11..8d442b53 100644 --- a/src/features/run/testing/jvm.ts +++ b/src/features/run/testing/jvm.ts @@ -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): 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(); 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; diff --git a/src/features/run/testing/parsers.test.ts b/src/features/run/testing/parsers.test.ts index db5e1f30..93e74d96 100644 --- a/src/features/run/testing/parsers.test.ts +++ b/src/features/run/testing/parsers.test.ts @@ -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(), diff --git a/src/features/run/testing/phpunit.test.ts b/src/features/run/testing/phpunit.test.ts index 8a72967f..1cf26046 100644 --- a/src/features/run/testing/phpunit.test.ts +++ b/src/features/run/testing/phpunit.test.ts @@ -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"; diff --git a/src/features/run/testing/phpunit.ts b/src/features/run/testing/phpunit.ts index eaf986c8..c6fdbe81 100644 --- a/src/features/run/testing/phpunit.ts +++ b/src/features/run/testing/phpunit.ts @@ -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 { - const out: Record = {}; - 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")); - } -} diff --git a/src/features/run/testing/results.ts b/src/features/run/testing/results.ts index 468ff636..e0c75aca 100644 --- a/src/features/run/testing/results.ts +++ b/src/features/run/testing/results.ts @@ -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; diff --git a/src/features/run/testing/teamcity.ts b/src/features/run/testing/teamcity.ts new file mode 100644 index 00000000..cbb495b7 --- /dev/null +++ b/src/features/run/testing/teamcity.ts @@ -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; +} + +/** 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 = {}; + 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")); + } +}