From 7c89e2e23bc7c4b2bb8a6add2d2fe80c834f2514 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:01:41 +0800 Subject: [PATCH] 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 --- src/features/run/gradleRun.test.ts | 23 ++++++- src/features/run/gradleRun.ts | 61 ++++++++++++----- src/features/run/gradleTests.gradle | 44 ++++++++++++ src/features/run/store.ts | 18 +++-- src/features/run/testing/jvm.ts | 86 +++++++++++++++++++----- src/features/run/testing/parsers.test.ts | 47 +++++++++++++ src/features/run/testing/phpunit.test.ts | 3 +- src/features/run/testing/phpunit.ts | 34 ++-------- src/features/run/testing/results.ts | 6 ++ src/features/run/testing/teamcity.ts | 37 ++++++++++ 10 files changed, 293 insertions(+), 66 deletions(-) create mode 100644 src/features/run/gradleTests.gradle create mode 100644 src/features/run/testing/teamcity.ts 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")); + } +}