[IJI-3114] bazel: resolve test dependencies using manifest file on windows test runs

GitOrigin-RevId: 396be8ad504c795723e54631b1eeb2a06b4c853b
This commit is contained in:
Evgenii Ilichev
2025-11-05 10:19:01 +01:00
committed by intellij-monorepo-bot
parent 72b00d5935
commit 984c33b9ed
7 changed files with 194 additions and 19 deletions

View File

@@ -22,6 +22,8 @@
<sourceFolder url="file://$MODULE_DIR$/src" isTestSource="false" />
<sourceFolder url="file://$MODULE_DIR$/gen" isTestSource="false" generated="true" />
<sourceFolder url="file://$MODULE_DIR$/resources" type="java-resource" />
<sourceFolder url="file://$MODULE_DIR$/test" isTestSource="true" />
<sourceFolder url="file://$MODULE_DIR$/testResources" type="java-test-resource" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
@@ -124,6 +126,7 @@
<orderEntry type="module" module-name="intellij.xml.psi" />
<orderEntry type="module" module-name="intellij.java.syntax" />
<orderEntry type="module" module-name="intellij.java.psi" />
<orderEntry type="module" module-name="intellij.platform.testFramework.common" scope="TEST" />
</component>
<component name="copyright">
<Base>

View File

