From aa939bb5c271295d6d287b446cedde5f0c8d74e7 Mon Sep 17 00:00:00 2001 From: "Maxim.Kolmakov" Date: Wed, 4 Feb 2026 16:55:03 +0100 Subject: [PATCH] AT-3932 Exceptions reported as failed tests on TC should inherit the test's codeowner GitOrigin-RevId: 4c29989cb680436226c4c1ac3bbd17ac8058c278 --- .../intellij/tests/TestLocationStorage.java | 131 +++++++++++++----- .../junit5/CurrentTestMethodProvider.kt | 5 +- .../starter/ci/teamcity/CodeOwnerResolver.kt | 20 +++ .../starter/ci/teamcity/TeamCityCIServer.kt | 15 ++ .../intellij/ide/starter/di/diContainer.kt | 3 + .../ide/starter/runner/CurrentTestMethod.kt | 6 +- 6 files changed, 138 insertions(+), 42 deletions(-) create mode 100644 tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/ci/teamcity/CodeOwnerResolver.kt diff --git a/platform/testFramework/bootstrap/src/com/intellij/tests/TestLocationStorage.java b/platform/testFramework/bootstrap/src/com/intellij/tests/TestLocationStorage.java index ab397af843cd..288f6d456ad5 100644 --- a/platform/testFramework/bootstrap/src/com/intellij/tests/TestLocationStorage.java +++ b/platform/testFramework/bootstrap/src/com/intellij/tests/TestLocationStorage.java @@ -54,6 +54,15 @@ public final class TestLocationStorage { public static final Logger LOG = Logger.getLogger(TestLocationStorage.class.getName()); + /** + * Holds extracted location information for a test class. + * + * @param moduleName the module name derived from the JAR path + * @param packagePath the package path extracted from bytecode + * @param fileName the source file name extracted from bytecode + */ + public record TestLocationInfo(String moduleName, String packagePath, String fileName) {} + /** * Path to the test location artifact file (NDJSON format) */ @@ -187,60 +196,108 @@ public final class TestLocationStorage { .replace("\t", "\\t"); } - static void recordTestLocation(TestIdentifier testIdentifier, TestExecutionResult.Status status, String testName) { + /** + * Extracts location information for a test identified by its {@link TestIdentifier}. + * + *

This method resolves the test class from the identifier, loads it using the context + * class loader, and extracts module name, package path, and source file name.

