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:
parent
8daad98992
commit
7a53477adc
2
.gitignore
vendored
2
.gitignore
vendored
@ -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
|
||||||
|
|||||||
@ -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": [
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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, "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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))
|
||||||
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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() }
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
// This function return all class names and nested class names inside a file
|
||||||
it.contains("@Test") || it.contains("@TestTemplate") || it.contains("@ParameterizedTest")
|
fun getAllVisibleClassesWithNestedAnnotation(file: File): MutableMap<String, String>? {
|
||||||
|
val content = file.takeIf { it.exists() }?.readText() ?: return null
|
||||||
|
|
||||||
|
val lines = content.lines()
|
||||||
|
val result = mutableMapOf<String, String>()
|
||||||
|
var packageName: String?
|
||||||
|
val classStack = mutableListOf<Pair<String, Int>>() // (className, indent)
|
||||||
|
|
||||||
|
var previousLine: String? = null
|
||||||
|
|
||||||
|
for (i in lines.indices) {
|
||||||
|
val line = lines[i]
|
||||||
|
val trimmed = line.trimStart()
|
||||||
|
val indent = line.indexOfFirst { !it.isWhitespace() }.takeIf { it >= 0 } ?: 0
|
||||||
|
|
||||||
|
// Skip private classes
|
||||||
|
if (privateClassRegex.containsMatchIn(trimmed)) continue
|
||||||
|
|
||||||
|
packageName = packageDeclarationRegex.find(content)?.groupValues?.getOrNull(1)
|
||||||
|
val match = classDeclarationRegex.find(trimmed)
|
||||||
|
if (match == null) {
|
||||||
|
previousLine = trimmed
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
?.let { content ->
|
|
||||||
val className = classDeclarationRegex.find(content)?.groupValues?.getOrNull(1)
|
val className = match.groupValues.getOrNull(2)
|
||||||
return if (className != null && !className.startsWith("Fake")) {
|
if (className == null) {
|
||||||
className
|
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)
|
||||||
|
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
153
packages/gradle/src/executors/gradle/get-exclude-task.spec.ts
Normal file
153
packages/gradle/src/executors/gradle/get-exclude-task.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
173
packages/gradle/src/executors/gradle/gradle-batch.impl.spec.ts
Normal file
173
packages/gradle/src/executors/gradle/gradle-batch.impl.spec.ts
Normal 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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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": [
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user