fix(gradle): fix gradle test running gaps (#31313)

<!-- Please make sure you have read the submission guidelines before
posting an PR -->
<!--
https://github.com/nrwl/nx/blob/master/CONTRIBUTING.md#-submitting-a-pr
-->

<!-- Please make sure that your commit message follows our format -->
<!-- Example: `fix(nx): must begin with lowercase` -->

<!-- If this is a particularly complex change or feature addition, you
can request a dedicated Nx release for this pull request branch. Mention
someone from the Nx team or the `@nrwl/nx-pipelines-reviewers` and they
will confirm if the PR warrants its own release for testing purposes,
and generate it for you if appropriate. -->

## Current Behavior
<!-- This is the behavior we have today -->
- for the atomized test, currenly, its testClassName is just the 1st
class name in the file

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->
- upgrade gradle to latest version from 8.13 to 8.14
- for test task, exclude all its depends on tasks
  - it currently only exclude its direct depends on, its children
- now it will go down the dependency tree and exclude all of its depends
on, its children and grandchildren
- for the atomized test target, its testClassName will be the full
package name
- e.g.
org.springframework.boot.autoconfigure.jersey.JerseyAutoConfigurationCustomObjectMapperProviderTest
   - add logics to handle nested class
   - exclude private class name
<img width="1081" alt="Screenshot 2025-06-06 at 2 53 39 PM"
src="https://github.com/user-attachments/assets/285792fb-f098-4511-85dc-ee1263d75929"
/>
- add build-ci target
<img width="1140" alt="Screenshot 2025-06-10 at 10 21 06 AM"
src="https://github.com/user-attachments/assets/25db4a3e-2794-4654-9a95-1b66d229340b"
/>


## Related Issue(s)
<!-- Please link the issue being fixed so it gets closed when this is
merged. -->

Fixes #
This commit is contained in:
Emily Xiong 2025-06-10 13:23:09 -04:00 committed by GitHub
parent 8daad98992
commit 7a53477adc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
22 changed files with 1066 additions and 290 deletions

2
.gitignore vendored
View File

@ -96,6 +96,8 @@ node_modules/
*.njsproj
*.sln
*.sw?
.specstory/**
.cursorindexingignore
# OS specific
# Task files
tasks.json

View File

@ -15,7 +15,7 @@
},
"testClassName": {
"type": "string",
"description": "The test class name to run for test task."
"description": "The full test name to run for test task (package name and class name)."
},
"args": {
"oneOf": [

View File

@ -1,19 +1,18 @@
package dev.nx.gradle
import com.google.gson.Gson
import dev.nx.gradle.cli.configureLogger
import dev.nx.gradle.cli.parseArgs
import dev.nx.gradle.runner.runTasksInParallel
import dev.nx.gradle.util.configureSingleLineLogger
import dev.nx.gradle.util.logger
import java.io.File
import kotlin.system.exitProcess
import kotlinx.coroutines.runBlocking
import org.gradle.tooling.GradleConnector
import org.gradle.tooling.ProjectConnection
fun main(args: Array<String>) {
val options = parseArgs(args)
configureLogger(options.quiet)
configureSingleLineLogger(options.quiet)
logger.info("NxBatchOptions: $options")
if (options.workspaceRoot.isBlank()) {
@ -26,15 +25,21 @@ fun main(args: Array<String>) {
exitProcess(1)
}
var connection: ProjectConnection? = null
var buildConnection: ProjectConnection? = null
try {
connection =
GradleConnector.newConnector().forProjectDirectory(File(options.workspaceRoot)).connect()
val connector = GradleConnector.newConnector().forProjectDirectory(File(options.workspaceRoot))
val results = runBlocking {
runTasksInParallel(connection, options.tasks, options.args, options.excludeTasks)
}
buildConnection = connector.connect()
logger.info("🏁 Gradle connection open.")
val results =
runTasksInParallel(
buildConnection,
options.tasks,
options.args,
options.excludeTasks,
options.excludeTestTasks)
val reportJson = Gson().toJson(results)
println(reportJson)
@ -47,7 +52,7 @@ fun main(args: Array<String>) {
exitProcess(1)
} finally {
try {
connection?.close()
buildConnection?.close()
logger.info("✅ Gradle connection closed.")
} catch (e: Exception) {
logger.warning("⚠️ Failed to close Gradle connection cleanly: ${e.message}")

View File

@ -4,7 +4,6 @@ import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import dev.nx.gradle.data.GradleTask
import dev.nx.gradle.data.NxBatchOptions
import dev.nx.gradle.util.logger
fun parseArgs(args: Array<String>): NxBatchOptions {
val argMap = mutableMapOf<String, String>()
@ -33,20 +32,15 @@ fun parseArgs(args: Array<String>): NxBatchOptions {
argMap["--excludeTasks"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: emptyList()
val excludeTestTasks =
argMap["--excludeTestTasks"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: emptyList()
return NxBatchOptions(
workspaceRoot = argMap["--workspaceRoot"] ?: "",
tasks = tasksMap,
args = argMap["--args"] ?: "",
quiet = argMap["--quiet"]?.toBoolean() ?: false,
excludeTasks = excludeTasks)
}
fun configureLogger(quiet: Boolean) {
if (quiet) {
logger.setLevel(java.util.logging.Level.OFF)
logger.useParentHandlers = false
logger.handlers.forEach { it.level = java.util.logging.Level.OFF }
} else {
logger.setLevel(java.util.logging.Level.INFO)
}
excludeTasks = excludeTasks,
excludeTestTasks = excludeTestTasks)
}

View File

@ -5,5 +5,6 @@ data class NxBatchOptions(
val tasks: Map<String, GradleTask>,
val args: String,
val quiet: Boolean,
val excludeTasks: List<String>
val excludeTasks: List<String>,
val excludeTestTasks: List<String>
)

View File

@ -3,8 +3,6 @@ package dev.nx.gradle.runner
import dev.nx.gradle.data.GradleTask
import dev.nx.gradle.data.TaskResult
import dev.nx.gradle.util.logger
import kotlin.math.max
import kotlin.math.min
import org.gradle.tooling.events.ProgressEvent
import org.gradle.tooling.events.task.TaskFailureResult
import org.gradle.tooling.events.task.TaskFinishEvent
@ -22,35 +20,38 @@ fun buildListener(
.find { it.value.taskName == event.descriptor.taskPath }
?.key
?.let { nxTaskId ->
taskStartTimes[nxTaskId] = min(System.currentTimeMillis(), event.eventTime)
taskStartTimes[nxTaskId] = event.eventTime
logger.info("🏁 Task start: $nxTaskId ${event.descriptor.taskPath}")
}
}
is TaskFinishEvent -> {
val taskPath = event.descriptor.taskPath
val success =
when (event.result) {
is TaskSuccessResult -> {
logger.info("✅ Task finished successfully: $taskPath")
true
}
is TaskFailureResult -> {
logger.warning("❌ Task failed: $taskPath")
false
}
else -> true
}
val success = getTaskFinishEventSuccess(event, taskPath)
tasks.entries
.find { it.value.taskName == taskPath }
?.key
?.let { nxTaskId ->
val endTime = max(System.currentTimeMillis(), event.eventTime)
val endTime = event.result.endTime
val startTime = taskStartTimes[nxTaskId] ?: event.result.startTime
taskResults[nxTaskId] = TaskResult(success, startTime, endTime, "")
}
}
}
}
fun getTaskFinishEventSuccess(event: TaskFinishEvent, taskPath: String): Boolean {
return when (event.result) {
is TaskSuccessResult -> {
logger.info("✅ Task finished successfully: $taskPath")
true
}
is TaskFailureResult -> {
logger.warning("❌ Task failed: $taskPath")
false
}
else -> true
}
}

View File

@ -6,17 +6,17 @@ import dev.nx.gradle.runner.OutputProcessor.buildTerminalOutput
import dev.nx.gradle.runner.OutputProcessor.finalizeTaskResults
import dev.nx.gradle.util.logger
import java.io.ByteArrayOutputStream
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import org.gradle.tooling.BuildCancelledException
import org.gradle.tooling.ProjectConnection
import org.gradle.tooling.events.OperationType
suspend fun runTasksInParallel(
fun runTasksInParallel(
connection: ProjectConnection,
tasks: Map<String, GradleTask>,
additionalArgs: String,
excludeTasks: List<String>
): Map<String, TaskResult> = coroutineScope {
excludeTasks: List<String>,
excludeTestTasks: List<String>
): Map<String, TaskResult> {
logger.info("▶️ Running all tasks in a single Gradle run: ${tasks.keys.joinToString(", ")}")
val (testClassTasks, buildTasks) = tasks.entries.partition { it.value.testClassName != null }
@ -29,54 +29,62 @@ suspend fun runTasksInParallel(
val outputStream2 = ByteArrayOutputStream()
val errorStream2 = ByteArrayOutputStream()
val args = buildList {
// --info is for terminal per task
// --continue is for continue running tasks if one failed in a batch
// --parallel is for performance
// -Dorg.gradle.daemon.idletimeout=10000 is to kill daemon after 10 seconds
addAll(listOf("--info", "--continue", "-Dorg.gradle.daemon.idletimeout=10000"))
addAll(additionalArgs.split(" ").filter { it.isNotBlank() })
excludeTasks.forEach {
add("--exclude-task")
add(it)
}
// --info is for terminal per task
// --continue is for continue running tasks if one failed in a batch
// --parallel is for performance
// -Dorg.gradle.daemon.idletimeout=0 is to kill daemon after 0 ms
val cpuCores = Runtime.getRuntime().availableProcessors()
val workersMax = (cpuCores * 0.5).toInt().coerceAtLeast(1)
val args =
mutableListOf(
"--info",
"--continue",
"-Dorg.gradle.daemon.idletimeout=0",
"--parallel",
"-Dorg.gradle.workers.max=$workersMax")
if (additionalArgs.isNotBlank()) {
val splitResult = additionalArgs.split(" ")
val filteredResult = splitResult.filter { it.isNotBlank() }
args.addAll(filteredResult)
}
logger.info("🏳️ Args: ${args.joinToString(", ")}")
val buildJob = async {
if (buildTasks.isNotEmpty()) {
runBuildLauncher(
connection,
buildTasks.associate { it.key to it.value },
args,
outputStream1,
errorStream1)
} else emptyMap()
}
val testJob = async {
if (testClassTasks.isNotEmpty()) {
runTestLauncher(
connection,
testClassTasks.associate { it.key to it.value },
args,
outputStream2,
errorStream2)
} else emptyMap()
}
val allResults = mutableMapOf<String, TaskResult>()
allResults.putAll(buildJob.await())
allResults.putAll(testJob.await())
return@coroutineScope allResults
if (buildTasks.isNotEmpty()) {
val buildResults =
runBuildLauncher(
connection,
buildTasks.associate { it.key to it.value },
args,
excludeTasks,
outputStream1,
errorStream1)
allResults.putAll(buildResults)
}
if (testClassTasks.isNotEmpty()) {
val testResults =
runTestLauncher(
connection,
testClassTasks.associate { it.key to it.value },
args,
excludeTestTasks,
outputStream2,
errorStream2)
allResults.putAll(testResults)
}
return allResults
}
fun runBuildLauncher(
connection: ProjectConnection,
tasks: Map<String, GradleTask>,
args: List<String>,
excludeTasks: List<String>,
outputStream: ByteArrayOutputStream,
errorStream: ByteArrayOutputStream
): Map<String, TaskResult> {
@ -89,14 +97,17 @@ fun runBuildLauncher(
val globalStart = System.currentTimeMillis()
var globalOutput: String
val excludeArgs = excludeTasks.map { "--exclude-task=$it" }
try {
connection
.newBuild()
.apply {
forTasks(*taskNames)
withArguments(*args.toTypedArray())
addArguments(*(args + excludeArgs).toTypedArray())
setStandardOutput(outputStream)
setStandardError(errorStream)
withDetailedFailure()
addProgressListener(buildListener(tasks, taskStartTimes, taskResults), OperationType.TASK)
}
.run()
@ -111,6 +122,13 @@ fun runBuildLauncher(
}
val globalEnd = System.currentTimeMillis()
val maxEndTime = taskResults.values.map { it.endTime }.maxOrNull() ?: globalEnd
val minStartTime = taskResults.values.map { it.startTime }.minOrNull() ?: globalStart
logger.info(
"⏱️ Build start timing gap: ${minStartTime - globalStart}ms (time between first task start and build launcher start) ")
logger.info(
"⏱️ Build completion timing gap: ${globalEnd - maxEndTime}ms (time between last task finish and build end)")
finalizeTaskResults(
tasks = tasks,
taskResults = taskResults,
@ -127,49 +145,47 @@ fun runTestLauncher(
connection: ProjectConnection,
tasks: Map<String, GradleTask>,
args: List<String>,
excludeTestTasks: List<String>,
outputStream: ByteArrayOutputStream,
errorStream: ByteArrayOutputStream
): Map<String, TaskResult> {
val taskNames = tasks.values.map { it.taskName }.distinct().toTypedArray()
logger.info("📋 Collected ${taskNames.size} unique task names: ${taskNames.joinToString(", ")}")
val taskStartTimes = mutableMapOf<String, Long>()
val taskResults = mutableMapOf<String, TaskResult>()
val testTaskStatus = mutableMapOf<String, Boolean>()
val testStartTimes = mutableMapOf<String, Long>()
val testEndTimes = mutableMapOf<String, Long>()
tasks.forEach { (nxTaskId, taskConfig) ->
if (taskConfig.testClassName != null) {
testTaskStatus[nxTaskId] = true
}
}
// Group the list of GradleTask by their taskName
val groupedTasks: Map<String, List<GradleTask>> = tasks.values.groupBy { it.taskName }
logger.info("📋 Collected ${groupedTasks.keys.size} unique task names: $groupedTasks")
val globalStart = System.currentTimeMillis()
var globalOutput: String
val eventTypes: MutableSet<OperationType> = HashSet()
eventTypes.add(OperationType.TASK)
eventTypes.add(OperationType.TEST)
val excludeArgs = excludeTestTasks.flatMap { listOf("--exclude-task", it) }
logger.info("excludeTestTasks $excludeArgs")
try {
connection
.newTestLauncher()
.apply {
forTasks(*taskNames)
tasks.values
.mapNotNull { it.testClassName }
.forEach {
logger.info("Registering test class: $it")
withArguments("--tests", it)
withJvmTestClasses(it)
}
withArguments(*args.toTypedArray())
groupedTasks.forEach { withTaskAndTestClasses(it.key, it.value.map { it.testClassName }) }
addArguments("-Djunit.jupiter.execution.parallel.enabled=true") // Add JUnit 5 parallelism
// arguments here
addArguments(
*(args + excludeArgs).toTypedArray()) // Combine your existing args with JUnit args
setStandardOutput(outputStream)
setStandardError(errorStream)
addProgressListener(
testListener(
tasks, taskStartTimes, taskResults, testTaskStatus, testStartTimes, testEndTimes),
OperationType.TEST)
testListener(tasks, testTaskStatus, testStartTimes, testEndTimes), eventTypes)
withDetailedFailure()
}
.run()
globalOutput = buildTerminalOutput(outputStream, errorStream)
} catch (e: BuildCancelledException) {
globalOutput = buildTerminalOutput(outputStream, errorStream)
logger.info("✅ Build cancelled gracefully by token.")
} catch (e: Exception) {
logger.warning(errorStream.toString())
globalOutput =
@ -181,16 +197,21 @@ fun runTestLauncher(
}
val globalEnd = System.currentTimeMillis()
val maxEndTime = testEndTimes.values.maxOrNull() ?: globalEnd
val minStartTime = testStartTimes.values.minOrNull() ?: globalStart
logger.info(
"⏱️ Test start timing gap: ${minStartTime - globalStart}ms (time between first test start and test launcher start) ")
logger.info(
"⏱️ Test completion timing gap: ${globalEnd - maxEndTime}ms (time between last test finish and test launcher end)")
val taskResults = mutableMapOf<String, TaskResult>()
tasks.forEach { (nxTaskId, taskConfig) ->
if (taskConfig.testClassName != null) {
val success = testTaskStatus[nxTaskId] ?: false
val startTime = testStartTimes[nxTaskId] ?: globalStart
val endTime = testEndTimes[nxTaskId] ?: globalEnd
if (!taskResults.containsKey(nxTaskId)) {
taskResults[nxTaskId] = TaskResult(success, startTime, endTime, "")
}
taskResults[nxTaskId] = TaskResult(success, startTime, endTime, "")
}
}

View File

@ -1,61 +1,83 @@
package dev.nx.gradle.runner
import dev.nx.gradle.data.GradleTask
import dev.nx.gradle.data.TaskResult
import dev.nx.gradle.util.formatMillis
import dev.nx.gradle.util.logger
import org.gradle.tooling.events.ProgressEvent
import org.gradle.tooling.events.task.TaskFinishEvent
import org.gradle.tooling.events.task.TaskStartEvent
import org.gradle.tooling.events.test.*
fun testListener(
tasks: Map<String, GradleTask>,
taskStartTimes: MutableMap<String, Long>,
taskResults: MutableMap<String, TaskResult>,
testTaskStatus: MutableMap<String, Boolean>,
testStartTimes: MutableMap<String, Long>,
testEndTimes: MutableMap<String, Long>
): (ProgressEvent) -> Unit = { event ->
when (event) {
is TaskStartEvent,
is TaskFinishEvent -> buildListener(tasks, taskStartTimes, taskResults)(event)
is TestStartEvent -> {
((event.descriptor as? JvmTestOperationDescriptor)?.className?.substringAfterLast('.')?.let {
simpleClassName ->
testEndTimes: MutableMap<String, Long>,
): (ProgressEvent) -> Unit {
return { event ->
when (event) {
is TaskFinishEvent -> {
val taskPath = event.descriptor.taskPath
val success = getTaskFinishEventSuccess(event, taskPath)
tasks.entries
.find { entry -> entry.value.testClassName?.let { simpleClassName == it } ?: false }
?.key
?.let { nxTaskId ->
testStartTimes.computeIfAbsent(nxTaskId) { event.eventTime }
logger.info("🏁 Test start at ${event.eventTime}: $nxTaskId $simpleClassName")
.filter {
it.value.taskName == taskPath
} // Filters the entries to keep only matching ones
.map { it.key }
.forEach { nxTaskId -> // Iterate over the filtered entries
testTaskStatus.computeIfAbsent(nxTaskId) { success }
testEndTimes.computeIfAbsent(nxTaskId) { event.result.endTime }
}
})
}
is TestFinishEvent -> {
((event.descriptor as? JvmTestOperationDescriptor)?.className?.substringAfterLast('.')?.let {
simpleClassName ->
tasks.entries
.find { entry -> entry.value.testClassName?.let { simpleClassName == it } ?: false }
?.key
?.let { nxTaskId ->
testEndTimes.compute(nxTaskId) { _, _ -> event.result.endTime }
when (event.result) {
is TestSuccessResult ->
logger.info(
"\u2705 Test passed at ${event.result.endTime}: $nxTaskId $simpleClassName")
is TestFailureResult -> {
testTaskStatus[nxTaskId] = false
logger.warning("\u274C Test failed: $nxTaskId $simpleClassName")
}
}
is TestSkippedResult ->
logger.warning("\u26A0\uFE0F Test skipped: $nxTaskId $simpleClassName")
is TestStartEvent -> {
val descriptor = event.descriptor as? JvmTestOperationDescriptor
else ->
logger.warning("\u26A0\uFE0F Unknown test result: $nxTaskId $simpleClassName")
descriptor?.className?.let { className ->
tasks.entries
.find { (_, v) -> v.testClassName == className }
?.key
?.let { nxTaskId ->
testStartTimes.computeIfAbsent(nxTaskId) { event.eventTime }
logger.info("🏁 Test start: $nxTaskId $className")
}
}
}
is TestFinishEvent -> {
val descriptor = event.descriptor as? JvmTestOperationDescriptor
val nxTaskId =
descriptor?.className?.let { className ->
tasks.entries.find { (_, v) -> v.testClassName == className }?.key
}
})
nxTaskId?.let {
testEndTimes[it] = event.result.endTime
val name = descriptor.className ?: "unknown"
when (event.result) {
is TestSuccessResult -> {
testTaskStatus[it] = true
logger.info("✅ Test passed at ${formatMillis(event.result.endTime)}: $nxTaskId $name")
}
is TestFailureResult -> {
testTaskStatus[it] = false
logger.warning("❌ Test failed: $nxTaskId $name")
}
is TestSkippedResult -> {
testTaskStatus[it] = true
logger.warning("⚠️ Test skipped: $nxTaskId $name")
}
else -> {
testTaskStatus[it] = true
logger.warning("⚠️ Unknown test result: $nxTaskId $name")
}
}
}
}
}
}
}

View File

@ -1,5 +1,65 @@
package dev.nx.gradle.util
import java.text.SimpleDateFormat
import java.util.*
import java.util.logging.ConsoleHandler // Or your preferred handler
import java.util.logging.Formatter
import java.util.logging.Level
import java.util.logging.LogRecord
import java.util.logging.Logger
/**
* A custom Formatter for java.util.logging that outputs logs in a single line. Format: [LEVEL]:
* Message
*/
class SingleLineFormatter : Formatter() {
override fun format(record: LogRecord): String {
// Get the log level and the message
val level = record.level.name
val message = formatMessage(record)
// Return the formatted string in a single line
return "${formatMillis(System.currentTimeMillis())} [$level]: $message\n"
}
}
// Your existing logger setup, modified to use the custom formatter
val logger: Logger = Logger.getLogger("NxBatchRunner")
fun configureSingleLineLogger(quiet: Boolean) {
// Get the root logger
val rootLogger = Logger.getLogger("") // The empty string gets the root logger
// Remove all handlers from the root logger first
rootLogger.handlers.forEach { handler -> rootLogger.removeHandler(handler) }
// Now, configure your specific logger and its handler
// Ensure this logger does NOT use parent handlers
logger.useParentHandlers = false
// Remove any existing handlers from your specific logger (if any were added before)
logger.handlers.forEach { handler -> logger.removeHandler(handler) }
val consoleHandler = ConsoleHandler()
consoleHandler.formatter = SingleLineFormatter()
// Add the configured handler to your specific logger
logger.addHandler(consoleHandler)
// Set levels
if (quiet) {
logger.level = Level.OFF // Turn off your specific logger
consoleHandler.level = Level.OFF // Turn off its handler
rootLogger.level = Level.OFF // Ensure root logger is also off (or a high level)
} else {
logger.level = Level.INFO
consoleHandler.level = Level.INFO // Ensure handler is on
rootLogger.level = Level.INFO // Ensure root logger passes INFO and above
}
}
fun formatMillis(millis: Long): String {
val sdf = SimpleDateFormat("HH:mm:ss.SSS")
sdf.timeZone = TimeZone.getTimeZone("UTC") // so it doesn't apply your local timezone offset
return sdf.format(Date(millis))
}

View File

@ -41,6 +41,17 @@ To pass in a hash parameter:
./gradlew nxProjectGraph -Phash=12345
```
To control whether Nx generates individual targets for each Gradle task (atomized targets) or a single target for the entire Gradle project, set the `atomized` boolean in your `build.gradle` or `build.gradle.kts` file.
To disable atomized targets:
```
nxProjectReport {
atomized = false
}
```
It generates a json file to be consumed by nx:
```json

View File

@ -10,7 +10,7 @@ plugins {
group = "dev.nx.gradle"
version = "0.1.0"
version = "0.0.1-alpha.6"
repositories { mavenCentral() }

View File

@ -11,7 +11,23 @@ const val testCiTargetGroup = "verification"
private val testFileNameRegex =
Regex("^(?!(abstract|fake)).*?(Test)(s)?\\d*", RegexOption.IGNORE_CASE)
private val classDeclarationRegex = Regex("""class\s+([A-Za-z_][A-Za-z0-9_]*)""")
private val packageDeclarationRegex =
Regex("""package\s+([a-zA-Z_][a-zA-Z0-9_]*(?:\.[a-zA-Z_][a-zA-Z0-9_]*)*)""")
private val classDeclarationRegex =
Regex("""^\s*(?:@[\w]+)?\s*(class|object)\s+([A-Za-z_][A-Za-z0-9_]*)""")
private val privateClassRegex = Regex("""\bprivate\s+(class|object)\s+([A-Za-z_][A-Za-z0-9_]*)""")
// Essential annotations (most common subset)
private val essentialTestAnnotations =
setOf(
"@Test",
"@TestTemplate",
"@ParameterizedTest",
"@RepeatedTest",
"@TestFactory",
"@org.junit.Test", // JUnit 4
"@org.testng.annotations.Test" // TestNG
)
fun addTestCiTargets(
testFiles: FileCollection,
@ -31,15 +47,18 @@ fun addTestCiTargets(
testFiles
.filter { isTestFile(it, workspaceRoot) }
.forEach { testFile ->
val className = getTestClassNameIfAnnotated(testFile) ?: return@forEach
val classNames = getAllVisibleClassesWithNestedAnnotation(testFile)
val targetName = "$ciTestTargetName--$className"
targets[targetName] =
buildTestCiTarget(
projectBuildPath, className, testFile, testTask, projectRoot, workspaceRoot)
targetGroups[testCiTargetGroup]?.add(targetName)
classNames?.entries?.forEach { (className, testClassPackagePath) ->
val targetName = "$ciTestTargetName--$className"
targets[targetName] =
buildTestCiTarget(
projectBuildPath, testClassPackagePath, testTask, projectRoot, workspaceRoot)
targetGroups[testCiTargetGroup]?.add(targetName)
ciDependsOn.add(mapOf("target" to targetName, "projects" to "self", "params" to "forward"))
ciDependsOn.add(
mapOf("target" to targetName, "projects" to "self", "params" to "forward"))
}
}
testTask.logger.info("${testTask.path} generated CI targets: ${ciDependsOn.map { it["target"] }}")
@ -56,21 +75,72 @@ fun addTestCiTargets(
}
}
private fun getTestClassNameIfAnnotated(file: File): String? {
return file
.takeIf { it.exists() }
?.readText()
?.takeIf {
it.contains("@Test") || it.contains("@TestTemplate") || it.contains("@ParameterizedTest")
private fun containsEssentialTestAnnotations(content: String): Boolean {
return essentialTestAnnotations.any { content.contains(it) }
}
// This function return all class names and nested class names inside a file
fun getAllVisibleClassesWithNestedAnnotation(file: File): MutableMap<String, String>? {
val content = file.takeIf { it.exists() }?.readText() ?: return null
val lines = content.lines()
val result = mutableMapOf<String, String>()
var packageName: String?
val classStack = mutableListOf<Pair<String, Int>>() // (className, indent)
var previousLine: String? = null
for (i in lines.indices) {
val line = lines[i]
val trimmed = line.trimStart()
val indent = line.indexOfFirst { !it.isWhitespace() }.takeIf { it >= 0 } ?: 0
// Skip private classes
if (privateClassRegex.containsMatchIn(trimmed)) continue
packageName = packageDeclarationRegex.find(content)?.groupValues?.getOrNull(1)
val match = classDeclarationRegex.find(trimmed)
if (match == null) {
previousLine = trimmed
continue
}
val className = match.groupValues.getOrNull(2)
if (className == null) {
previousLine = trimmed
continue
}
val isAnnotatedNested = previousLine?.trimStart()?.startsWith("@Nested") == true
// Top-level class (no indentation or same as outermost level)
if (indent == 0) {
// Exclude top-level @nested classes
if (!isAnnotatedNested) {
result.put(className, packageName?.let { "$it.$className" } ?: className)
}
?.let { content ->
val className = classDeclarationRegex.find(content)?.groupValues?.getOrNull(1)
return if (className != null && !className.startsWith("Fake")) {
className
} else {
null
}
classStack.clear()
classStack.add(className to indent)
} else {
// Maintain nesting stack
while (classStack.isNotEmpty() && indent <= classStack.last().second) {
classStack.removeLast()
}
val parent = classStack.lastOrNull()?.first
if (isAnnotatedNested && parent != null) {
val packageClassName = "$parent$$className"
result["$parent$className"] =
packageName?.let { "$it.$packageClassName" } ?: packageClassName
result.remove(parent) // remove the parent class since child nested class is added
}
classStack.add(className to indent)
}
previousLine = trimmed
}
return result
}
fun ensureTargetGroupExists(targetGroups: TargetGroups, group: String) {
@ -84,8 +154,7 @@ private fun isTestFile(file: File, workspaceRoot: String): Boolean {
private fun buildTestCiTarget(
projectBuildPath: String,
testClassName: String,
testFile: File,
testClassPackagePath: String,
testTask: Task,
projectRoot: String,
workspaceRoot: String,
@ -98,9 +167,9 @@ private fun buildTestCiTarget(
"options" to
mapOf(
"taskName" to "${projectBuildPath}:${testTask.name}",
"testClassName" to testClassName),
"testClassName" to testClassPackagePath),
"metadata" to
getMetadata("Runs Gradle test $testClassName in CI", projectBuildPath, "test"),
getMetadata("Runs Gradle test $testClassPackagePath in CI", projectBuildPath, "test"),
"cache" to true,
"inputs" to taskInputs)

View File

@ -79,7 +79,6 @@ fun processTargetsForProject(
val ciTestTargetName = targetNameOverrides["ciTestTargetName"]
val ciIntTestTargetName = targetNameOverrides["ciIntTestTargetName"]
val ciCheckTargetName = targetNameOverrides.getOrDefault("ciCheckTargetName", "check-ci")
val testTargetName = targetNameOverrides.getOrDefault("testTargetName", "test")
val intTestTargetName = targetNameOverrides.getOrDefault("intTestTargetName", "intTest")
@ -138,6 +137,7 @@ fun processTargetsForProject(
ciIntTestTargetName!!)
}
val ciCheckTargetName = targetNameOverrides.getOrDefault("ciCheckTargetName", "check-ci")
if (task.name == "check") {
val replacedDependencies =
(target["dependsOn"] as? List<*>)?.map { dep ->
@ -153,16 +153,44 @@ fun processTargetsForProject(
}
} ?: emptyList()
val newTarget: MutableMap<String, Any?> =
mutableMapOf(
"dependsOn" to replacedDependencies,
"executor" to "nx:noop",
"cache" to true,
"metadata" to getMetadata("Runs Gradle Check in CI", projectBuildPath, "check"))
if (atomized) {
val newTarget: MutableMap<String, Any?> =
mutableMapOf(
"dependsOn" to replacedDependencies,
"executor" to "nx:noop",
"cache" to true,
"metadata" to getMetadata("Runs Gradle Check in CI", projectBuildPath, "check"))
targets[ciCheckTargetName] = newTarget
ensureTargetGroupExists(targetGroups, testCiTargetGroup)
targetGroups[testCiTargetGroup]?.add(ciCheckTargetName)
targets[ciCheckTargetName] = newTarget
ensureTargetGroupExists(targetGroups, testCiTargetGroup)
targetGroups[testCiTargetGroup]?.add(ciCheckTargetName)
}
}
if (task.name == "build") {
val ciBuildTargetName = targetNameOverrides.getOrDefault("ciBuildTargetName", "build-ci")
val replacedDependencies =
(target["dependsOn"] as? List<*>)?.map { dep ->
val dependsOn = dep.toString()
if (dependsOn == "${project.name}:check" && atomized) {
"${project.name}:$ciCheckTargetName"
} else {
dep
}
} ?: emptyList()
if (atomized) {
val newTarget: MutableMap<String, Any?> =
mutableMapOf(
"dependsOn" to replacedDependencies,
"executor" to "nx:noop",
"cache" to true,
"metadata" to getMetadata("Runs Gradle Build in CI", projectBuildPath, "build"))
targets[ciBuildTargetName] = newTarget
ensureTargetGroupExists(targetGroups, "build")
targetGroups["build"]?.add(ciBuildTargetName)
}
}
logger.info("$now ${project.name}: Processed task ${task.path}")

View File

@ -161,7 +161,7 @@ fun getOutputsForTask(task: Task, projectRoot: String, workspaceRoot: String): L
*/
fun getDependsOnForTask(
task: Task,
dependencies: MutableSet<Dependency>?,
dependencies: MutableSet<Dependency>?, // Assuming Dependency class is defined elsewhere
targetNameOverrides: Map<String, String> = emptyMap()
): List<String>? {
@ -187,19 +187,30 @@ fun getDependsOnForTask(
}
return try {
// get depends on using taskDependencies.getDependencies(task) because task.dependsOn has
// missing deps
val dependsOn =
// 1. Get dependencies from task.taskDependencies.getDependencies(task)
val dependsOnFromTaskDependencies: Set<Task> =
try {
task.taskDependencies.getDependencies(null)
task.taskDependencies.getDependencies(task)
} catch (e: Exception) {
task.logger.info("Error calling getDependencies for ${task.path}: ${e.message}")
task.logger.debug("Stack trace:", e)
emptySet<Task>()
emptySet<Task>() // If it fails, return an empty set to be combined later
}
if (dependsOn.isNotEmpty()) {
return mapTasksToNames(dependsOn)
// 2. Get dependencies from task.dependsOn and filter for Task instances
val dependsOnFromDependsOnProperty: Set<Task> = task.dependsOn.filterIsInstance<Task>().toSet()
// 3. Combine the two sets of dependencies
val combinedDependsOn = dependsOnFromTaskDependencies.union(dependsOnFromDependsOnProperty)
task.logger.info(
"Dependencies from taskDependencies.getDependencies for $task: $dependsOnFromTaskDependencies")
task.logger.info(
"Dependencies from task.dependsOn property for $task: $dependsOnFromDependsOnProperty")
task.logger.info("Combined dependencies for $task: $combinedDependsOn")
if (combinedDependsOn.isNotEmpty()) {
return mapTasksToNames(combinedDependsOn)
}
null

View File

@ -0,0 +1,87 @@
package dev.nx.gradle.utils
import java.io.File
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Test
class ClassDetectionTest {
@Test
fun `detects top-level and annotated nested classes only`() {
val kotlinSource =
"""
class ClassA {
@Nested
class ClassB
class ClassC
private class ClassD
}
@Nested
class ClassX // not nested — should be ignored
private class ClassY
class ClassZ
"""
.trimIndent()
// Write to temp file
val tempFile = File.createTempFile("ClassDetectionTest", ".kt")
tempFile.writeText(kotlinSource)
tempFile.deleteOnExit()
// Run the function
val result = getAllVisibleClassesWithNestedAnnotation(tempFile)
// Expected output
val expected = mapOf("ClassAClassB" to "ClassA\$ClassB", "ClassZ" to "ClassZ")
assertEquals(expected, result)
}
@Test
fun `detects top-level and annotated nested classes only with package name`() {
val kotlinSource =
"""
package dev.test
class ClassA {
@Nested
class ClassB
class ClassC
private class ClassD
@Nested
class ClassE
}
@Nested
class ClassX // not nested — should be ignored
private class ClassY
class ClassZ
"""
.trimIndent()
// Write to temp file
val tempFile = File.createTempFile("ClassDetectionTest", ".kt")
tempFile.writeText(kotlinSource)
tempFile.deleteOnExit()
// Run the function
val result = getAllVisibleClassesWithNestedAnnotation(tempFile)
// Expected output
val expected =
mapOf(
"ClassAClassB" to "dev.test.ClassA\$ClassB",
"ClassAClassE" to "dev.test.ClassA\$ClassE",
"ClassZ" to "dev.test.ClassZ")
assertEquals(expected, result)
}
}

View File

@ -1,6 +1,7 @@
package dev.nx.gradle.utils
import dev.nx.gradle.data.*
import java.io.File
import kotlin.test.*
import org.gradle.testfixtures.ProjectBuilder
@ -50,4 +51,104 @@ class CreateNodeForProjectTest {
assertTrue(result.dependencies.isEmpty(), "Expected no dependencies")
assertTrue(result.externalNodes.isEmpty(), "Expected no external nodes")
}
@Test
fun `should not generate atomized targets when atomized is false`() {
// Arrange
val workspaceRoot = createTempDir("workspace").absolutePath
val projectDir = createTempDir("project")
val project = ProjectBuilder.builder().withProjectDir(projectDir).build()
// Create tasks that would normally trigger atomized targets
val testFile1 =
File(projectDir, "src/test/kotlin/MyFirstTest.kt").apply {
parentFile.mkdirs()
writeText("@Test class MyFirstTest")
}
val testTask =
project.task("test").apply {
group = "verification"
description = "Runs the tests"
inputs.files(project.files(testFile1))
}
project
.task("compileTestKotlin")
.dependsOn(testTask) // This task name triggers ci test target logic
val checkTask =
project.task("check").apply {
group = "verification"
description = "Runs all checks"
dependsOn(testTask)
}
project.task("build").apply {
group = "build"
description = "Assembles and tests"
dependsOn(checkTask)
}
val targetNameOverrides =
mapOf(
"ciTestTargetName" to "ci-test",
"ciCheckTargetName" to "ci-check",
"ciBuildTargetName" to "ci-build")
// Act
val result =
createNodeForProject(
project = project,
targetNameOverrides = targetNameOverrides,
workspaceRoot = workspaceRoot,
atomized = false) // Test with atomized = false
// Assert
val projectRoot = project.projectDir.absolutePath
val projectNode = result.nodes[projectRoot]
assertNotNull(projectNode, "ProjectNode should not be null")
// Verify that individual atomized targets are NOT created
assertFalse(
projectNode.targets.containsKey("ci--MyFirstTest"),
"Expected ci--MyFirstTest target NOT to be present")
// Verify 'test' and 'check' targets are present but not their 'ci' counterparts if atomized is
// false
assertNotNull(projectNode.targets["test"], "Expected 'test' target to be present")
assertNotNull(projectNode.targets["check"], "Expected 'check' target to be present")
assertNotNull(projectNode.targets["build"], "Expected 'build' target to be present")
// Verify that 'ci-test', 'ci-check', 'ci-build' are not created as main targets if atomized is
// false
assertFalse(
projectNode.targets.containsKey("ci-test"),
"Expected ci-test target NOT to be present as a main target")
assertFalse(
projectNode.targets.containsKey("ci-check"),
"Expected ci-check target NOT to be present as a main target")
assertFalse(
projectNode.targets.containsKey("ci-build"),
"Expected ci-build target NOT to be present as a main target")
// Verify dependencies are NOT rewritten for 'check' and 'build' tasks
val checkTarget = projectNode.targets["check"]
assertNotNull(checkTarget, "Check target should exist")
val checkDependsOn = checkTarget["dependsOn"] as? List<*>
assertNotNull(checkDependsOn, "Check dependsOn should not be null")
assertTrue(
checkDependsOn.contains("${project.name}:test"), "Expected 'check' to depend on 'test'")
assertFalse(
checkDependsOn.contains("${project.name}:ci-test"),
"Expected 'check' NOT to depend on 'ci-test'")
val buildTarget = projectNode.targets["build"]
assertNotNull(buildTarget, "Build target should exist")
val buildDependsOn = buildTarget["dependsOn"] as? List<*>
assertNotNull(buildDependsOn, "Build dependsOn should not be null")
assertTrue(
buildDependsOn.contains("${project.name}:check"), "Expected 'build' to depend on 'check'")
assertFalse(
buildDependsOn.contains("${project.name}:ci-check"),
"Expected 'build' NOT to depend on 'ci-check'")
}
}

View File

@ -0,0 +1,153 @@
import { getExcludeTasks, getAllDependsOn } from './get-exclude-task';
describe('getExcludeTasks', () => {
const nodes: any = {
app1: {
name: 'app1',
type: 'app',
data: {
root: 'app1',
targets: {
test: {
dependsOn: ['app1:lint', 'app2:build'],
options: { taskName: 'testApp1' },
},
lint: { options: { taskName: 'lintApp1' } },
},
},
},
app2: {
name: 'app2',
type: 'app',
data: {
root: 'app2',
targets: {
build: { dependsOn: [], options: { taskName: 'buildApp2' } },
},
},
},
app3: {
name: 'app3',
type: 'app',
data: {
root: 'app3',
targets: {
deploy: {
dependsOn: ['app1:test'],
options: { taskName: 'deployApp3' },
},
},
},
},
};
it('should exclude tasks that are not in runningTaskIds and have excludeDependsOn true', () => {
const targets = new Set<string>(['app1:test', 'app2:build']);
const runningTaskIds = new Set<string>(['app1:test']);
const excludes = getExcludeTasks(targets, nodes, runningTaskIds);
expect(excludes).toEqual(new Set(['lintApp1', 'buildApp2']));
});
it('should not exclude tasks if direct dependencies are running', () => {
const targets = new Set<string>(['app1:test']);
const runningTaskIds = new Set<string>([
'app1:test',
'app1:lint',
'app2:build',
]);
const excludes = getExcludeTasks(targets, nodes, runningTaskIds);
expect(excludes).toEqual(new Set());
});
it('should handle targets with no dependencies', () => {
const targets = new Set<string>(['app2:build']);
const runningTaskIds = new Set<string>(['app2:build']);
const excludes = getExcludeTasks(targets, nodes, runningTaskIds);
expect(excludes).toEqual(new Set());
});
it('should handle missing project or target', () => {
const targets = new Set<string>(['nonexistent:test']);
const runningTaskIds = new Set<string>();
const excludes = getExcludeTasks(targets, nodes, runningTaskIds);
expect(excludes).toEqual(new Set());
});
it('should handle dependencies that are also running tasks', () => {
const targets = new Set<string>(['app1:test']);
const runningTaskIds = new Set<string>(['app1:test', 'app1:lint']);
const excludes = getExcludeTasks(targets, nodes, runningTaskIds);
expect(excludes).toEqual(new Set(['buildApp2']));
});
it('should handle recursive dependencies', () => {
// Assuming app3:deploy depends on app1:test, which in turn depends on app1:lint and app2:build
const targets = new Set<string>(['app3:deploy']);
const runningTaskIds = new Set<string>(['app3:deploy']);
const excludes = getExcludeTasks(targets, nodes, runningTaskIds);
expect(excludes).toEqual(new Set(['testApp1']));
});
});
describe('getAllDependsOn', () => {
const nodes: any = {
a: {
name: 'a',
type: 'lib',
data: {
root: 'a',
targets: { build: { dependsOn: ['b:build', 'c:build'] } },
},
},
b: {
name: 'b',
type: 'lib',
data: { root: 'b', targets: { build: { dependsOn: ['d:build'] } } },
},
c: {
name: 'c',
type: 'lib',
data: { root: 'c', targets: { build: {} } },
},
d: {
name: 'd',
type: 'lib',
data: { root: 'd', targets: { build: {} } },
},
e: {
name: 'e',
type: 'lib',
data: { root: 'e', targets: { build: { dependsOn: ['f:build'] } } },
},
f: {
name: 'f',
type: 'lib',
data: { root: 'f', targets: { build: { dependsOn: ['e:build'] } } }, // Circular dependency
},
};
it('should return all transitive dependencies excluding the starting task', () => {
const dependencies = getAllDependsOn(nodes, 'a', 'build');
expect(dependencies).toEqual(new Set(['b:build', 'c:build', 'd:build']));
});
it('should handle no dependencies', () => {
const dependencies = getAllDependsOn(nodes, 'c', 'build');
expect(dependencies).toEqual(new Set());
});
it('should handle missing project or target', () => {
const dependencies = getAllDependsOn(nodes, 'nonexistent', 'build');
expect(dependencies).toEqual(new Set());
});
it('should handle circular dependencies gracefully', () => {
const dependencies = getAllDependsOn(nodes, 'e', 'build');
expect(dependencies).toEqual(new Set(['f:build']));
});
it('should not include the starting task in the result', () => {
const dependencies = getAllDependsOn(nodes, 'a', 'build');
expect(dependencies).not.toContain('a:build');
});
});

View File

@ -1,4 +1,7 @@
import { ProjectGraph } from 'nx/src/config/project-graph';
import {
ProjectGraph,
ProjectGraphProjectNode,
} from 'nx/src/config/project-graph';
/**
* Returns Gradle CLI arguments to exclude dependent tasks
@ -8,28 +11,22 @@ import { ProjectGraph } from 'nx/src/config/project-graph';
* and only `test` is running, this will return: ['lint']
*/
export function getExcludeTasks(
projectGraph: ProjectGraph,
targets: { project: string; target: string; excludeDependsOn: boolean }[],
taskIds: Set<string>,
nodes: Record<string, ProjectGraphProjectNode>,
runningTaskIds: Set<string> = new Set()
): Set<string> {
const excludes = new Set<string>();
for (const { project, target, excludeDependsOn } of targets) {
if (!excludeDependsOn) {
continue;
}
const taskDeps =
projectGraph.nodes[project]?.data?.targets?.[target]?.dependsOn ?? [];
for (const taskId of taskIds) {
const [project, target] = taskId.split(':');
const taskDeps = nodes[project]?.data?.targets?.[target]?.dependsOn ?? [];
for (const dep of taskDeps) {
const taskId = typeof dep === 'string' ? dep : dep?.target;
if (taskId && !runningTaskIds.has(taskId)) {
const [projectName, targetName] = taskId.split(':');
const taskName =
projectGraph.nodes[projectName]?.data?.targets?.[targetName]?.options
?.taskName;
if (taskName) {
excludes.add(taskName);
const gradleTaskName = getGradleTaskNameWithNxTaskId(taskId, nodes);
if (gradleTaskName) {
excludes.add(gradleTaskName);
}
}
}
@ -38,30 +35,43 @@ export function getExcludeTasks(
return excludes;
}
export function getGradleTaskNameWithNxTaskId(
nxTaskId: string,
nodes: Record<string, ProjectGraphProjectNode>
): string | null {
const [projectName, targetName] = nxTaskId.split(':');
const gradleTaskName =
nodes[projectName]?.data?.targets?.[targetName]?.options?.taskName;
return gradleTaskName;
}
export function getAllDependsOn(
projectGraph: ProjectGraph,
nodes: Record<string, ProjectGraphProjectNode>,
projectName: string,
targetName: string,
visited: Set<string> = new Set()
): string[] {
const dependsOn =
projectGraph[projectName]?.data?.targets?.[targetName]?.dependsOn ?? [];
targetName: string
): Set<string> {
const allDependsOn = new Set<string>();
const stack: string[] = [`${projectName}:${targetName}`];
const allDependsOn: string[] = [];
while (stack.length > 0) {
const currentTaskId = stack.pop();
if (currentTaskId && !allDependsOn.has(currentTaskId)) {
allDependsOn.add(currentTaskId);
for (const dependency of dependsOn) {
if (!visited.has(dependency)) {
visited.add(dependency);
const [currentProjectName, currentTargetName] = currentTaskId.split(':');
const directDependencies =
nodes[currentProjectName]?.data?.targets?.[currentTargetName]
?.dependsOn ?? [];
const [depProjectName, depTargetName] = dependency.split(':');
allDependsOn.push(dependency);
// Recursively get dependencies of the current dependency
allDependsOn.push(
...getAllDependsOn(projectGraph, depProjectName, depTargetName, visited)
);
for (const dep of directDependencies) {
const depTaskId = typeof dep === 'string' ? dep : dep?.target;
if (depTaskId && !allDependsOn.has(depTaskId)) {
stack.push(depTaskId);
}
}
}
}
allDependsOn.delete(`${projectName}:${targetName}`); // Exclude the starting task itself
return allDependsOn;
}

View File

@ -0,0 +1,173 @@
import { getGradlewTasksToRun } from './gradle-batch.impl';
import { TaskGraph, ProjectGraphProjectNode } from '@nx/devkit';
import { GradleExecutorSchema } from './schema';
describe('getGradlewTasksToRun', () => {
let taskGraph: TaskGraph;
let inputs: Record<string, GradleExecutorSchema>;
let nodes: Record<string, ProjectGraphProjectNode>;
beforeEach(() => {
nodes = {
app1: {
name: 'app1',
type: 'app',
data: {
root: 'app1',
targets: {
test: {
dependsOn: ['app1:lint', 'app2:build'],
options: { taskName: 'testApp1' },
},
lint: {
options: { taskName: 'lintApp1' },
},
},
},
},
app2: {
name: 'app2',
type: 'app',
data: {
root: 'app2',
targets: {
build: {
dependsOn: [],
options: { taskName: 'buildApp2' },
},
},
},
},
app3: {
name: 'app3',
type: 'app',
data: {
root: 'app3',
targets: {
deploy: {
dependsOn: ['app1:test'],
options: { taskName: 'deployApp3' },
},
},
},
},
};
taskGraph = {
tasks: {
'app1:test': {
id: 'app1:test',
target: { project: 'app1', target: 'test' },
outputs: [],
overrides: {},
projectRoot: 'app1',
parallelism: false,
},
'app2:build': {
id: 'app2:build',
target: { project: 'app2', target: 'build' },
outputs: [],
overrides: {},
projectRoot: 'app2',
parallelism: false,
},
},
dependencies: {},
continuousDependencies: {},
roots: ['app1:test', 'app2:build'],
};
inputs = {
'app1:test': {
taskName: 'test',
excludeDependsOn: true,
},
'app2:build': {
taskName: 'build',
excludeDependsOn: false,
},
};
});
it('should correctly categorize tasks and their dependencies for exclusion', () => {
const taskIds = ['app1:test', 'app2:build'];
const result = getGradlewTasksToRun(taskIds, taskGraph, inputs, nodes);
expect(result.gradlewTasksToRun).toEqual({
'app1:test': inputs['app1:test'],
'app2:build': inputs['app2:build'],
});
expect(result.excludeTasks).toEqual(new Set(['lintApp1']));
expect(result.excludeTestTasks).toEqual(new Set());
});
it('should handle tasks with no excludeDependsOn', () => {
inputs['app1:test'].excludeDependsOn = false;
const taskIds = ['app1:test', 'app2:build'];
const result = getGradlewTasksToRun(taskIds, taskGraph, inputs, nodes);
expect(result.excludeTasks).toEqual(new Set());
expect(result.excludeTestTasks).toEqual(new Set());
});
it('should handle testClassName for excludeTestTasks', () => {
inputs['app1:test'].testClassName = 'com.example.MyTestClass';
const taskIds = ['app1:test'];
const result = getGradlewTasksToRun(taskIds, taskGraph, inputs, nodes);
expect(result.excludeTasks).toEqual(new Set());
// Test task's dependsOn should be added to excludeTestTasks if testClassName is present
expect(result.excludeTestTasks).toEqual(new Set(['buildApp2', 'lintApp1']));
});
it('should include all dependencies when excludeDependsOn is false for a task', () => {
inputs = {
'app1:test': {
taskName: 'test',
excludeDependsOn: false,
},
};
const taskIds = ['app1:test'];
const result = getGradlewTasksToRun(taskIds, taskGraph, inputs, nodes);
// Since excludeDependsOn is false, no tasks should be excluded via excludeTasks
// This part of the logic is used for `allDependsOn` to correctly calculate runningTaskIds
// for `getExcludeTasks` later.
// In this specific test, we're not checking `allDependsOn` directly, but the outcome
// on `excludeTasks` confirms its effect (or lack thereof due to `excludeDependsOn: false`).
expect(result.excludeTasks).toEqual(new Set());
expect(result.excludeTestTasks).toEqual(new Set());
});
it('should correctly handle a mix of excludeDependsOn true and false', () => {
taskGraph.tasks['app3:deploy'] = {
id: 'app3:deploy',
target: { project: 'app3', target: 'deploy' },
outputs: [],
overrides: {},
projectRoot: 'app3',
parallelism: false,
};
taskGraph.roots.push('app3:deploy');
inputs['app3:deploy'] = {
taskName: 'deploy',
excludeDependsOn: true,
};
const taskIds = ['app1:test', 'app2:build', 'app3:deploy'];
const result = getGradlewTasksToRun(taskIds, taskGraph, inputs, nodes);
expect(result.gradlewTasksToRun).toEqual({
'app1:test': inputs['app1:test'],
'app2:build': inputs['app2:build'],
'app3:deploy': inputs['app3:deploy'],
});
// app1:test (excludeDependsOn: true) -> exclude lintApp1
// app3:deploy (excludeDependsOn: true) -> depends on app1:test
// Since app1:test is also running, 'testApp1' should not be excluded.
// However, 'lintApp1' and 'buildApp2' (dependencies of 'app1:test') should be excluded.
expect(result.excludeTasks).toEqual(new Set(['lintApp1']));
expect(result.excludeTestTasks).toEqual(new Set());
});
});

View File

@ -1,5 +1,11 @@
import { ExecutorContext, output, TaskGraph, workspaceRoot } from '@nx/devkit';
import runCommandsImpl, {
import {
ExecutorContext,
output,
ProjectGraphProjectNode,
TaskGraph,
workspaceRoot,
} from '@nx/devkit';
import {
LARGE_BUFFER,
RunCommandsOptions,
} from 'nx/src/executors/run-commands/run-commands.impl';
@ -12,18 +18,17 @@ import {
createPseudoTerminal,
PseudoTerminal,
} from 'nx/src/tasks-runner/pseudo-terminal';
import { getAllDependsOn, getExcludeTasks } from './get-exclude-task';
import {
getAllDependsOn,
getExcludeTasks,
getGradleTaskNameWithNxTaskId,
} from './get-exclude-task';
export const batchRunnerPath = join(
__dirname,
'../../../batch-runner/build/libs/batch-runner-all.jar'
);
interface GradleTask {
taskName: string;
testClassName: string;
}
export default async function gradleBatch(
taskGraph: TaskGraph,
inputs: Record<string, GradleExecutorSchema>,
@ -50,59 +55,20 @@ export default async function gradleBatch(
args.push(...overrides.__overrides_unparsed__);
}
const taskIdsWithExclude = [];
const taskIdsWithoutExclude = [];
const taskIds = Object.keys(taskGraph.tasks);
for (const taskId of taskIds) {
if (inputs[taskId].excludeDependsOn) {
taskIdsWithExclude.push(taskId);
} else {
taskIdsWithoutExclude.push(taskId);
}
}
const allDependsOn = new Set<string>(taskIds);
taskIdsWithoutExclude.forEach((taskId) => {
const [projectName, targetName] = taskId.split(':');
const dependencies = getAllDependsOn(
context.projectGraph,
projectName,
targetName
const { gradlewTasksToRun, excludeTasks, excludeTestTasks } =
getGradlewTasksToRun(
taskIds,
taskGraph,
inputs,
context.projectGraph.nodes
);
dependencies.forEach((dep) => allDependsOn.add(dep));
});
const gradlewTasksToRun: Record<string, GradleTask> = taskIds.reduce(
(gradlewTasksToRun, taskId) => {
const task = taskGraph.tasks[taskId];
const gradlewTaskName = inputs[task.id].taskName;
const testClassName = inputs[task.id].testClassName;
gradlewTasksToRun[taskId] = {
taskName: gradlewTaskName,
testClassName: testClassName,
};
return gradlewTasksToRun;
},
{}
);
const excludeTasks = getExcludeTasks(
context.projectGraph,
taskIdsWithExclude.map((taskId) => {
const task = taskGraph.tasks[taskId];
return {
project: task?.target?.project,
target: task?.target?.target,
excludeDependsOn: inputs[taskId]?.excludeDependsOn,
};
}),
allDependsOn
);
const batchResults = await runTasksInBatch(
gradlewTasksToRun,
excludeTasks,
excludeTestTasks,
args,
root
);
@ -129,9 +95,71 @@ export default async function gradleBatch(
}
}
/**
* Get the gradlew task ids to run
*/
export function getGradlewTasksToRun(
taskIds: string[],
taskGraph: TaskGraph,
inputs: Record<string, GradleExecutorSchema>,
nodes: Record<string, ProjectGraphProjectNode>
) {
const taskIdsWithExclude: Set<string> = new Set([]);
const testTaskIdsWithExclude: Set<string> = new Set([]);
const taskIdsWithoutExclude: Set<string> = new Set([]);
const gradlewTasksToRun: Record<string, GradleExecutorSchema> = {};
for (const taskId of taskIds) {
const task = taskGraph.tasks[taskId];
const input = inputs[task.id];
gradlewTasksToRun[taskId] = input;
if (input.excludeDependsOn) {
if (input.testClassName) {
testTaskIdsWithExclude.add(taskId);
} else {
taskIdsWithExclude.add(taskId);
}
} else {
taskIdsWithoutExclude.add(taskId);
}
}
const allDependsOn = new Set<string>(taskIds);
for (const taskId of taskIdsWithoutExclude) {
const [projectName, targetName] = taskId.split(':');
const dependencies = getAllDependsOn(nodes, projectName, targetName);
dependencies.forEach((dep) => allDependsOn.add(dep));
}
const excludeTasks = getExcludeTasks(taskIdsWithExclude, nodes, allDependsOn);
const allTestsDependsOn = new Set<string>();
for (const taskId of testTaskIdsWithExclude) {
const [projectName, targetName] = taskId.split(':');
const taskDependsOn = getAllDependsOn(nodes, projectName, targetName);
taskDependsOn.forEach((dep) => allTestsDependsOn.add(dep));
}
const excludeTestTasks = new Set<string>();
for (let taskId of allTestsDependsOn) {
const gradleTaskName = getGradleTaskNameWithNxTaskId(taskId, nodes);
if (gradleTaskName) {
excludeTestTasks.add(gradleTaskName);
}
}
return {
gradlewTasksToRun,
excludeTasks,
excludeTestTasks,
};
}
async function runTasksInBatch(
gradlewTasksToRun: Record<string, GradleTask>,
gradlewTasksToRun: Record<string, GradleExecutorSchema>,
excludeTasks: Set<string>,
excludeTestTasks: Set<string>,
args: string[],
root: string
): Promise<BatchResults> {
@ -146,7 +174,9 @@ async function runTasksInBatch(
.join(' ')
.replaceAll("'", '"')}' --excludeTasks='${Array.from(excludeTasks).join(
','
)}' ${process.env.NX_VERBOSE_LOGGING === 'true' ? '' : '--quiet'}`;
)}' --excludeTestTasks='${Array.from(excludeTestTasks).join(',')}' ${
process.env.NX_VERBOSE_LOGGING === 'true' ? '' : '--quiet'
}`;
let batchResults;
if (usePseudoTerminal && process.env.NX_VERBOSE_LOGGING !== 'true') {
const terminal = createPseudoTerminal();

View File

@ -24,13 +24,10 @@ export default async function gradleExecutor(
args.push(`--tests`, options.testClassName);
}
getExcludeTasks(context.projectGraph, [
{
project: context.projectName,
target: context.targetName,
excludeDependsOn: options.excludeDependsOn,
},
]).forEach((task) => {
getExcludeTasks(
new Set([`${context.projectName}:${context.targetName}`]),
context.projectGraph.nodes
).forEach((task) => {
if (task) {
args.push('--exclude-task', task);
}

View File

@ -11,7 +11,7 @@
},
"testClassName": {
"type": "string",
"description": "The test class name to run for test task."
"description": "The full test name to run for test task (package name and class name)."
},
"args": {
"oneOf": [