diff --git a/.bazelproject b/.bazelproject new file mode 100644 index 000000000000..5c6a3cdc50fb --- /dev/null +++ b/.bazelproject @@ -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 diff --git a/platform/testFramework/bootstrap/src/com/intellij/tests/JUnit5BazelRunner.java b/platform/testFramework/bootstrap/src/com/intellij/tests/JUnit5BazelRunner.java index 0570390ca3ee..700eab75d430 100644 --- a/platform/testFramework/bootstrap/src/com/intellij/tests/JUnit5BazelRunner.java +++ b/platform/testFramework/bootstrap/src/com/intellij/tests/JUnit5BazelRunner.java @@ -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 getTestExecutionListeners() { List 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 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 bazelTestSelectors) { List> 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 getTestsSelectors(ClassLoader classLoader) throws Throwable { + // First, allow IDE-driven rerun-failed via explicit env vars + List jbSelectors = new ArrayList<>(); + addSelectorsFromJbEnv(classLoader, jbSelectors); + if (!jbSelectors.isEmpty()) { + return jbSelectors; + } + + // Next, Bazel's TESTBRIDGE_TEST_ONLY method selector (single class#method) List 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 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(); - } } diff --git a/platform/testFramework/bootstrap/src/com/intellij/tests/bazel/IjSmTestExecutionListener.java b/platform/testFramework/bootstrap/src/com/intellij/tests/bazel/IjSmTestExecutionListener.java new file mode 100644 index 000000000000..8b85dd8ba46b --- /dev/null +++ b/platform/testFramework/bootstrap/src/com/intellij/tests/bazel/IjSmTestExecutionListener.java @@ -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 nodesById = new HashMap<>(); + + private final Map testStartNanos = new HashMap<>(); + private final Set startedSuites = new HashSet<>(); + private final Set 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 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 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 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 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 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 = 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 = ""; + Map 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 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 fin = new LinkedHashMap<>(); + fin.put("name", suiteSetupName); + fin.put("nodeId", syntheticId); + fin.put("parentNodeId", parentId); + serviceMessage("testFinished", fin); + } + + if (startedSuites.contains(id)) { + Map attrs = baseAttrs(testIdentifier); + serviceMessage("testSuiteFinished", attrs); + startedSuites.remove(id); + } + return; + } + + if (testIdentifier.isTest()) { + Optional 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 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 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 attrs = baseAttrs(testIdentifier); + attrs.put("message", "Test aborted"); + testExecutionResult.getThrowable().ifPresent(t -> attrs.put("details", stackTraceToString(t))); + serviceMessage("testFailed", attrs); + } + + // finished with duration + Map 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 attrs = baseAttrs(testIdentifier); + Map 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 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 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 fin = baseAttrsById(id); + serviceMessage("testFinished", fin); + } + for (String id : new ArrayList<>(startedSuites)) { + Map fin = baseAttrsById(id); + serviceMessage("testSuiteFinished", fin); + } + startedTests.clear(); + startedSuites.clear(); + + serviceMessage("testingFinished", Collections.emptyMap()); + this.testPlan = null; + + // Restore streams + tryRestoreStreams(); + } + + private Map baseAttrs(TestIdentifier testIdentifier) { + Map 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 kv = entry.getKeyValuePairs(); + if (kv == null || kv.isEmpty()) return ""; + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Map.Entry 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 map, java.util.List 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 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 baseAttrsById(String nodeId) { + TestIdentifier id = nodesById.get(nodeId); + if (id != null) return baseAttrs(id); + Map 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 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 fail = baseAttrsById(id); + fail.put("message", "Interrupted"); + serviceMessage("testFailed", fail); + Map fin = baseAttrsById(id); + serviceMessage("testFinished", fin); + } + startedTests.clear(); + + // Finish any open suites + for (String id : new ArrayList<>(startedSuites)) { + Map 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 attrs) { + StringBuilder sb = new StringBuilder(); + sb.append("##teamcity[").append(name); + for (Map.Entry 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 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()); + } + } + } +} diff --git a/plugins/kotlin/base/code-insight/src/org/jetbrains/kotlin/idea/base/codeInsight/tooling/AbstractJvmIdePlatformKindTooling.kt b/plugins/kotlin/base/code-insight/src/org/jetbrains/kotlin/idea/base/codeInsight/tooling/AbstractJvmIdePlatformKindTooling.kt index 7055a47e56c8..f29f346d031a 100644 --- a/plugins/kotlin/base/code-insight/src/org/jetbrains/kotlin/idea/base/codeInsight/tooling/AbstractJvmIdePlatformKindTooling.kt +++ b/plugins/kotlin/base/code-insight/src/org/jetbrains/kotlin/idea/base/codeInsight/tooling/AbstractJvmIdePlatformKindTooling.kt @@ -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? { 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 }