diff --git a/.gitignore b/.gitignore index 9a9b22fd38..27f93e6666 100644 --- a/.gitignore +++ b/.gitignore @@ -96,6 +96,8 @@ node_modules/ *.njsproj *.sln *.sw? +.specstory/** +.cursorindexingignore # OS specific # Task files tasks.json diff --git a/docs/generated/packages/gradle/executors/gradle.json b/docs/generated/packages/gradle/executors/gradle.json index 9a8f63ba1d..2afc3d1423 100644 --- a/docs/generated/packages/gradle/executors/gradle.json +++ b/docs/generated/packages/gradle/executors/gradle.json @@ -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": [ diff --git a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/NxBatchRunner.kt b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/NxBatchRunner.kt index c96464f55d..9e58e0e99e 100644 --- a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/NxBatchRunner.kt +++ b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/NxBatchRunner.kt @@ -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) { 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) { 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) { 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}") diff --git a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/cli/ArgParser.kt b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/cli/ArgParser.kt index 3bc30eaf21..1c623655a9 100644 --- a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/cli/ArgParser.kt +++ b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/cli/ArgParser.kt @@ -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): NxBatchOptions { val argMap = mutableMapOf() @@ -33,20 +32,15 @@ fun parseArgs(args: Array): 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) } diff --git a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/data/NxBatchOptions.kt b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/data/NxBatchOptions.kt index 59a87de791..eb0f262343 100644 --- a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/data/NxBatchOptions.kt +++ b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/data/NxBatchOptions.kt @@ -5,5 +5,6 @@ data class NxBatchOptions( val tasks: Map, val args: String, val quiet: Boolean, - val excludeTasks: List + val excludeTasks: List, + val excludeTestTasks: List ) diff --git a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/BuildListener.kt b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/BuildListener.kt index 1337936063..7badd57ef5 100644 --- a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/BuildListener.kt +++ b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/BuildListener.kt @@ -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 + } +} diff --git a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/GradleRunner.kt b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/GradleRunner.kt index d15f3d79b9..025619af86 100644 --- a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/GradleRunner.kt +++ b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/GradleRunner.kt @@ -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, additionalArgs: String, - excludeTasks: List -): Map = coroutineScope { + excludeTasks: List, + excludeTestTasks: List +): Map { 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() - 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, args: List, + excludeTasks: List, outputStream: ByteArrayOutputStream, errorStream: ByteArrayOutputStream ): Map { @@ -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, args: List, + excludeTestTasks: List, outputStream: ByteArrayOutputStream, errorStream: ByteArrayOutputStream ): Map { - val taskNames = tasks.values.map { it.taskName }.distinct().toTypedArray() - logger.info("📋 Collected ${taskNames.size} unique task names: ${taskNames.joinToString(", ")}") - - val taskStartTimes = mutableMapOf() - val taskResults = mutableMapOf() val testTaskStatus = mutableMapOf() val testStartTimes = mutableMapOf() val testEndTimes = mutableMapOf() - tasks.forEach { (nxTaskId, taskConfig) -> - if (taskConfig.testClassName != null) { - testTaskStatus[nxTaskId] = true - } - } + // Group the list of GradleTask by their taskName + val groupedTasks: Map> = 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 = 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() 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, "") } } diff --git a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/TestListener.kt b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/TestListener.kt index 447af84cd5..a84dd23665 100644 --- a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/TestListener.kt +++ b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/runner/TestListener.kt @@ -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, - taskStartTimes: MutableMap, - taskResults: MutableMap, testTaskStatus: MutableMap, testStartTimes: MutableMap, - testEndTimes: MutableMap -): (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, +): (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") + } + } + } + } } } } diff --git a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/util/Logger.kt b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/util/Logger.kt index b3b7d54b1e..10d09c8c23 100644 --- a/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/util/Logger.kt +++ b/packages/gradle/batch-runner/src/main/kotlin/dev/nx/gradle/util/Logger.kt @@ -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)) +} diff --git a/packages/gradle/project-graph/README.md b/packages/gradle/project-graph/README.md index 2ffdde03fd..b3f119b5ae 100644 --- a/packages/gradle/project-graph/README.md +++ b/packages/gradle/project-graph/README.md @@ -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 diff --git a/packages/gradle/project-graph/build.gradle.kts b/packages/gradle/project-graph/build.gradle.kts index 6221fb3e96..42a17d87ba 100644 --- a/packages/gradle/project-graph/build.gradle.kts +++ b/packages/gradle/project-graph/build.gradle.kts @@ -10,7 +10,7 @@ plugins { group = "dev.nx.gradle" -version = "0.1.0" +version = "0.0.1-alpha.6" repositories { mavenCentral() } diff --git a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/CiTargetsUtils.kt b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/CiTargetsUtils.kt index 55f13ae2bb..1c3a7eb990 100644 --- a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/CiTargetsUtils.kt +++ b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/CiTargetsUtils.kt @@ -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? { + val content = file.takeIf { it.exists() }?.readText() ?: return null + + val lines = content.lines() + val result = mutableMapOf() + var packageName: String? + val classStack = mutableListOf>() // (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) diff --git a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/ProjectUtils.kt b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/ProjectUtils.kt index 9b81ae0dd5..9454d6a132 100644 --- a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/ProjectUtils.kt +++ b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/ProjectUtils.kt @@ -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 = - 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 = + 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 = + 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}") diff --git a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/TaskUtils.kt b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/TaskUtils.kt index 912161d8f8..986e1836e0 100644 --- a/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/TaskUtils.kt +++ b/packages/gradle/project-graph/src/main/kotlin/dev/nx/gradle/utils/TaskUtils.kt @@ -161,7 +161,7 @@ fun getOutputsForTask(task: Task, projectRoot: String, workspaceRoot: String): L */ fun getDependsOnForTask( task: Task, - dependencies: MutableSet?, + dependencies: MutableSet?, // Assuming Dependency class is defined elsewhere targetNameOverrides: Map = emptyMap() ): List? { @@ -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 = 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() + emptySet() // 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.dependsOn.filterIsInstance().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 diff --git a/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/ClassDetectionTest.kt b/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/ClassDetectionTest.kt new file mode 100644 index 0000000000..29729c4dde --- /dev/null +++ b/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/ClassDetectionTest.kt @@ -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) + } +} diff --git a/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/CreateNodeForProjectTest.kt b/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/CreateNodeForProjectTest.kt index 3f890f452c..a7ad43147e 100644 --- a/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/CreateNodeForProjectTest.kt +++ b/packages/gradle/project-graph/src/test/kotlin/dev/nx/gradle/utils/CreateNodeForProjectTest.kt @@ -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'") + } } diff --git a/packages/gradle/src/executors/gradle/get-exclude-task.spec.ts b/packages/gradle/src/executors/gradle/get-exclude-task.spec.ts new file mode 100644 index 0000000000..2ec11f49b2 --- /dev/null +++ b/packages/gradle/src/executors/gradle/get-exclude-task.spec.ts @@ -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(['app1:test', 'app2:build']); + const runningTaskIds = new Set(['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(['app1:test']); + const runningTaskIds = new Set([ + '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(['app2:build']); + const runningTaskIds = new Set(['app2:build']); + const excludes = getExcludeTasks(targets, nodes, runningTaskIds); + expect(excludes).toEqual(new Set()); + }); + + it('should handle missing project or target', () => { + const targets = new Set(['nonexistent:test']); + const runningTaskIds = new Set(); + const excludes = getExcludeTasks(targets, nodes, runningTaskIds); + expect(excludes).toEqual(new Set()); + }); + + it('should handle dependencies that are also running tasks', () => { + const targets = new Set(['app1:test']); + const runningTaskIds = new Set(['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(['app3:deploy']); + const runningTaskIds = new Set(['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'); + }); +}); diff --git a/packages/gradle/src/executors/gradle/get-exclude-task.ts b/packages/gradle/src/executors/gradle/get-exclude-task.ts index 1a300efee0..35230f6a0d 100644 --- a/packages/gradle/src/executors/gradle/get-exclude-task.ts +++ b/packages/gradle/src/executors/gradle/get-exclude-task.ts @@ -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, + nodes: Record, runningTaskIds: Set = new Set() ): Set { const excludes = new Set(); - 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 | null { + const [projectName, targetName] = nxTaskId.split(':'); + const gradleTaskName = + nodes[projectName]?.data?.targets?.[targetName]?.options?.taskName; + return gradleTaskName; +} + export function getAllDependsOn( - projectGraph: ProjectGraph, + nodes: Record, projectName: string, - targetName: string, - visited: Set = new Set() -): string[] { - const dependsOn = - projectGraph[projectName]?.data?.targets?.[targetName]?.dependsOn ?? []; + targetName: string +): Set { + const allDependsOn = new Set(); + 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; } diff --git a/packages/gradle/src/executors/gradle/gradle-batch.impl.spec.ts b/packages/gradle/src/executors/gradle/gradle-batch.impl.spec.ts new file mode 100644 index 0000000000..e4bf69aa65 --- /dev/null +++ b/packages/gradle/src/executors/gradle/gradle-batch.impl.spec.ts @@ -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; + let nodes: Record; + + 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()); + }); +}); diff --git a/packages/gradle/src/executors/gradle/gradle-batch.impl.ts b/packages/gradle/src/executors/gradle/gradle-batch.impl.ts index ab3b6dbeb1..b43d058523 100644 --- a/packages/gradle/src/executors/gradle/gradle-batch.impl.ts +++ b/packages/gradle/src/executors/gradle/gradle-batch.impl.ts @@ -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, @@ -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(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 = 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, + nodes: Record +) { + const taskIdsWithExclude: Set = new Set([]); + const testTaskIdsWithExclude: Set = new Set([]); + const taskIdsWithoutExclude: Set = new Set([]); + const gradlewTasksToRun: Record = {}; + + 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(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(); + 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(); + for (let taskId of allTestsDependsOn) { + const gradleTaskName = getGradleTaskNameWithNxTaskId(taskId, nodes); + if (gradleTaskName) { + excludeTestTasks.add(gradleTaskName); + } + } + + return { + gradlewTasksToRun, + excludeTasks, + excludeTestTasks, + }; +} + async function runTasksInBatch( - gradlewTasksToRun: Record, + gradlewTasksToRun: Record, excludeTasks: Set, + excludeTestTasks: Set, args: string[], root: string ): Promise { @@ -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(); diff --git a/packages/gradle/src/executors/gradle/gradle.impl.ts b/packages/gradle/src/executors/gradle/gradle.impl.ts index 0217388cfb..d1964cb94b 100644 --- a/packages/gradle/src/executors/gradle/gradle.impl.ts +++ b/packages/gradle/src/executors/gradle/gradle.impl.ts @@ -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); } diff --git a/packages/gradle/src/executors/gradle/schema.json b/packages/gradle/src/executors/gradle/schema.json index c8833c62a7..243f3ff7cf 100644 --- a/packages/gradle/src/executors/gradle/schema.json +++ b/packages/gradle/src/executors/gradle/schema.json @@ -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": [