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 *.njsproj
*.sln *.sln
*.sw? *.sw?
.specstory/**
.cursorindexingignore
# OS specific # OS specific
# Task files # Task files
tasks.json tasks.json

View File

@ -15,7 +15,7 @@
}, },
"testClassName": { "testClassName": {
"type": "string", "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": { "args": {
"oneOf": [ "oneOf": [

View File

@ -1,19 +1,18 @@
package dev.nx.gradle package dev.nx.gradle
import com.google.gson.Gson import com.google.gson.Gson
import dev.nx.gradle.cli.configureLogger
import dev.nx.gradle.cli.parseArgs import dev.nx.gradle.cli.parseArgs
import dev.nx.gradle.runner.runTasksInParallel import dev.nx.gradle.runner.runTasksInParallel
import dev.nx.gradle.util.configureSingleLineLogger
import dev.nx.gradle.util.logger import dev.nx.gradle.util.logger
import java.io.File import java.io.File
import kotlin.system.exitProcess import kotlin.system.exitProcess
import kotlinx.coroutines.runBlocking
import org.gradle.tooling.GradleConnector import org.gradle.tooling.GradleConnector
import org.gradle.tooling.ProjectConnection import org.gradle.tooling.ProjectConnection
fun main(args: Array<String>) { fun main(args: Array<String>) {
val options = parseArgs(args) val options = parseArgs(args)
configureLogger(options.quiet) configureSingleLineLogger(options.quiet)
logger.info("NxBatchOptions: $options") logger.info("NxBatchOptions: $options")
if (options.workspaceRoot.isBlank()) { if (options.workspaceRoot.isBlank()) {
@ -26,15 +25,21 @@ fun main(args: Array<String>) {
exitProcess(1) exitProcess(1)
} }
var connection: ProjectConnection? = null var buildConnection: ProjectConnection? = null
try { try {
connection = val connector = GradleConnector.newConnector().forProjectDirectory(File(options.workspaceRoot))
GradleConnector.newConnector().forProjectDirectory(File(options.workspaceRoot)).connect()
val results = runBlocking { buildConnection = connector.connect()
runTasksInParallel(connection, options.tasks, options.args, options.excludeTasks) logger.info("🏁 Gradle connection open.")
}
val results =
runTasksInParallel(
buildConnection,
options.tasks,
options.args,
options.excludeTasks,
options.excludeTestTasks)
val reportJson = Gson().toJson(results) val reportJson = Gson().toJson(results)
println(reportJson) println(reportJson)
@ -47,7 +52,7 @@ fun main(args: Array<String>) {
exitProcess(1) exitProcess(1)
} finally { } finally {
try { try {
connection?.close() buildConnection?.close()
logger.info("✅ Gradle connection closed.") logger.info("✅ Gradle connection closed.")
} catch (e: Exception) { } catch (e: Exception) {
logger.warning("⚠️ Failed to close Gradle connection cleanly: ${e.message}") 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 com.google.gson.reflect.TypeToken
import dev.nx.gradle.data.GradleTask import dev.nx.gradle.data.GradleTask
import dev.nx.gradle.data.NxBatchOptions import dev.nx.gradle.data.NxBatchOptions
import dev.nx.gradle.util.logger
fun parseArgs(args: Array<String>): NxBatchOptions { fun parseArgs(args: Array<String>): NxBatchOptions {
val argMap = mutableMapOf<String, String>() val argMap = mutableMapOf<String, String>()
@ -33,20 +32,15 @@ fun parseArgs(args: Array<String>): NxBatchOptions {
argMap["--excludeTasks"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } argMap["--excludeTasks"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: emptyList() ?: emptyList()
val excludeTestTasks =
argMap["--excludeTestTasks"]?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
?: emptyList()
return NxBatchOptions( return NxBatchOptions(
workspaceRoot = argMap["--workspaceRoot"] ?: "", workspaceRoot = argMap["--workspaceRoot"] ?: "",
tasks = tasksMap, tasks = tasksMap,
args = argMap["--args"] ?: "", args = argMap["--args"] ?: "",
quiet = argMap["--quiet"]?.toBoolean() ?: false, quiet = argMap["--quiet"]?.toBoolean() ?: false,
excludeTasks = excludeTasks) excludeTasks = excludeTasks,
} excludeTestTasks = excludeTestTasks)
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)
}
} }

View File

@ -5,5 +5,6 @@ data class NxBatchOptions(
val tasks: Map<String, GradleTask>, val tasks: Map<String, GradleTask>,
val args: String, val args: String,
val quiet: Boolean, 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.GradleTask
import dev.nx.gradle.data.TaskResult import dev.nx.gradle.data.TaskResult
import dev.nx.gradle.util.logger 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.ProgressEvent
import org.gradle.tooling.events.task.TaskFailureResult import org.gradle.tooling.events.task.TaskFailureResult
import org.gradle.tooling.events.task.TaskFinishEvent import org.gradle.tooling.events.task.TaskFinishEvent
@ -22,14 +20,28 @@ fun buildListener(
.find { it.value.taskName == event.descriptor.taskPath } .find { it.value.taskName == event.descriptor.taskPath }
?.key ?.key
?.let { nxTaskId -> ?.let { nxTaskId ->
taskStartTimes[nxTaskId] = min(System.currentTimeMillis(), event.eventTime) taskStartTimes[nxTaskId] = event.eventTime
logger.info("🏁 Task start: $nxTaskId ${event.descriptor.taskPath}")
} }
} }
is TaskFinishEvent -> { is TaskFinishEvent -> {
val taskPath = event.descriptor.taskPath val taskPath = event.descriptor.taskPath
val success = val success = getTaskFinishEventSuccess(event, taskPath)
when (event.result) { tasks.entries
.find { it.value.taskName == taskPath }
?.key
?.let { nxTaskId ->
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 -> { is TaskSuccessResult -> {
logger.info("✅ Task finished successfully: $taskPath") logger.info("✅ Task finished successfully: $taskPath")
true true
@ -42,15 +54,4 @@ fun buildListener(
else -> true else -> true
} }
tasks.entries
.find { it.value.taskName == taskPath }
?.key
?.let { nxTaskId ->
val endTime = max(System.currentTimeMillis(), event.eventTime)
val startTime = taskStartTimes[nxTaskId] ?: event.result.startTime
taskResults[nxTaskId] = TaskResult(success, startTime, endTime, "")
}
}
}
} }

View File

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

View File

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

View File

@ -1,5 +1,65 @@
package dev.nx.gradle.util 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 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") 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 ./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: It generates a json file to be consumed by nx:
```json ```json

View File

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

View File

@ -11,7 +11,23 @@ const val testCiTargetGroup = "verification"
private val testFileNameRegex = private val testFileNameRegex =
Regex("^(?!(abstract|fake)).*?(Test)(s)?\\d*", RegexOption.IGNORE_CASE) 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( fun addTestCiTargets(
testFiles: FileCollection, testFiles: FileCollection,
@ -31,15 +47,18 @@ fun addTestCiTargets(
testFiles testFiles
.filter { isTestFile(it, workspaceRoot) } .filter { isTestFile(it, workspaceRoot) }
.forEach { testFile -> .forEach { testFile ->
val className = getTestClassNameIfAnnotated(testFile) ?: return@forEach val classNames = getAllVisibleClassesWithNestedAnnotation(testFile)
classNames?.entries?.forEach { (className, testClassPackagePath) ->
val targetName = "$ciTestTargetName--$className" val targetName = "$ciTestTargetName--$className"
targets[targetName] = targets[targetName] =
buildTestCiTarget( buildTestCiTarget(
projectBuildPath, className, testFile, testTask, projectRoot, workspaceRoot) projectBuildPath, testClassPackagePath, testTask, projectRoot, workspaceRoot)
targetGroups[testCiTargetGroup]?.add(targetName) 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"] }}") testTask.logger.info("${testTask.path} generated CI targets: ${ciDependsOn.map { it["target"] }}")
@ -56,21 +75,72 @@ fun addTestCiTargets(
} }
} }
private fun getTestClassNameIfAnnotated(file: File): String? { private fun containsEssentialTestAnnotations(content: String): Boolean {
return file return essentialTestAnnotations.any { content.contains(it) }
.takeIf { it.exists() }
?.readText()
?.takeIf {
it.contains("@Test") || it.contains("@TestTemplate") || it.contains("@ParameterizedTest")
} }
?.let { content ->
val className = classDeclarationRegex.find(content)?.groupValues?.getOrNull(1) // This function return all class names and nested class names inside a file
return if (className != null && !className.startsWith("Fake")) { fun getAllVisibleClassesWithNestedAnnotation(file: File): MutableMap<String, String>? {
className 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)
}
classStack.clear()
classStack.add(className to indent)
} else { } else {
null // 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) { fun ensureTargetGroupExists(targetGroups: TargetGroups, group: String) {
@ -84,8 +154,7 @@ private fun isTestFile(file: File, workspaceRoot: String): Boolean {
private fun buildTestCiTarget( private fun buildTestCiTarget(
projectBuildPath: String, projectBuildPath: String,
testClassName: String, testClassPackagePath: String,
testFile: File,
testTask: Task, testTask: Task,
projectRoot: String, projectRoot: String,
workspaceRoot: String, workspaceRoot: String,
@ -98,9 +167,9 @@ private fun buildTestCiTarget(
"options" to "options" to
mapOf( mapOf(
"taskName" to "${projectBuildPath}:${testTask.name}", "taskName" to "${projectBuildPath}:${testTask.name}",
"testClassName" to testClassName), "testClassName" to testClassPackagePath),
"metadata" to "metadata" to
getMetadata("Runs Gradle test $testClassName in CI", projectBuildPath, "test"), getMetadata("Runs Gradle test $testClassPackagePath in CI", projectBuildPath, "test"),
"cache" to true, "cache" to true,
"inputs" to taskInputs) "inputs" to taskInputs)

View File

@ -79,7 +79,6 @@ fun processTargetsForProject(
val ciTestTargetName = targetNameOverrides["ciTestTargetName"] val ciTestTargetName = targetNameOverrides["ciTestTargetName"]
val ciIntTestTargetName = targetNameOverrides["ciIntTestTargetName"] val ciIntTestTargetName = targetNameOverrides["ciIntTestTargetName"]
val ciCheckTargetName = targetNameOverrides.getOrDefault("ciCheckTargetName", "check-ci")
val testTargetName = targetNameOverrides.getOrDefault("testTargetName", "test") val testTargetName = targetNameOverrides.getOrDefault("testTargetName", "test")
val intTestTargetName = targetNameOverrides.getOrDefault("intTestTargetName", "intTest") val intTestTargetName = targetNameOverrides.getOrDefault("intTestTargetName", "intTest")
@ -138,6 +137,7 @@ fun processTargetsForProject(
ciIntTestTargetName!!) ciIntTestTargetName!!)
} }
val ciCheckTargetName = targetNameOverrides.getOrDefault("ciCheckTargetName", "check-ci")
if (task.name == "check") { if (task.name == "check") {
val replacedDependencies = val replacedDependencies =
(target["dependsOn"] as? List<*>)?.map { dep -> (target["dependsOn"] as? List<*>)?.map { dep ->
@ -153,6 +153,7 @@ fun processTargetsForProject(
} }
} ?: emptyList() } ?: emptyList()
if (atomized) {
val newTarget: MutableMap<String, Any?> = val newTarget: MutableMap<String, Any?> =
mutableMapOf( mutableMapOf(
"dependsOn" to replacedDependencies, "dependsOn" to replacedDependencies,
@ -164,6 +165,33 @@ fun processTargetsForProject(
ensureTargetGroupExists(targetGroups, testCiTargetGroup) ensureTargetGroupExists(targetGroups, testCiTargetGroup)
targetGroups[testCiTargetGroup]?.add(ciCheckTargetName) 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}") logger.info("$now ${project.name}: Processed task ${task.path}")
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -161,7 +161,7 @@ fun getOutputsForTask(task: Task, projectRoot: String, workspaceRoot: String): L
*/ */
fun getDependsOnForTask( fun getDependsOnForTask(
task: Task, task: Task,
dependencies: MutableSet<Dependency>?, dependencies: MutableSet<Dependency>?, // Assuming Dependency class is defined elsewhere
targetNameOverrides: Map<String, String> = emptyMap() targetNameOverrides: Map<String, String> = emptyMap()
): List<String>? { ): List<String>? {
@ -187,19 +187,30 @@ fun getDependsOnForTask(
} }
return try { return try {
// get depends on using taskDependencies.getDependencies(task) because task.dependsOn has // 1. Get dependencies from task.taskDependencies.getDependencies(task)
// missing deps val dependsOnFromTaskDependencies: Set<Task> =
val dependsOn =
try { try {
task.taskDependencies.getDependencies(null) task.taskDependencies.getDependencies(task)
} catch (e: Exception) { } catch (e: Exception) {
task.logger.info("Error calling getDependencies for ${task.path}: ${e.message}") task.logger.info("Error calling getDependencies for ${task.path}: ${e.message}")
task.logger.debug("Stack trace:", e) task.logger.debug("Stack trace:", e)
emptySet<Task>() emptySet<Task>() // If it fails, return an empty set to be combined later
} }
if (dependsOn.isNotEmpty()) { // 2. Get dependencies from task.dependsOn and filter for Task instances
return mapTasksToNames(dependsOn) 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 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 package dev.nx.gradle.utils
import dev.nx.gradle.data.* import dev.nx.gradle.data.*
import java.io.File
import kotlin.test.* import kotlin.test.*
import org.gradle.testfixtures.ProjectBuilder import org.gradle.testfixtures.ProjectBuilder
@ -50,4 +51,104 @@ class CreateNodeForProjectTest {
assertTrue(result.dependencies.isEmpty(), "Expected no dependencies") assertTrue(result.dependencies.isEmpty(), "Expected no dependencies")
assertTrue(result.externalNodes.isEmpty(), "Expected no external nodes") 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 * 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'] * and only `test` is running, this will return: ['lint']
*/ */
export function getExcludeTasks( export function getExcludeTasks(
projectGraph: ProjectGraph, taskIds: Set<string>,
targets: { project: string; target: string; excludeDependsOn: boolean }[], nodes: Record<string, ProjectGraphProjectNode>,
runningTaskIds: Set<string> = new Set() runningTaskIds: Set<string> = new Set()
): Set<string> { ): Set<string> {
const excludes = new Set<string>(); const excludes = new Set<string>();
for (const { project, target, excludeDependsOn } of targets) { for (const taskId of taskIds) {
if (!excludeDependsOn) { const [project, target] = taskId.split(':');
continue; const taskDeps = nodes[project]?.data?.targets?.[target]?.dependsOn ?? [];
}
const taskDeps =
projectGraph.nodes[project]?.data?.targets?.[target]?.dependsOn ?? [];
for (const dep of taskDeps) { for (const dep of taskDeps) {
const taskId = typeof dep === 'string' ? dep : dep?.target; const taskId = typeof dep === 'string' ? dep : dep?.target;
if (taskId && !runningTaskIds.has(taskId)) { if (taskId && !runningTaskIds.has(taskId)) {
const [projectName, targetName] = taskId.split(':'); const gradleTaskName = getGradleTaskNameWithNxTaskId(taskId, nodes);
const taskName = if (gradleTaskName) {
projectGraph.nodes[projectName]?.data?.targets?.[targetName]?.options excludes.add(gradleTaskName);
?.taskName;
if (taskName) {
excludes.add(taskName);
} }
} }
} }
@ -38,30 +35,43 @@ export function getExcludeTasks(
return excludes; 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( export function getAllDependsOn(
projectGraph: ProjectGraph, nodes: Record<string, ProjectGraphProjectNode>,
projectName: string, projectName: string,
targetName: string, targetName: string
visited: Set<string> = new Set() ): Set<string> {
): string[] { const allDependsOn = new Set<string>();
const dependsOn = const stack: string[] = [`${projectName}:${targetName}`];
projectGraph[projectName]?.data?.targets?.[targetName]?.dependsOn ?? [];
const allDependsOn: string[] = []; while (stack.length > 0) {
const currentTaskId = stack.pop();
if (currentTaskId && !allDependsOn.has(currentTaskId)) {
allDependsOn.add(currentTaskId);
for (const dependency of dependsOn) { const [currentProjectName, currentTargetName] = currentTaskId.split(':');
if (!visited.has(dependency)) { const directDependencies =
visited.add(dependency); nodes[currentProjectName]?.data?.targets?.[currentTargetName]
?.dependsOn ?? [];
const [depProjectName, depTargetName] = dependency.split(':'); for (const dep of directDependencies) {
allDependsOn.push(dependency); const depTaskId = typeof dep === 'string' ? dep : dep?.target;
if (depTaskId && !allDependsOn.has(depTaskId)) {
// Recursively get dependencies of the current dependency stack.push(depTaskId);
allDependsOn.push(
...getAllDependsOn(projectGraph, depProjectName, depTargetName, visited)
);
} }
} }
}
}
allDependsOn.delete(`${projectName}:${targetName}`); // Exclude the starting task itself
return allDependsOn; 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 {
import runCommandsImpl, { ExecutorContext,
output,
ProjectGraphProjectNode,
TaskGraph,
workspaceRoot,
} from '@nx/devkit';
import {
LARGE_BUFFER, LARGE_BUFFER,
RunCommandsOptions, RunCommandsOptions,
} from 'nx/src/executors/run-commands/run-commands.impl'; } from 'nx/src/executors/run-commands/run-commands.impl';
@ -12,18 +18,17 @@ import {
createPseudoTerminal, createPseudoTerminal,
PseudoTerminal, PseudoTerminal,
} from 'nx/src/tasks-runner/pseudo-terminal'; } 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( export const batchRunnerPath = join(
__dirname, __dirname,
'../../../batch-runner/build/libs/batch-runner-all.jar' '../../../batch-runner/build/libs/batch-runner-all.jar'
); );
interface GradleTask {
taskName: string;
testClassName: string;
}
export default async function gradleBatch( export default async function gradleBatch(
taskGraph: TaskGraph, taskGraph: TaskGraph,
inputs: Record<string, GradleExecutorSchema>, inputs: Record<string, GradleExecutorSchema>,
@ -50,59 +55,20 @@ export default async function gradleBatch(
args.push(...overrides.__overrides_unparsed__); args.push(...overrides.__overrides_unparsed__);
} }
const taskIdsWithExclude = [];
const taskIdsWithoutExclude = [];
const taskIds = Object.keys(taskGraph.tasks); 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); const { gradlewTasksToRun, excludeTasks, excludeTestTasks } =
taskIdsWithoutExclude.forEach((taskId) => { getGradlewTasksToRun(
const [projectName, targetName] = taskId.split(':'); taskIds,
const dependencies = getAllDependsOn( taskGraph,
context.projectGraph, inputs,
projectName, context.projectGraph.nodes
targetName
);
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( const batchResults = await runTasksInBatch(
gradlewTasksToRun, gradlewTasksToRun,
excludeTasks, excludeTasks,
excludeTestTasks,
args, args,
root 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( async function runTasksInBatch(
gradlewTasksToRun: Record<string, GradleTask>, gradlewTasksToRun: Record<string, GradleExecutorSchema>,
excludeTasks: Set<string>, excludeTasks: Set<string>,
excludeTestTasks: Set<string>,
args: string[], args: string[],
root: string root: string
): Promise<BatchResults> { ): Promise<BatchResults> {
@ -146,7 +174,9 @@ async function runTasksInBatch(
.join(' ') .join(' ')
.replaceAll("'", '"')}' --excludeTasks='${Array.from(excludeTasks).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; let batchResults;
if (usePseudoTerminal && process.env.NX_VERBOSE_LOGGING !== 'true') { if (usePseudoTerminal && process.env.NX_VERBOSE_LOGGING !== 'true') {
const terminal = createPseudoTerminal(); const terminal = createPseudoTerminal();

View File

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

View File

@ -11,7 +11,7 @@
}, },
"testClassName": { "testClassName": {
"type": "string", "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": { "args": {
"oneOf": [ "oneOf": [