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:
Andrzej Głuszak
2025-10-09 13:12:17 +00:00
committed by intellij-monorepo-bot
parent ea07073dfe
commit dcf718d5e8
4 changed files with 674 additions and 35 deletions

8
.bazelproject Normal file
View 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

View File

@@ -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();
}
}

View File

@@ -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());
}
}
}
}

View File

@@ -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
}