mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-13 04:34:21 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c89e2e23b |
No files matched your search
@@ -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