+ * + * @param testIdentifier the JUnit Platform test identifier + * @return location info, or {@code null} if resolution fails (failures are logged) + */ + public static TestLocationInfo getTestLocationInfo(TestIdentifier testIdentifier) { TestSource source = testIdentifier.getSource().orElse(null); String className = getClassNameFromTestSource(source); if (className == null) { - LOG.info("Cannot find class name for " + testName); - return; + LOG.info("Cannot find class name for " + testIdentifier.getDisplayName()); + return null; } try { ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); if (classLoader == null) { - LOG.info("Could not find class loader for test " + testName); - return; + LOG.info("Could not find class loader for test " + testIdentifier.getDisplayName()); + return null; } Class testClass = Class.forName(className, false, classLoader); + return getTestLocationInfo(testClass); + } + catch (Exception e) { + LOG.info(e.getMessage()); + return null; + } + } + + /** + * Extracts location information for a test class. + * + *

This method extracts module name, package path, and source file name from + * the class's code source location and bytecode.

+ * + * @param testClass the test class + * @return location info, or {@code null} if resolution fails (failures are logged) + */ + public static TestLocationInfo getTestLocationInfo(Class testClass) { + try { CodeSource codeSource = testClass.getProtectionDomain().getCodeSource(); - if (codeSource != null && codeSource.getLocation() != null) { - Path jarPath = Paths.get(codeSource.getLocation().toURI()); - String moduleName = getModuleName(jarPath); - if (moduleName == null) { - LOG.info("No module found for " + codeSource.getLocation()); - return; - } - String packagePath = getPackagePath(testClass); - String fileName = getFileName(testClass); - if (fileName == null) { - LOG.info("No file found for " + codeSource.getLocation()); - return; - } - boolean failed = (status == TestExecutionResult.Status.FAILED); + if (codeSource == null || codeSource.getLocation() == null) { + LOG.info("No code source for " + testClass.getName()); + return null; + } - String json = String.format( - "{\"test\":\"%s\",\"module\":\"%s\",\"package\":\"%s\",\"file\":\"%s\",\"failed\":%s}%n", - escapeJson(testName), - escapeJson(moduleName), - escapeJson(packagePath), - escapeJson(fileName), - failed - ); + Path jarPath = Paths.get(codeSource.getLocation().toURI()); + String moduleName = getModuleName(jarPath); + if (moduleName == null) { + LOG.info("No module found for " + codeSource.getLocation()); + return null; + } - LOG.info("Writing to " + TEST_LOCATION_ARTIFACT.toAbsolutePath()); - synchronized (TEST_LOCATION_ARTIFACT) { - // Ensure parent directory exists - Path parentDir = TEST_LOCATION_ARTIFACT.getParent(); - if (parentDir != null && !Files.exists(parentDir)) { - Files.createDirectories(parentDir); - } - Files.writeString(TEST_LOCATION_ARTIFACT, json, - StandardOpenOption.CREATE, - StandardOpenOption.APPEND); + String packagePath = getPackagePath(testClass); + String fileName = getFileName(testClass); + if (fileName == null) { + LOG.info("No file found for " + codeSource.getLocation()); + return null; + } + + return new TestLocationInfo(moduleName, packagePath, fileName); + } + catch (Exception e) { + LOG.info(e.getMessage()); + return null; + } + } + + static void recordTestLocation(TestIdentifier testIdentifier, TestExecutionResult.Status status, String testName) { + TestLocationInfo info = getTestLocationInfo(testIdentifier); + if (info == null) { + return; + } + + boolean failed = (status == TestExecutionResult.Status.FAILED); + + String json = String.format( + "{\"test\":\"%s\",\"module\":\"%s\",\"package\":\"%s\",\"file\":\"%s\",\"failed\":%s}%n", + escapeJson(testName), + escapeJson(info.moduleName()), + escapeJson(info.packagePath()), + escapeJson(info.fileName()), + failed + ); + + LOG.info("Writing to " + TEST_LOCATION_ARTIFACT.toAbsolutePath()); + try { + synchronized (TEST_LOCATION_ARTIFACT) { + // Ensure parent directory exists + Path parentDir = TEST_LOCATION_ARTIFACT.getParent(); + if (parentDir != null && !Files.exists(parentDir)) { + Files.createDirectories(parentDir); } + Files.writeString(TEST_LOCATION_ARTIFACT, json, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND); } } catch (Exception e) { diff --git a/tools/intellij.tools.ide.starter.junit5/src/com/intellij/ide/starter/junit5/CurrentTestMethodProvider.kt b/tools/intellij.tools.ide.starter.junit5/src/com/intellij/ide/starter/junit5/CurrentTestMethodProvider.kt index d8a07882c685..4ca3a2bb96b6 100644 --- a/tools/intellij.tools.ide.starter.junit5/src/com/intellij/ide/starter/junit5/CurrentTestMethodProvider.kt +++ b/tools/intellij.tools.ide.starter.junit5/src/com/intellij/ide/starter/junit5/CurrentTestMethodProvider.kt @@ -25,9 +25,8 @@ open class CurrentTestMethodProvider : TestExecutionListener { di.direct.instance().set( TestMethod( name = methodSource.methodName, - clazz = methodSource.javaClass.name, - clazzSimpleName = methodSource.javaClass.simpleName, - displayName = testIdentifier.displayName + displayName = testIdentifier.displayName, + testClass = methodSource.javaClass, ) ) } diff --git a/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/ci/teamcity/CodeOwnerResolver.kt b/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/ci/teamcity/CodeOwnerResolver.kt new file mode 100644 index 000000000000..7e7b507faff8 --- /dev/null +++ b/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/ci/teamcity/CodeOwnerResolver.kt @@ -0,0 +1,20 @@ +package com.intellij.ide.starter.ci.teamcity + +/** + * Resolves code owner for the current test. + * Default implementation is no-op; override in DI for actual resolution. + */ +interface CodeOwnerResolver { + /** + * Resolves the code owner for the current test method. + * Returns null if no test is currently running or if owner cannot be determined. + */ + fun getOwnerGroupName(): String? +} + +/** + * No-op implementation used when code owner resolution is not available. + */ +object NoOpCodeOwnerResolver : CodeOwnerResolver { + override fun getOwnerGroupName(): String? = null +} diff --git a/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/ci/teamcity/TeamCityCIServer.kt b/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/ci/teamcity/TeamCityCIServer.kt index 02c57c6c78ea..d2b6fb29f76a 100644 --- a/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/ci/teamcity/TeamCityCIServer.kt +++ b/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/ci/teamcity/TeamCityCIServer.kt @@ -27,6 +27,8 @@ open class TeamCityCIServer( null } ) : CIServer { + private val codeOwnerResolver by lazy { di.direct.instance() } + override fun publishArtifact(source: Path, artifactPath: String, artifactName: String) { TeamCityClient.publishTeamCityArtifacts(source = source, artifactPath = artifactPath, artifactName = artifactName) } @@ -42,6 +44,7 @@ open class TeamCityCIServer( "##teamcity[testFailed name='%s' message='%s' details='%s' flowId='%s' nodeId='%s' parentNodeId='0']", generifiedTestName, message.processStringForTC(), details.processStringForTC(), flowId, generifiedTestName )) + addCodeOwnerMetadata(generifiedTestName, flowId) if (isJetbrainsBuildserver) { addTestMetadata(testName = generifiedTestName, TeamCityMetadataType.LINK, flowId = flowId, name = "Start bisect", value = "https://ij-perf.labs.jb.gg/bisect/launcher?buildId=$buildId") } @@ -59,6 +62,17 @@ open class TeamCityCIServer( logOutput(String.format("##teamcity[testFinished name='%s' flowId='%s' nodeId='%s' parentNodeId='0']", generifiedTestName, flowId, generifiedTestName)) } + private fun addCodeOwnerMetadata(testName: String, flowId: String) { + val owner = codeOwnerResolver.getOwnerGroupName() ?: return + addTestMetadata( + testName = testName, + type = TeamCityMetadataType.TEXT, + flowId = flowId, + name = CODE_OWNER_METADATA_NAME, + value = owner, + ) + } + override fun reportTestFailure(testName: String, message: String, details: String, linkToLogs: String?) { reportTest(testName, message, details, linkToLogs, isFailure = true) } @@ -249,6 +263,7 @@ open class TeamCityCIServer( companion object { const val LOCAL_RUN_ID = "LOCAL_RUN_SNAPSHOT" + private const val CODE_OWNER_METADATA_NAME = "Code Owner" fun String.processStringForTC(): String { //todo replace to intellij.platform.testFramework.util.teamcity.escapeStringForTeamCity when module is published diff --git a/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/di/diContainer.kt b/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/di/diContainer.kt index 2d5b28ae651f..cf28afd0d8ce 100644 --- a/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/di/diContainer.kt +++ b/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/di/diContainer.kt @@ -3,6 +3,8 @@ package com.intellij.ide.starter.di import com.intellij.ide.starter.buildTool.BuildTool import com.intellij.ide.starter.ci.CIServer import com.intellij.ide.starter.ci.NoCIServer +import com.intellij.ide.starter.ci.teamcity.NoOpCodeOwnerResolver +import com.intellij.ide.starter.ci.teamcity.CodeOwnerResolver import com.intellij.ide.starter.community.PublicIdeDownloader import com.intellij.ide.starter.config.ConfigurationStorage import com.intellij.ide.starter.config.ScrambleToolProvider @@ -99,6 +101,7 @@ private var _di = DI { bindSingleton { LocalOnlyTargetResolver } bindSingleton { object : ScrambleToolProvider {} } bindSingleton { NoOpDevBuildServerRunner } + bindSingleton { NoOpCodeOwnerResolver } }.apply { logOutput("Starter DI was initialized") } diff --git a/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/runner/CurrentTestMethod.kt b/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/runner/CurrentTestMethod.kt index 9805c9b0b331..831a9c6b4f50 100644 --- a/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/runner/CurrentTestMethod.kt +++ b/tools/intellij.tools.ide.starter/src/com/intellij/ide/starter/runner/CurrentTestMethod.kt @@ -4,12 +4,14 @@ import java.time.LocalDateTime data class TestMethod( val name: String, - val clazz: String, - val clazzSimpleName: String, val displayName: String, + val testClass: Class<*>, val startTime: LocalDateTime = LocalDateTime.now(), var arguments: List = emptyList(), ) { + val clazzSimpleName: String = testClass.simpleName + val clazz: String = testClass.name + fun argsString(): String = arguments.takeIf { it.isNotEmpty() }?.joinToString(prefix = "(", postfix = ")", separator = " ") ?: "" fun fullName(): String {