AT-3932 Exceptions reported as failed tests on TC should inherit the test's codeowner

GitOrigin-RevId: 4c29989cb680436226c4c1ac3bbd17ac8058c278
This commit is contained in:
Maxim.Kolmakov
2026-02-06 00:21:20 +00:00
committed by intellij-monorepo-bot
parent 4b0231e8fd
commit aa939bb5c2
6 changed files with 138 additions and 42 deletions
@@ -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}.
*
* <p>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.</p>
*
* @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.
*
* <p>This method extracts module name, package path, and source file name from
* the class's code source location and bytecode.</p>
*
* @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) {
@@ -25,9 +25,8 @@ open class CurrentTestMethodProvider : TestExecutionListener {
di.direct.instance<CurrentTestMethod>().set(
TestMethod(
name = methodSource.methodName,
clazz = methodSource.javaClass.name,
clazzSimpleName = methodSource.javaClass.simpleName,
displayName = testIdentifier.displayName
displayName = testIdentifier.displayName,
testClass = methodSource.javaClass,
)
)
}
@@ -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
}
@@ -27,6 +27,8 @@ open class TeamCityCIServer(
null
}
) : CIServer {
private val codeOwnerResolver by lazy { di.direct.instance<CodeOwnerResolver>() }
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
@@ -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<TargetResolver> { LocalOnlyTargetResolver }
bindSingleton<ScrambleToolProvider> { object : ScrambleToolProvider {} }
bindSingleton<DevBuildServerRunner> { NoOpDevBuildServerRunner }
bindSingleton<CodeOwnerResolver> { NoOpCodeOwnerResolver }
}.apply {
logOutput("Starter DI was initialized")
}
@@ -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<Any> = 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 {