@@ -16,6 +16,7 @@ import com.intellij.openapi.progress.ProgressManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.project.ProjectBundle;
import com.intellij.openapi.projectRoots.*;
import com.intellij.openapi.projectRoots.testFramework.TestJdkAnnotationsFilesProvider;
import com.intellij.openapi.roots.AnnotationOrderRootType;
import com.intellij.openapi.roots.JavadocOrderRootType;
import com.intellij.openapi.roots.OrderRootType;
@@ -454,10 +455,16 @@ public final class JavaSdkImpl extends JavaSdk {
// Bazel-provided test dependencies, from runfiles tree
String testSrcDir = System.getenv("TEST_SRCDIR");
if (testSrcDir != null && !testSrcDir.isBlank()) {
Path root1 = Path.of(testSrcDir, "community+/java/jdkAnnotations");
Path root2 = Path.of(testSrcDir, "_main/java/jdkAnnotations");
Path rootPath = Files.isDirectory(root1) ? root1 : Files.isDirectory(root2) ? root2 : null;
ServiceLoader<TestJdkAnnotationsFilesProvider> providerClasses = ServiceLoader.load(TestJdkAnnotationsFilesProvider.class);
var iterator = providerClasses.iterator();
if (!iterator.hasNext()) {
throw new IllegalStateException("TestJdkAnnotationsFilesProvider service provider not found");
}
TestJdkAnnotationsFilesProvider provider = iterator.next();
if (iterator.hasNext()) {
throw new IllegalStateException("more than one TestJdkAnnotationsFilesProvider service providers found. Only one is expected");
}
Path rootPath = provider.getJdkAnnotationsPath();
if (rootPath != null) {
String path = FileUtil.toSystemIndependentName(rootPath.toString());
root = refresh ? lfs.refreshAndFindFileByPath(path) : lfs.findFileByPath(path);

View File

@@ -0,0 +1,9 @@
// 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.openapi.projectRoots.testFramework;
import java.nio.file.Path;
public interface TestJdkAnnotationsFilesProvider {
Path getJdkAnnotationsPath();
}

View File

@@ -0,0 +1,13 @@
package com.intellij.openapi.projectRoots.files;
import com.intellij.testFramework.common.BazelTestUtil;
import com.intellij.openapi.projectRoots.testFramework.TestJdkAnnotationsFilesProvider;
import java.nio.file.Path;
public class TestJdkAnnotationsFilesProviderImpl implements TestJdkAnnotationsFilesProvider {
@Override
public Path getJdkAnnotationsPath() {
return BazelTestUtil.findRunfilesDirectoryUnderCommunityOrUltimate("java/jdkAnnotations");
}
}

View File

@@ -0,0 +1 @@
com.intellij.openapi.projectRoots.files.TestJdkAnnotationsFilesProviderImpl

View File

@@ -38,6 +38,7 @@ public final class JUnit5BazelRunner {
private static final int EXIT_CODE_TEST_RUNNER_FAILURE = 2;
private static final int EXIT_CODE_TEST_FAILURE_OOM = 137;
private static final String bazelEnvRunfilesManifestOnly = "RUNFILES_MANIFEST_ONLY";
private static final String bazelEnvSelfLocation = "SELF_LOCATION";
private static final String bazelEnvTestTmpDir = "TEST_TMPDIR";
private static final String bazelEnvRunFilesDir = "RUNFILES_DIR";
@@ -422,10 +423,13 @@ public final class JUnit5BazelRunner {
}
private static Boolean isBazelTestRun() {
return Stream.of(bazelEnvSelfLocation, bazelEnvTestTmpDir, bazelEnvRunFilesDir, bazelEnvJavaRunFilesDir)
return Stream.of(bazelEnvTestTmpDir, bazelEnvRunFilesDir, bazelEnvJavaRunFilesDir)
.allMatch(bazelTestEnv -> {
var bazelTestEnvValue = System.getenv(bazelTestEnv);
return bazelTestEnvValue != null && !bazelTestEnvValue.isBlank();
}) && Stream.of(bazelEnvSelfLocation, bazelEnvRunfilesManifestOnly).anyMatch(bazelTestEnv -> {
var bazelTestEnvValue = System.getenv(bazelTestEnv);
return bazelTestEnvValue != null && !bazelTestEnvValue.isBlank();
});
}
@@ -497,6 +501,10 @@ public final class JUnit5BazelRunner {
List<Path> paths = (List<Path>)getBaseUrls.invoke(classLoader);
String bazelTestSelfLocation = System.getenv(bazelEnvSelfLocation);
// the relevant jars are expected to be next to the classloader when no SELF_LOCATION is set (singlejar, windows runs)
if (bazelTestSelfLocation == null || bazelTestSelfLocation.isBlank()) {
return new HashSet<>(paths);
}
Path bazelTestSelfLocationDir = Path.of(bazelTestSelfLocation).getParent().toAbsolutePath();
return paths.stream()
.filter(p -> bazelTestSelfLocationDir.equals(p.toAbsolutePath().getParent()))

View File

@@ -4,10 +4,8 @@ package com.intellij.testFramework.common
import com.intellij.testFramework.common.bazel.BazelLabel
import org.jetbrains.annotations.ApiStatus
import java.nio.file.Path
import kotlin.io.path.absolute
import kotlin.io.path.isDirectory
import kotlin.io.path.isRegularFile
import kotlin.io.path.useLines
import kotlin.io.path.*
import kotlin.math.max
@ApiStatus.Experimental
object BazelTestUtil {
@@ -15,6 +13,7 @@ object BazelTestUtil {
// also https://leimao.github.io/blog/Bazel-Test-Outputs/
private const val TEST_SRCDIR_ENV_NAME = "TEST_SRCDIR"
private const val TEST_UNDECLARED_OUTPUTS_DIR_ENV_NAME = "TEST_UNDECLARED_OUTPUTS_DIR"
private const val RUNFILES_MANIFEST_ONLY_ENV_NAME = "RUNFILES_MANIFEST_ONLY"
@JvmStatic
val isUnderBazelTest: Boolean =
@@ -27,7 +26,13 @@ object BazelTestUtil {
*/
@JvmStatic
val bazelTestRepoMapping: Map<String, RepoMappingEntry> by lazy {
bazelTestRunfilesPath.resolve("_repo_mapping").useLines { lines ->
val repoMappingFile = when {
bazelTestRunfilesPath.resolve("_repo_mapping").exists() -> bazelTestRunfilesPath.resolve("_repo_mapping")
Path.of(bazelRunfilesManifestResolver.get("_repo_mapping")).exists() ->
Path.of(bazelRunfilesManifestResolver.get("_repo_mapping"))
else -> error("repo_mapping file not found.")
}
repoMappingFile.useLines { lines ->
lines
.filter { it.isNotBlank() && it.isNotEmpty() }
.map { parseRepoEntry(it) }
@@ -36,6 +41,18 @@ object BazelTestUtil {
}
}
@JvmStatic
val bazelRunfilesManifestResolver: BazelRunfilesManifest by lazy {
BazelRunfilesManifest()
}
// Bazel sets RUNFILES_MANIFEST_ONLY=1 on platforms that only support manifest-based runfiles (e.g., Windows).
// Cache it to avoid repeated env lookups and branching cost in hot paths.
private val runfilesManifestOnly: Boolean by lazy {
val v = System.getenv(RUNFILES_MANIFEST_ONLY_ENV_NAME)
v != null && v.isNotBlank() && v == "1"
}
/**
* Absolute path to the base of the runfiles tree (your test dependencies too),
* see [Test encyclopedia](https://bazel.build/reference/test-encyclopedia#initial-conditions)
@@ -71,15 +88,29 @@ object BazelTestUtil {
val repoEntry = bazelTestRepoMapping.getOrElse(label.repo) {
error("Unable to determine dependency path '${label.asLabel}'")
}
val file = bazelTestRunfilesPath
.resolve(repoEntry.runfilesRelativePath)
.let { if (label.packageName.isNotEmpty()) it.resolve(label.packageName) else it }
.resolve(label.target)
// Build a single relative key used both for runfiles tree and for manifest lookup
val manifestKey = buildString {
append(repoEntry.runfilesRelativePath)
if (label.packageName.isNotEmpty()) {
append('/')
append(label.packageName)
}
append('/')
append(label.target)
}
// Fast path for manifest-only environments: avoid touching filesystem entirely
if (runfilesManifestOnly) {
val resolved = Path.of(bazelRunfilesManifestResolver.get(manifestKey))
if (resolved.isRegularFile() || resolved.isDirectory()) return resolved
error("Unable to find test dependency '${label.asLabel}' at $resolved")
}
// Typical path-based runfiles: try direct path first, then fall back to manifest (covers mixed layouts)
val file = bazelTestRunfilesPath.resolve(manifestKey)
return when {
file.isRegularFile() || file.isDirectory() -> file.toAbsolutePath()
else -> {
error("Unable to find test dependency '${label.asLabel}' at $file")
}
else -> error("Unable to find test dependency '${label.asLabel}' at $file")
}
}
@@ -98,8 +129,15 @@ object BazelTestUtil {
*/
@JvmStatic
fun findRunfilesDirectoryUnderCommunityOrUltimate(relativePath: String): Path {
val root1 = bazelTestRunfilesPath.resolve("community+").resolve(relativePath)
val root2 = bazelTestRunfilesPath.resolve("_main").resolve(relativePath)
val (root1, root2) = if (runfilesManifestOnly) {
val root1key = "community+/${relativePath}"
val root2key = "_main/${relativePath}"
Path.of(bazelRunfilesManifestResolver.get(root1key)) to
Path.of(bazelRunfilesManifestResolver.get(root2key))
} else {
bazelTestRunfilesPath.resolve("community+").resolve(relativePath) to
bazelTestRunfilesPath.resolve("_main").resolve(relativePath)
}
val root1exists = root1.isDirectory()
val root2exists = root2.isDirectory()
@@ -126,3 +164,99 @@ object BazelTestUtil {
return RepoMappingEntry( parts[1], parts[2])
}
}
class BazelRunfilesManifest() {
companion object {
// https://fuchsia.googlesource.com/fuchsia/+/HEAD/build/bazel/BAZEL_RUNFILES.md?format%2F%2F#how-runfiles-libraries-really-work
private const val RUNFILES_MANIFEST_FILE_ENV_NAME = "RUNFILES_MANIFEST_FILE"
}
private val bazelRunFilesManifest: Map<String, String> by lazy {
val file = Path.of(System.getenv(RUNFILES_MANIFEST_FILE_ENV_NAME))
require(file.exists()) { "RUNFILES_MANIFEST_FILE does not exist: $file" }
file.useLines { lines ->
lines
.filter { it.isNotBlank() && it.isNotEmpty() }
.map { parseManifestEntry(it) }
.distinct()
.toMap()
}
}
private val calculatedManifestEntries: MutableMap<String, String> = mutableMapOf()
private fun parseManifestEntry(line: String): Pair<String, String> {
val parts = line.split(" ", limit = 2)
require(parts.size == 2) { "runfiles_manifest line must have exactly 2 space-separated values: '$line'" }
return parts[0] to parts[1]
}
fun get(key: String) : String {
val valueByFullKey = bazelRunFilesManifest[key]
if (valueByFullKey != null) {
return valueByFullKey
}
// required to resolve directories as in the manifest entries
// only files are present as keys, but not directories
return calculatedManifestEntries.computeIfAbsent(key, {
val subset = bazelRunFilesManifest.filter { it.key.startsWith(key) }
val calculatedValue = if (subset.isNotEmpty()) {
val longestKey = findLongestCommonPrefix(subset.keys)
val longestValue = findLongestCommonPrefix(subset.values.toSet())
mapByQuery(longestKey, longestValue, key)
} else {
key
}
return@computeIfAbsent calculatedValue
})
}
fun findLongestCommonPrefix(paths: Set<String>?): String {
// Handle null/empty gracefully
if (paths.isNullOrEmpty()) return ""
// Normalize and split into path segments without using regex; ignore empty segments
val partsList: List<List<String>> = paths
.map { it.trim('/') }
.map { p -> if (p.isEmpty()) emptyList() else p.split('/').filter { it.isNotEmpty() } }
if (partsList.isEmpty()) return ""
// Find the shortest path length to limit comparisons
val minLength = partsList.minOf { it.size }
val sb = StringBuilder()
for (i in 0 until minLength) {
val segment = partsList[0][i]
if (partsList.any { it[i] != segment }) break
if (sb.isNotEmpty()) sb.append('/')
sb.append(segment)
}
return sb.toString()
}
fun mapByQuery(fullKey: String, value: String, queryKey: String): String {
require(fullKey.isNotEmpty() && value.isNotEmpty() && queryKey.isNotEmpty()) { "Query cannot be empty" }
val fullKeyParts: Array<String> = fullKey.split("/").dropLastWhile { it.isEmpty() }.toTypedArray()
val valueParts: Array<String> = value.split("/").dropLastWhile { it.isEmpty() }.toTypedArray()
val queryKeyParts: Array<String?> = queryKey.split("/").dropLastWhile { it.isEmpty() }.toTypedArray()
require(fullKeyParts.size >= queryKeyParts.size) { "Query key $queryKey is longer than full key $fullKey" }
val removeCount = fullKeyParts.size - queryKeyParts.size
val endIndex = max(valueParts.size - removeCount, 0)
return buildString {
for (i in 0..<endIndex) {
if (i > 0) {
append('/')
}
append(valueParts[i])
}
}
}
}