mirror of
https://gitflic.ru/project/openide/openide.git
synced 2026-03-22 06:50:54 +07:00
BAZEL-2232: Custom test runner for JUnit tests
Co-authored-by: Andrzej Głuszak <andrzej.gluszak@jetbrains.com> Merge-request: IJ-MR-177278 Merged-by: Lev Leontev <lev.leontev@jetbrains.com> GitOrigin-RevId: 9812d7f3c4cb84510c6b41c20b4fdf3899a353d1
This commit is contained in:
committed by
intellij-monorepo-bot
parent
ea07073dfe
commit
dcf718d5e8
8
.bazelproject
Normal file
8
.bazelproject
Normal file
@@ -0,0 +1,8 @@
|
||||
# Read more about project view files here:
|
||||
# https://code.jetbrains.team/p/ij/repositories/ultimate/files/master/plugins/bazel/commons/src/main/kotlin/org/jetbrains/bazel/projectview/README.md
|
||||
|
||||
targets:
|
||||
//...
|
||||
@rules_jvm//...
|
||||
|
||||
use_jetbrains_test_runner: true
|
||||
@@ -2,15 +2,16 @@
|
||||
package com.intellij.tests;
|
||||
|
||||
import com.intellij.tests.bazel.BazelJUnitOutputListener;
|
||||
import com.intellij.tests.bazel.IjSmTestExecutionListener;
|
||||
import com.intellij.tests.bazel.bucketing.BucketsPostDiscoveryFilter;
|
||||
import org.junit.platform.engine.DiscoverySelector;
|
||||
import org.junit.platform.engine.Filter;
|
||||
import org.junit.platform.engine.FilterResult;
|
||||
import org.junit.platform.engine.TestEngine;
|
||||
import org.junit.platform.engine.TestExecutionResult;
|
||||
import org.junit.platform.engine.discovery.ClassNameFilter;
|
||||
import org.junit.platform.engine.discovery.DiscoverySelectors;
|
||||
import org.junit.platform.engine.discovery.MethodSelector;
|
||||
import org.junit.platform.engine.discovery.UniqueIdSelector;
|
||||
import org.junit.platform.launcher.*;
|
||||
import org.junit.platform.launcher.core.LauncherDiscoveryRequestBuilder;
|
||||
import org.junit.platform.launcher.core.LauncherFactory;
|
||||
@@ -26,6 +27,7 @@ import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectClass;
|
||||
import static org.junit.platform.engine.discovery.DiscoverySelectors.selectMethod;
|
||||
|
||||
@SuppressWarnings("UseOfSystemOutOrSystemErr")
|
||||
@@ -51,6 +53,12 @@ public final class JUnit5BazelRunner {
|
||||
// true by default. try as much as possible to run tests in sandbox
|
||||
private static final String jbEnvSandbox = "JB_TEST_SANDBOX";
|
||||
private static final String jbEnvXmlOutputFile = "JB_XML_OUTPUT_FILE";
|
||||
// Enable IntelliJ Service Messages stream from test process
|
||||
private static final String jbEnvIdeSmRun = "JB_IDE_SM_RUN";
|
||||
// Allows specifying an unambiguous test filter format that supports, e.g., method names with spaces in them
|
||||
private static final String jbEnvTestFilter = "JB_TEST_FILTER";
|
||||
// Allow rerun-failed selection via JUnit5 UniqueId list
|
||||
private static final String jbEnvTestUniqueIds = "JB_TEST_UNIQUE_IDS";
|
||||
|
||||
private static final ClassLoader ourClassLoader = Thread.currentThread().getContextClassLoader();
|
||||
private static final Launcher launcher = LauncherFactory.create();
|
||||
@@ -178,6 +186,15 @@ public final class JUnit5BazelRunner {
|
||||
}
|
||||
|
||||
var testExecutionListeners = getTestExecutionListeners();
|
||||
// Add shutdown hook for SM listener to handle interrupts
|
||||
IjSmTestExecutionListener smListener = null;
|
||||
for (var l : testExecutionListeners) {
|
||||
if (l instanceof IjSmTestExecutionListener) { smListener = (IjSmTestExecutionListener) l; break; }
|
||||
}
|
||||
if (smListener != null) {
|
||||
IjSmTestExecutionListener finalSmListener = smListener;
|
||||
Runtime.getRuntime().addShutdownHook(new Thread(() -> finalSmListener.closeForInterrupt(), "IjSmTestExecutionListenerShutdownHook"));
|
||||
}
|
||||
|
||||
try (var bazelJUnitOutputListener = new BazelJUnitOutputListener(xmlOutputFile)) {
|
||||
Runtime.getRuntime()
|
||||
@@ -189,10 +206,8 @@ public final class JUnit5BazelRunner {
|
||||
launcher.execute(testPlan);
|
||||
}
|
||||
|
||||
if (testExecutionListeners.stream().anyMatch(
|
||||
l -> (l instanceof ConsoleTestLogger && ((ConsoleTestLogger)l).hasTestsWithThrowableResults()) ||
|
||||
(l instanceof BazelJUnitOutputListener && ((BazelJUnitOutputListener)l).hasTestsWithThrowableResults()))
|
||||
) {
|
||||
if (testExecutionListeners.stream()
|
||||
.anyMatch(l -> l instanceof BazelJUnitOutputListener && ((BazelJUnitOutputListener)l).hasTestsWithThrowableResults())) {
|
||||
System.err.println("Some tests failed");
|
||||
System.exit(EXIT_CODE_TEST_FAILURE_OTHER);
|
||||
}
|
||||
@@ -232,11 +247,49 @@ public final class JUnit5BazelRunner {
|
||||
private static List<TestExecutionListener> getTestExecutionListeners() {
|
||||
List<TestExecutionListener> myListeners = new ArrayList<>();
|
||||
if (!isUnderTeamCity()) {
|
||||
myListeners.add(new ConsoleTestLogger());
|
||||
if ("true".equals(System.getenv(jbEnvIdeSmRun))) {
|
||||
myListeners.add(new IjSmTestExecutionListener());
|
||||
} else {
|
||||
myListeners.add(new ConsoleTestLogger());
|
||||
}
|
||||
}
|
||||
return myListeners;
|
||||
}
|
||||
|
||||
private static void addSelectorsFromJbEnv(ClassLoader classLoader, List<DiscoverySelector> out) {
|
||||
// We can use colons and semicolons as separators because they aren't allowed in identifiers neither in Java nor Kotlin
|
||||
// See https://kotlinlang.org/docs/reference/grammar.html
|
||||
|
||||
// JB_TEST_UNIQUE_IDS: semicolon-separated list of JUnit5 UniqueIds
|
||||
String uniqueIds = System.getenv(jbEnvTestUniqueIds);
|
||||
if (uniqueIds != null && !uniqueIds.isBlank()) {
|
||||
for (String uid : uniqueIds.split(";")) {
|
||||
if (!uid.isEmpty()) {
|
||||
out.add(DiscoverySelectors.selectUniqueId(uid));
|
||||
}
|
||||
}
|
||||
}
|
||||
// JB_TEST_FILTER: semicolon-separated list of class, class:method, or class:method:comma_separated_parameter_type_names
|
||||
String methods = System.getenv(jbEnvTestFilter);
|
||||
if (methods != null && !methods.isBlank()) {
|
||||
for (String token : methods.split(";")) {
|
||||
if (token.isEmpty()) continue;
|
||||
String[] parts = token.split(":");
|
||||
switch (parts.length) {
|
||||
case 1:
|
||||
out.add(selectClass(classLoader, /*className*/ parts[0]));
|
||||
break;
|
||||
case 2:
|
||||
out.add(selectMethod(classLoader, /*className*/ parts[0], /*methodName*/ parts[1]));
|
||||
break;
|
||||
case 3:
|
||||
out.add(selectMethod(classLoader, /*className*/ parts[0], /*methodName*/ parts[1], /*parameterTypeNames*/ parts[2]));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static Filter<?>[] getTestFilters(List<? extends DiscoverySelector> bazelTestSelectors) {
|
||||
List<Filter<?>> filters = new ArrayList<>();
|
||||
filters.add(bucketingPostDiscoveryFilter);
|
||||
@@ -248,8 +301,8 @@ public final class JUnit5BazelRunner {
|
||||
return filters.toArray(new Filter[0]);
|
||||
}
|
||||
|
||||
// in case when we already have precise method selectors, so we aren't going to filter by test class name
|
||||
if (bazelTestSelectors.stream().allMatch(selector -> selector instanceof MethodSelector)) {
|
||||
// If we already have precise selectors (method or uniqueId), don't also apply class name filter
|
||||
if (bazelTestSelectors.stream().allMatch(selector -> selector instanceof MethodSelector || selector instanceof UniqueIdSelector)) {
|
||||
return filters.toArray(new Filter[0]);
|
||||
}
|
||||
|
||||
@@ -265,11 +318,20 @@ public final class JUnit5BazelRunner {
|
||||
}
|
||||
|
||||
private static List<? extends DiscoverySelector> getTestsSelectors(ClassLoader classLoader) throws Throwable {
|
||||
// First, allow IDE-driven rerun-failed via explicit env vars
|
||||
List<DiscoverySelector> jbSelectors = new ArrayList<>();
|
||||
addSelectorsFromJbEnv(classLoader, jbSelectors);
|
||||
if (!jbSelectors.isEmpty()) {
|
||||
return jbSelectors;
|
||||
}
|
||||
|
||||
// Next, Bazel's TESTBRIDGE_TEST_ONLY method selector (single class#method)
|
||||
List<? extends DiscoverySelector> bazelTestClassSelector = getBazelTestMethodSelectors(classLoader);
|
||||
if (!bazelTestClassSelector.isEmpty()) {
|
||||
return bazelTestClassSelector;
|
||||
}
|
||||
|
||||
// Otherwise, discover from classpath roots
|
||||
return getTestSelectorsByClassPathRoots(classLoader);
|
||||
}
|
||||
|
||||
@@ -296,10 +358,6 @@ public final class JUnit5BazelRunner {
|
||||
if (parts.length == 2) {
|
||||
String className = parts[0];
|
||||
String methodName = parts[1];
|
||||
//let's be strict here and force user to specify fully qualified class name
|
||||
if (!className.contains(".")) {
|
||||
throw new IllegalArgumentException("Class name should contain package when filtering with method name: " + className);
|
||||
}
|
||||
System.err.println("Selecting class: " + className);
|
||||
System.err.println("Selecting method: " + methodName);
|
||||
return List.of(selectMethod(classLoader, className, methodName));
|
||||
@@ -455,8 +513,6 @@ public final class JUnit5BazelRunner {
|
||||
}
|
||||
|
||||
private static class ConsoleTestLogger implements TestExecutionListener {
|
||||
private final Set<TestIdentifier> testsWithThrowableResult = new HashSet<>();
|
||||
|
||||
@Override
|
||||
public void testPlanExecutionStarted(TestPlan testPlan) {
|
||||
System.out.println("Test plan started: " + testPlan.countTestIdentifiers(TestIdentifier::isTest) + " tests found.");
|
||||
@@ -476,25 +532,10 @@ public final class JUnit5BazelRunner {
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult result) {
|
||||
if (testIdentifier.isTest()) {
|
||||
result.getThrowable().ifPresent(testThrowable -> {
|
||||
if (!IgnoreException.isIgnoringThrowable(testThrowable)) {
|
||||
testsWithThrowableResult.add(testIdentifier);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void testPlanExecutionFinished(TestPlan testPlan) {
|
||||
System.out.println("Test plan finished with " + testPlan.countTestIdentifiers(TestIdentifier::isTest) + " tests.");
|
||||
}
|
||||
|
||||
private Boolean hasTestsWithThrowableResults() {
|
||||
return !testsWithThrowableResult.isEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license.
|
||||
package com.intellij.tests.bazel;
|
||||
|
||||
import org.junit.platform.engine.TestExecutionResult;
|
||||
import org.junit.platform.engine.TestSource;
|
||||
import org.junit.platform.engine.reporting.ReportEntry;
|
||||
import org.junit.platform.engine.support.descriptor.ClassSource;
|
||||
import org.junit.platform.engine.support.descriptor.CompositeTestSource;
|
||||
import org.junit.platform.engine.support.descriptor.FileSource;
|
||||
import org.junit.platform.engine.support.descriptor.MethodSource;
|
||||
import org.junit.platform.launcher.TestExecutionListener;
|
||||
import org.junit.platform.launcher.TestIdentifier;
|
||||
import org.junit.platform.launcher.TestPlan;
|
||||
import org.opentest4j.AssertionFailedError;
|
||||
import org.opentest4j.MultipleFailuresError;
|
||||
|
||||
import java.io.OutputStream;
|
||||
import java.io.PrintStream;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Lightweight IntelliJ Service Messages emitter for JUnit 5 execution.
|
||||
*
|
||||
* This listener prints TeamCity/IntelliJ SM messages (##teamcity[...]) to stdout so that
|
||||
* the IDE can build a live test tree and show progress while tests are running under Bazel.
|
||||
*
|
||||
* The goal is to keep dependencies to a minimum; therefore, we format SM lines directly
|
||||
* and implement a minimal escaping routine compatible with the protocol.
|
||||
*/
|
||||
@SuppressWarnings("UseOfSystemOutOrSystemErr")
|
||||
public final class IjSmTestExecutionListener implements TestExecutionListener {
|
||||
private TestPlan testPlan;
|
||||
|
||||
// Registry of discovered nodes for quick lookup by id (used for stdout attribution and base attrs)
|
||||
private final Map<String, TestIdentifier> nodesById = new HashMap<>();
|
||||
|
||||
private final Map<String, Long> testStartNanos = new HashMap<>();
|
||||
private final Set<String> startedSuites = new HashSet<>();
|
||||
private final Set<String> startedTests = new HashSet<>();
|
||||
|
||||
// Stdout/stderr capture
|
||||
private PrintStream originalOut;
|
||||
private PrintStream originalErr;
|
||||
private CapturingPrintStream capturingOut;
|
||||
private CapturingPrintStream capturingErr;
|
||||
|
||||
// Current executing test per thread (best-effort attribution)
|
||||
private final ThreadLocal<String> currentTestIdTL = new ThreadLocal<>();
|
||||
|
||||
@Override
|
||||
public void testPlanExecutionStarted(TestPlan testPlan) {
|
||||
this.testPlan = testPlan;
|
||||
|
||||
// Index all known nodes
|
||||
nodesById.clear();
|
||||
for (TestIdentifier root : testPlan.getRoots()) {
|
||||
indexRecursively(root);
|
||||
}
|
||||
|
||||
// Install stdout/stderr capturing to attribute output to running tests
|
||||
try {
|
||||
originalOut = System.out;
|
||||
originalErr = System.err;
|
||||
capturingOut = new CapturingPrintStream(originalOut, false, this);
|
||||
capturingErr = new CapturingPrintStream(originalErr, true, this);
|
||||
System.setOut(capturingOut);
|
||||
System.setErr(capturingErr);
|
||||
} catch (Throwable ignore) {
|
||||
// fail-safe: keep originals
|
||||
}
|
||||
|
||||
// Signal start of testing to IDE
|
||||
serviceMessage("enteredTheMatrix", Collections.emptyMap());
|
||||
serviceMessage("testingStarted", Collections.emptyMap());
|
||||
|
||||
// Pre-emit full test tree disabled for Bazel SM converter compatibility
|
||||
// Previously emitted testTreeStarted/testTreeNode/testTreeEnded which some converters don't handle and spam console.
|
||||
// We rely on live execution events (testSuiteStarted/testStarted) instead.
|
||||
}
|
||||
|
||||
@Override
|
||||
public void dynamicTestRegistered(TestIdentifier testIdentifier) {
|
||||
// Register dynamic node for later lookup; avoid emitting testTreeNode to prevent console spam
|
||||
nodesById.put(getId(testIdentifier), testIdentifier);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void executionStarted(TestIdentifier testIdentifier) {
|
||||
String id = getId(testIdentifier);
|
||||
if (testIdentifier.isContainer()) {
|
||||
if (!startedSuites.contains(id)) {
|
||||
Map<String, String> attrs = baseAttrs(testIdentifier);
|
||||
// Provide location hint for class-like containers
|
||||
String location = getLocationHint(testIdentifier);
|
||||
if (location != null) attrs.put("locationHint", location);
|
||||
serviceMessage("testSuiteStarted", attrs);
|
||||
startedSuites.add(id);
|
||||
}
|
||||
}
|
||||
else if (testIdentifier.isTest()) {
|
||||
Map<String, String> attrs = baseAttrs(testIdentifier);
|
||||
String location = getLocationHint(testIdentifier);
|
||||
if (location != null) attrs.put("locationHint", location);
|
||||
attrs.put("captureStandardOutput", "true");
|
||||
serviceMessage("testStarted", attrs);
|
||||
startedTests.add(id);
|
||||
testStartNanos.put(id, System.nanoTime());
|
||||
// Attribute stdout/stderr on the current thread to this test while it runs
|
||||
currentTestIdTL.set(id);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void executionSkipped(TestIdentifier testIdentifier, String reason) {
|
||||
if (testIdentifier.isTest()) {
|
||||
Map<String, String> attrs = baseAttrs(testIdentifier);
|
||||
if (reason != null && !reason.isEmpty()) attrs.put("message", reason);
|
||||
serviceMessage("testIgnored", attrs);
|
||||
} else if (testIdentifier.isContainer()) {
|
||||
// Emit started/ignored/finished trio for skipped containers to show up in UI
|
||||
executionStarted(testIdentifier);
|
||||
Map<String, String> attrs = baseAttrs(testIdentifier);
|
||||
if (reason != null && !reason.isEmpty()) attrs.put("message", reason);
|
||||
serviceMessage("testIgnored", attrs);
|
||||
executionFinished(testIdentifier, TestExecutionResult.successful());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void executionFinished(TestIdentifier testIdentifier, TestExecutionResult testExecutionResult) {
|
||||
String id = getId(testIdentifier);
|
||||
if (testIdentifier.isContainer()) {
|
||||
// If container failed/aborted, report a synthetic failure test under the suite
|
||||
Optional<Throwable> throwable = testExecutionResult.getThrowable();
|
||||
if ((testExecutionResult.getStatus() == TestExecutionResult.Status.FAILED
|
||||
|| testExecutionResult.getStatus() == TestExecutionResult.Status.ABORTED) && throwable.isPresent()) {
|
||||
String syntheticId = id + "/[suite-setup]";
|
||||
String parentId = id;
|
||||
String suiteSetupName = "<suite setup>";
|
||||
Map<String, String> start = new LinkedHashMap<>();
|
||||
start.put("name", suiteSetupName);
|
||||
start.put("nodeId", syntheticId);
|
||||
start.put("parentNodeId", parentId);
|
||||
String loc = getLocationHint(testIdentifier);
|
||||
if (loc != null) start.put("locationHint", loc);
|
||||
start.put("captureStandardOutput", "true");
|
||||
serviceMessage("testStarted", start);
|
||||
|
||||
Throwable t = throwable.get();
|
||||
Map<String, String> fail = new LinkedHashMap<>();
|
||||
fail.put("name", suiteSetupName);
|
||||
fail.put("nodeId", syntheticId);
|
||||
fail.put("parentNodeId", parentId);
|
||||
String msg = t.getMessage();
|
||||
if (msg != null && !msg.isEmpty()) fail.put("message", msg);
|
||||
fail.put("details", stackTraceToString(t));
|
||||
serviceMessage("testFailed", fail);
|
||||
|
||||
Map<String, String> fin = new LinkedHashMap<>();
|
||||
fin.put("name", suiteSetupName);
|
||||
fin.put("nodeId", syntheticId);
|
||||
fin.put("parentNodeId", parentId);
|
||||
serviceMessage("testFinished", fin);
|
||||
}
|
||||
|
||||
if (startedSuites.contains(id)) {
|
||||
Map<String, String> attrs = baseAttrs(testIdentifier);
|
||||
serviceMessage("testSuiteFinished", attrs);
|
||||
startedSuites.remove(id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (testIdentifier.isTest()) {
|
||||
Optional<Throwable> throwable = testExecutionResult.getThrowable();
|
||||
if (capturingOut != null) capturingOut.flushBufferForCurrentThread();
|
||||
if (capturingErr != null) capturingErr.flushBufferForCurrentThread();
|
||||
currentTestIdTL.remove();
|
||||
if (testExecutionResult.getStatus() == TestExecutionResult.Status.FAILED && throwable.isPresent()) {
|
||||
Throwable t = throwable.get();
|
||||
if (t instanceof MultipleFailuresError) {
|
||||
for (Throwable sub : ((MultipleFailuresError) t).getFailures()) {
|
||||
Map<String, String> fail = baseAttrs(testIdentifier);
|
||||
String message = sub.getMessage();
|
||||
if (message != null && !message.isEmpty()) fail.put("message", message);
|
||||
if (sub instanceof AssertionFailedError) {
|
||||
AssertionFailedError afe = (AssertionFailedError) sub;
|
||||
if (afe.isExpectedDefined() || afe.isActualDefined()) {
|
||||
fail.put("type", "comparisonFailure");
|
||||
String expected = afe.isExpectedDefined() && afe.getExpected() != null ? String.valueOf(afe.getExpected().getValue()) : "";
|
||||
String actual = afe.isActualDefined() && afe.getActual() != null ? String.valueOf(afe.getActual().getValue()) : "";
|
||||
fail.put("expected", expected);
|
||||
fail.put("actual", actual);
|
||||
}
|
||||
}
|
||||
fail.put("details", stackTraceToString(sub));
|
||||
serviceMessage("testFailed", fail);
|
||||
}
|
||||
} else {
|
||||
Map<String, String> fail = baseAttrs(testIdentifier);
|
||||
String message = t.getMessage();
|
||||
if (message != null && !message.isEmpty()) fail.put("message", message);
|
||||
if (t instanceof AssertionFailedError) {
|
||||
AssertionFailedError afe = (AssertionFailedError) t;
|
||||
if (afe.isExpectedDefined() || afe.isActualDefined()) {
|
||||
fail.put("type", "comparisonFailure");
|
||||
String expected = afe.isExpectedDefined() && afe.getExpected() != null ? String.valueOf(afe.getExpected().getValue()) : "";
|
||||
String actual = afe.isActualDefined() && afe.getActual() != null ? String.valueOf(afe.getActual().getValue()) : "";
|
||||
fail.put("expected", expected);
|
||||
fail.put("actual", actual);
|
||||
}
|
||||
}
|
||||
fail.put("details", stackTraceToString(t));
|
||||
serviceMessage("testFailed", fail);
|
||||
}
|
||||
}
|
||||
else if (testExecutionResult.getStatus() == TestExecutionResult.Status.ABORTED) {
|
||||
Map<String, String> attrs = baseAttrs(testIdentifier);
|
||||
attrs.put("message", "Test aborted");
|
||||
testExecutionResult.getThrowable().ifPresent(t -> attrs.put("details", stackTraceToString(t)));
|
||||
serviceMessage("testFailed", attrs);
|
||||
}
|
||||
|
||||
// finished with duration
|
||||
Map<String, String> fin = baseAttrs(testIdentifier);
|
||||
Long startNs = testStartNanos.remove(id);
|
||||
if (startNs != null) {
|
||||
long durationMs = Math.max(0, (System.nanoTime() - startNs) / 1_000_000);
|
||||
fin.put("duration", Long.toString(durationMs));
|
||||
}
|
||||
serviceMessage("testFinished", fin);
|
||||
startedTests.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reportingEntryPublished(TestIdentifier testIdentifier, ReportEntry entry) {
|
||||
if (!testIdentifier.isTest()) return;
|
||||
Map<String, String> attrs = baseAttrs(testIdentifier);
|
||||
Map<String, String> kv = entry != null ? entry.getKeyValuePairs() : null;
|
||||
boolean emitted = false;
|
||||
if (kv != null && !kv.isEmpty()) {
|
||||
String stderr = firstNonEmpty(kv, Arrays.asList("stderr", "stdErr", "err"));
|
||||
if (stderr != null) {
|
||||
Map<String, String> a = new LinkedHashMap<>(attrs);
|
||||
a.put("out", stderr);
|
||||
serviceMessage("testStdErr", a);
|
||||
emitted = true;
|
||||
}
|
||||
String stdout = firstNonEmpty(kv, Arrays.asList("stdout", "stdOut", "out"));
|
||||
if (stdout != null) {
|
||||
Map<String, String> a = new LinkedHashMap<>(attrs);
|
||||
a.put("out", stdout);
|
||||
serviceMessage("testStdOut", a);
|
||||
emitted = true;
|
||||
}
|
||||
}
|
||||
if (!emitted) {
|
||||
String text = formatReportEntry(entry);
|
||||
if (!text.isEmpty()) {
|
||||
attrs.put("out", text);
|
||||
serviceMessage("testStdOut", attrs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void testPlanExecutionFinished(TestPlan testPlan) {
|
||||
// Close any still-open suites/tests defensively
|
||||
for (String id : new ArrayList<>(startedTests)) {
|
||||
Map<String, String> fin = baseAttrsById(id);
|
||||
serviceMessage("testFinished", fin);
|
||||
}
|
||||
for (String id : new ArrayList<>(startedSuites)) {
|
||||
Map<String, String> fin = baseAttrsById(id);
|
||||
serviceMessage("testSuiteFinished", fin);
|
||||
}
|
||||
startedTests.clear();
|
||||
startedSuites.clear();
|
||||
|
||||
serviceMessage("testingFinished", Collections.emptyMap());
|
||||
this.testPlan = null;
|
||||
|
||||
// Restore streams
|
||||
tryRestoreStreams();
|
||||
}
|
||||
|
||||
private Map<String, String> baseAttrs(TestIdentifier testIdentifier) {
|
||||
Map<String, String> attrs = new LinkedHashMap<>();
|
||||
attrs.put("name", testIdentifier.getDisplayName());
|
||||
String id = getId(testIdentifier);
|
||||
attrs.put("nodeId", id);
|
||||
String parentId = getParentId(testIdentifier);
|
||||
attrs.put("parentNodeId", parentId);
|
||||
String metainfo = getMetaInfo(testIdentifier);
|
||||
attrs.put("metainfo", metainfo);
|
||||
return attrs;
|
||||
}
|
||||
|
||||
private String getId(TestIdentifier id) {
|
||||
return id.getUniqueId();
|
||||
}
|
||||
|
||||
private String getParentId(TestIdentifier id) {
|
||||
if (testPlan == null) return "0";
|
||||
return testPlan.getParent(id).map(this::getId).orElse("0");
|
||||
}
|
||||
|
||||
private static String getMetaInfo(TestIdentifier id) {
|
||||
// Keep simple: include type information
|
||||
String type = id.isContainer() ? (id.isTest() ? "test+container" : "container") : (id.isTest() ? "test" : "unknown");
|
||||
return type;
|
||||
}
|
||||
|
||||
private static String stackTraceToString(Throwable t) {
|
||||
StringWriter sw = new StringWriter();
|
||||
PrintWriter pw = new PrintWriter(sw);
|
||||
t.printStackTrace(pw);
|
||||
pw.flush();
|
||||
return sw.toString();
|
||||
}
|
||||
|
||||
private static String formatReportEntry(ReportEntry entry) {
|
||||
if (entry == null) return "";
|
||||
Map<String, String> kv = entry.getKeyValuePairs();
|
||||
if (kv == null || kv.isEmpty()) return "";
|
||||
StringBuilder sb = new StringBuilder();
|
||||
boolean first = true;
|
||||
for (Map.Entry<String, String> e : kv.entrySet()) {
|
||||
if (!first) sb.append(" \u00B7 "); // middle dot separator
|
||||
sb.append(e.getKey()).append(": ").append(e.getValue());
|
||||
first = false;
|
||||
}
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String firstNonEmpty(Map<String, String> map, java.util.List<String> keys) {
|
||||
for (String k : keys) {
|
||||
String v = map.get(k);
|
||||
if (v != null && !v.isEmpty()) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String getLocationHint(TestIdentifier id) {
|
||||
Optional<TestSource> source = id.getSource();
|
||||
if (source.isEmpty()) return null;
|
||||
TestSource s = source.get();
|
||||
if (s instanceof MethodSource) {
|
||||
MethodSource ms = (MethodSource) s;
|
||||
String className = ms.getClassName();
|
||||
String methodName = ms.getMethodName();
|
||||
if (className != null && methodName != null) {
|
||||
return "java:test://" + className + "/" + methodName;
|
||||
}
|
||||
}
|
||||
else if (s instanceof ClassSource) {
|
||||
ClassSource cs = (ClassSource) s;
|
||||
String className = cs.getClassName();
|
||||
if (className != null) return "java:suite://" + className;
|
||||
}
|
||||
else if (s instanceof FileSource) {
|
||||
FileSource fs = (FileSource) s;
|
||||
return "file://" + fs.getFile();
|
||||
}
|
||||
else if (s instanceof CompositeTestSource) {
|
||||
// Fallback to first known source inside composite
|
||||
for (TestSource child : ((CompositeTestSource) s).getSources()) {
|
||||
if (child instanceof MethodSource) {
|
||||
MethodSource ms = (MethodSource) child;
|
||||
String className = ms.getClassName();
|
||||
String methodName = ms.getMethodName();
|
||||
if (className != null && methodName != null) {
|
||||
return "java:test://" + className + "/" + methodName;
|
||||
}
|
||||
}
|
||||
else if (child instanceof ClassSource) {
|
||||
ClassSource cs = (ClassSource) child;
|
||||
String className = cs.getClassName();
|
||||
if (className != null) return "java:suite://" + className;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void indexRecursively(TestIdentifier id) {
|
||||
nodesById.put(getId(id), id);
|
||||
if (testPlan == null) return;
|
||||
for (TestIdentifier child : testPlan.getChildren(id)) {
|
||||
indexRecursively(child);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> baseAttrsById(String nodeId) {
|
||||
TestIdentifier id = nodesById.get(nodeId);
|
||||
if (id != null) return baseAttrs(id);
|
||||
Map<String, String> attrs = new LinkedHashMap<>();
|
||||
// Fallback: derive minimal required attributes from UniqueId so SM parser is satisfied
|
||||
attrs.put("name", deriveNameFromUniqueId(nodeId));
|
||||
attrs.put("nodeId", nodeId);
|
||||
String parent = deriveParentIdFromUniqueId(nodeId);
|
||||
if (parent != null && !parent.isEmpty()) attrs.put("parentNodeId", parent);
|
||||
return attrs;
|
||||
}
|
||||
|
||||
private static String deriveNameFromUniqueId(String uid) {
|
||||
if (uid == null || uid.isEmpty()) return "unknown";
|
||||
// UniqueId format example: [engine:junit-jupiter]/[class:com.acme.MyTest]/[method:test()]
|
||||
// Extract last segment content between '[' and ']', then take substring after ':' if present
|
||||
int end = uid.lastIndexOf(']');
|
||||
int start = uid.lastIndexOf("[");
|
||||
if (start >= 0 && end > start) {
|
||||
String seg = uid.substring(start + 1, end); // e.g., engine:junit-jupiter or class:com.acme.MyTest
|
||||
int colon = seg.indexOf(':');
|
||||
String value = colon >= 0 ? seg.substring(colon + 1) : seg;
|
||||
// For class FQCN, use simple name for readability
|
||||
int lastDot = value.lastIndexOf('.');
|
||||
if (lastDot >= 0 && colon >= 0 && seg.startsWith("class:")) {
|
||||
return value.substring(lastDot + 1);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
return uid; // fallback to whole uid
|
||||
}
|
||||
|
||||
private static String deriveParentIdFromUniqueId(String uid) {
|
||||
if (uid == null) return null;
|
||||
int slash = uid.lastIndexOf('/')
|
||||
; if (slash < 0) return null;
|
||||
return uid.substring(0, slash);
|
||||
}
|
||||
|
||||
private void emitStd(boolean err, String nodeId, String text) {
|
||||
if (text == null || text.isEmpty()) return;
|
||||
Map<String, String> attrs = baseAttrsById(nodeId);
|
||||
attrs.put("out", text);
|
||||
serviceMessage(err ? "testStdErr" : "testStdOut", attrs);
|
||||
}
|
||||
|
||||
public void closeForInterrupt() {
|
||||
try {
|
||||
// Mark started tests as interrupted and finish them
|
||||
for (String id : new ArrayList<>(startedTests)) {
|
||||
Map<String, String> fail = baseAttrsById(id);
|
||||
fail.put("message", "Interrupted");
|
||||
serviceMessage("testFailed", fail);
|
||||
Map<String, String> fin = baseAttrsById(id);
|
||||
serviceMessage("testFinished", fin);
|
||||
}
|
||||
startedTests.clear();
|
||||
|
||||
// Finish any open suites
|
||||
for (String id : new ArrayList<>(startedSuites)) {
|
||||
Map<String, String> fin = baseAttrsById(id);
|
||||
serviceMessage("testSuiteFinished", fin);
|
||||
}
|
||||
startedSuites.clear();
|
||||
|
||||
// Ensure closing marker
|
||||
serviceMessage("testingFinished", Collections.emptyMap());
|
||||
} catch (Throwable ignore) {
|
||||
// best-effort
|
||||
} finally {
|
||||
// Restore original streams
|
||||
tryRestoreStreams();
|
||||
}
|
||||
}
|
||||
|
||||
private void tryRestoreStreams() {
|
||||
try {
|
||||
if (originalOut != null) System.setOut(originalOut);
|
||||
if (originalErr != null) System.setErr(originalErr);
|
||||
} catch (Throwable ignore) {
|
||||
}
|
||||
}
|
||||
|
||||
private void serviceMessage(String name, Map<String, String> attrs) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("##teamcity[").append(name);
|
||||
for (Map.Entry<String, String> e : attrs.entrySet()) {
|
||||
if (e.getValue() == null) continue;
|
||||
sb.append(' ').append(e.getKey()).append("='").append(escapeTc(e.getValue())).append("'");
|
||||
}
|
||||
sb.append(']');
|
||||
PrintStream out = (originalOut != null) ? originalOut : System.out;
|
||||
out.println(sb);
|
||||
}
|
||||
|
||||
// Minimal TeamCity service messages escaping
|
||||
private static String escapeTc(String s) {
|
||||
StringBuilder r = new StringBuilder();
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
switch (c) {
|
||||
case '\n': r.append("|n"); break;
|
||||
case '\r': r.append("|r"); break;
|
||||
case '\u0085': r.append("|x"); break; // next line
|
||||
case '\u2028': r.append("|l"); break; // line sep
|
||||
case '\u2029': r.append("|p"); break; // paragraph sep
|
||||
case '|': r.append("||"); break;
|
||||
case '\'': r.append("|'"); break;
|
||||
case '[': r.append("|["); break;
|
||||
case ']': r.append("|]"); break;
|
||||
default: r.append(c);
|
||||
}
|
||||
}
|
||||
return r.toString();
|
||||
}
|
||||
|
||||
// Lightweight capturing PrintStream that attributes output to the current test via ThreadLocal
|
||||
private static final class CapturingPrintStream extends PrintStream {
|
||||
private final PrintStream original;
|
||||
private final boolean isErr;
|
||||
private final IjSmTestExecutionListener owner;
|
||||
private final ThreadLocal<StringBuilder> buffer = ThreadLocal.withInitial(StringBuilder::new);
|
||||
|
||||
private CapturingPrintStream(PrintStream original, boolean isErr, IjSmTestExecutionListener owner) {
|
||||
super(new OutputStream() {
|
||||
@Override
|
||||
public void write(int b) { }
|
||||
}, true);
|
||||
this.original = original;
|
||||
this.isErr = isErr;
|
||||
this.owner = owner;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(byte[] buf, int off, int len) {
|
||||
if (len <= 0) return;
|
||||
String s = new String(buf, off, len);
|
||||
handleText(s);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void write(int b) {
|
||||
handleText(new String(new byte[]{(byte)b}));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void flush() {
|
||||
flushBufferForCurrentThread();
|
||||
original.flush();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
flushBufferForCurrentThread();
|
||||
original.close();
|
||||
super.close();
|
||||
}
|
||||
|
||||
void flushBufferForCurrentThread() {
|
||||
String id = owner.currentTestIdTL.get();
|
||||
if (id == null) return;
|
||||
StringBuilder sb = buffer.get();
|
||||
if (sb.length() > 0) {
|
||||
owner.emitStd(isErr, id, sb.toString());
|
||||
sb.setLength(0);
|
||||
}
|
||||
}
|
||||
|
||||
private void handleText(String s) {
|
||||
String id = owner.currentTestIdTL.get();
|
||||
if (id == null || !owner.startedTests.contains(id)) {
|
||||
// Not inside a known test: forward to original stream as-is
|
||||
original.print(s);
|
||||
return;
|
||||
}
|
||||
StringBuilder sb = buffer.get();
|
||||
int start = 0;
|
||||
for (int i = 0; i < s.length(); i++) {
|
||||
char c = s.charAt(i);
|
||||
if (c == '\n') {
|
||||
sb.append(s, start, i + 1);
|
||||
owner.emitStd(isErr, id, sb.toString());
|
||||
sb.setLength(0);
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
if (start < s.length()) {
|
||||
sb.append(s, start, s.length());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import com.intellij.psi.util.CachedValue
|
||||
import com.intellij.psi.util.CachedValueProvider
|
||||
import com.intellij.psi.util.CachedValuesManager
|
||||
import com.intellij.testIntegration.TestFramework
|
||||
import org.jetbrains.kotlin.idea.base.psi.KotlinPsiHeuristics
|
||||
import org.jetbrains.kotlin.idea.highlighter.KotlinTestRunLineMarkerContributor
|
||||
import org.jetbrains.kotlin.idea.projectModel.KotlinPlatform
|
||||
import org.jetbrains.kotlin.idea.testIntegration.framework.KotlinPsiBasedTestFramework
|
||||
@@ -69,20 +70,21 @@ abstract class AbstractJvmIdePlatformKindTooling : IdePlatformKindTooling() {
|
||||
|
||||
private fun calculateUrls(declaration: KtNamedDeclaration): List<String>? {
|
||||
val qualifiedName = when (declaration) {
|
||||
is KtClassOrObject -> declaration.fqName?.asString()
|
||||
is KtNamedFunction -> declaration.containingClassOrObject?.fqName?.asString()
|
||||
is KtClassOrObject -> declaration.fqName
|
||||
is KtNamedFunction -> declaration.containingClassOrObject?.fqName
|
||||
else -> null
|
||||
} ?: return null
|
||||
val jvmQualifiedName = KotlinPsiHeuristics.getJvmName(qualifiedName)
|
||||
|
||||
return when (declaration) {
|
||||
is KtClassOrObject -> listOf("$URL_SUITE_PREFIX$qualifiedName")
|
||||
is KtClassOrObject -> listOf("$URL_SUITE_PREFIX$jvmQualifiedName")
|
||||
is KtNamedFunction -> {
|
||||
val urlList = listOf(
|
||||
"$URL_TEST_PREFIX$qualifiedName/${declaration.name}",
|
||||
"$URL_TEST_PREFIX$qualifiedName.${declaration.name}"
|
||||
"$URL_TEST_PREFIX$jvmQualifiedName/${declaration.name}",
|
||||
"$URL_TEST_PREFIX$jvmQualifiedName.${declaration.name}"
|
||||
)
|
||||
if (RunConfigurationUtils.isGradleRunConfiguration(declaration)) {
|
||||
urlList + "$URL_SUITE_PREFIX$qualifiedName/${declaration.name}"
|
||||
urlList + "$URL_SUITE_PREFIX$jvmQualifiedName/${declaration.name}"
|
||||
} else {
|
||||
urlList
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user