feat(gradle): add batch runner (#30457)

<!-- 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 -->

Gradle tasks are run by invoking the Gradle CLI

## Expected Behavior
<!-- This is the behavior we should expect with the changes in this PR
-->

Gradle tasks are run through the Gradle Tooling API and is more
performant.

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

Fixes #

---------

Co-authored-by: Jason Jean <jasonjean1993@gmail.com>
This commit is contained in:
Emily Xiong 2025-04-29 16:57:16 -04:00 committed by GitHub
parent 57724d3df9
commit 624f0359e3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 1026 additions and 223 deletions

View File

@ -76,7 +76,7 @@ jobs:
uses: actions/setup-java@v4 uses: actions/setup-java@v4
with: with:
distribution: temurin distribution: temurin
java-version: 21 java-version: 17
- name: Check Documentation - name: Check Documentation
run: pnpm nx documentation run: pnpm nx documentation

View File

@ -64,13 +64,18 @@ launch-templates:
- name: Load Cargo Env - name: Load Cargo Env
script: echo "PATH=$HOME/.cargo/bin:$PATH" >> $NX_CLOUD_ENV script: echo "PATH=$HOME/.cargo/bin:$PATH" >> $NX_CLOUD_ENV
- name: Setup Java 21 - name: Setup Java 17
script: | script: |
sudo apt update sudo apt update
sudo apt install -y openjdk-21-jdk sudo apt install -y openjdk-17-jdk
sudo update-alternatives --set java /usr/lib/jvm/java-21-openjdk-amd64/bin/java sudo update-alternatives --set java /usr/lib/jvm/java-17-openjdk-amd64/bin/java
java -version java -version
- name: Setup Gradle
script: |
./gradlew wrapper
./gradlew --version
linux-extra-large: linux-extra-large:
resource-class: 'docker_linux_amd64/extra_large' resource-class: 'docker_linux_amd64/extra_large'
image: 'ubuntu22.04-node20.11-v10' image: 'ubuntu22.04-node20.11-v10'

View File

@ -1,5 +1,5 @@
plugins { plugins {
id("dev.nx.gradle.project-graph") version("0.0.2") id("dev.nx.gradle.project-graph") version("0.1.0")
id("com.ncorti.ktfmt.gradle") version("+") id("com.ncorti.ktfmt.gradle") version("+")
} }

View File

@ -8387,6 +8387,23 @@
"isExternal": false, "isExternal": false,
"disableCollapsible": false "disableCollapsible": false
}, },
{
"id": "executors",
"path": "/nx-api/gradle/executors",
"name": "executors",
"children": [
{
"id": "gradle",
"path": "/nx-api/gradle/executors/gradle",
"name": "gradle",
"children": [],
"isExternal": false,
"disableCollapsible": false
}
],
"isExternal": false,
"disableCollapsible": false
},
{ {
"id": "generators", "id": "generators",
"path": "/nx-api/gradle/generators", "path": "/nx-api/gradle/generators",

View File

@ -2103,7 +2103,17 @@
}, },
"root": "/packages/gradle", "root": "/packages/gradle",
"source": "/packages/gradle/src", "source": "/packages/gradle/src",
"executors": {}, "executors": {
"/nx-api/gradle/executors/gradle": {
"description": "The Gradlew executor is used to run Gradle tasks.",
"file": "generated/packages/gradle/executors/gradle.json",
"hidden": false,
"name": "gradle",
"originalFilePath": "/packages/gradle/src/executors/gradle/schema.json",
"path": "/nx-api/gradle/executors/gradle",
"type": "executor"
}
},
"generators": { "generators": {
"/nx-api/gradle/generators/init": { "/nx-api/gradle/generators/init": {
"description": "Initializes a Gradle project in the current workspace", "description": "Initializes a Gradle project in the current workspace",

View File

@ -2087,7 +2087,17 @@
"originalFilePath": "shared/packages/gradle/gradle-plugin" "originalFilePath": "shared/packages/gradle/gradle-plugin"
} }
], ],
"executors": [], "executors": [
{
"description": "The Gradlew executor is used to run Gradle tasks.",
"file": "generated/packages/gradle/executors/gradle.json",
"hidden": false,
"name": "gradle",
"originalFilePath": "/packages/gradle/src/executors/gradle/schema.json",
"path": "gradle/executors/gradle",
"type": "executor"
}
],
"generators": [ "generators": [
{ {
"description": "Initializes a Gradle project in the current workspace", "description": "Initializes a Gradle project in the current workspace",

View File

@ -0,0 +1,37 @@
{
"name": "gradle",
"batchImplementation": "./src/executors/gradle/gradle-batch.impl",
"implementation": "/packages/gradle/src/executors/gradle/gradle.impl.ts",
"schema": {
"$schema": "https://json-schema.org/schema",
"version": 2,
"title": "Gradle Impl executor",
"description": "The Gradle Impl executor is used to run Gradle tasks.",
"type": "object",
"properties": {
"taskName": {
"type": "string",
"description": "The name of the Gradle task to run."
},
"testClassName": {
"type": "string",
"description": "The test class name to run for test task."
},
"args": {
"oneOf": [
{ "type": "array", "items": { "type": "string" } },
{ "type": "string" }
],
"description": "The arguments to pass to the Gradle task.",
"examples": [["--warning-mode", "all"], "--stracktrace"]
}
},
"required": ["taskName"],
"presets": []
},
"description": "The Gradlew executor is used to run Gradle tasks.",
"aliases": [],
"hidden": false,
"path": "/packages/gradle/src/executors/gradle/schema.json",
"type": "executor"
}

View File

@ -485,6 +485,8 @@
- [gradle](/nx-api/gradle) - [gradle](/nx-api/gradle)
- [documents](/nx-api/gradle/documents) - [documents](/nx-api/gradle/documents)
- [Overview](/nx-api/gradle/documents/overview) - [Overview](/nx-api/gradle/documents/overview)
- [executors](/nx-api/gradle/executors)
- [gradle](/nx-api/gradle/executors/gradle)
- [generators](/nx-api/gradle/generators) - [generators](/nx-api/gradle/generators)
- [init](/nx-api/gradle/generators/init) - [init](/nx-api/gradle/generators/init)
- [ci-workflow](/nx-api/gradle/generators/ci-workflow) - [ci-workflow](/nx-api/gradle/generators/ci-workflow)

View File

@ -31,9 +31,7 @@ describe('Gradle', () => {
expect(projects).toContain(gradleProjectName); expect(projects).toContain(gradleProjectName);
const buildOutput = runCLI('build app', { verbose: true }); const buildOutput = runCLI('build app', { verbose: true });
expect(buildOutput).toContain('nx run list:');
expect(buildOutput).toContain(':list:classes'); expect(buildOutput).toContain(':list:classes');
expect(buildOutput).toContain('nx run utilities:');
expect(buildOutput).toContain(':utilities:classes'); expect(buildOutput).toContain(':utilities:classes');
checkFilesExist( checkFilesExist(
@ -83,11 +81,8 @@ dependencies {
let buildOutput = runCLI('build app2', { verbose: true }); let buildOutput = runCLI('build app2', { verbose: true });
// app2 depends on app // app2 depends on app
expect(buildOutput).toContain('nx run app:');
expect(buildOutput).toContain(':app:classes'); expect(buildOutput).toContain(':app:classes');
expect(buildOutput).toContain('nx run list:');
expect(buildOutput).toContain(':list:classes'); expect(buildOutput).toContain(':list:classes');
expect(buildOutput).toContain('nx run utilities:');
expect(buildOutput).toContain(':utilities:classes'); expect(buildOutput).toContain(':utilities:classes');
checkFilesExist(`app2/build/libs/app2.jar`); checkFilesExist(`app2/build/libs/app2.jar`);
@ -96,7 +91,7 @@ dependencies {
it('should run atomized test target', () => { it('should run atomized test target', () => {
updateJson('nx.json', (json) => { updateJson('nx.json', (json) => {
json.plugins.find((p) => p.plugin === '@nx/gradle').options[ json.plugins.find((p) => p.plugin === '@nx/gradle').options[
'ciTargetName' 'ciTestTargetName'
] = 'test-ci'; ] = 'test-ci';
return json; return json;
}); });

View File

@ -0,0 +1,3 @@
# Ignore Gradle project-specific cache directory
bin
build

View File

@ -0,0 +1,33 @@
group = "dev.nx.gradle"
plugins {
// Apply the org.jetbrains.kotlin.jvm Plugin to add support for Kotlin.
id("org.jetbrains.kotlin.jvm") version "2.1.10"
// Apply the application plugin to add support for building a CLI application in Java.
application
id("com.github.johnrengelman.shadow") version "8.1.1"
id("com.ncorti.ktfmt.gradle") version "+"
id("dev.nx.gradle.project-graph") version "0.1.0"
}
repositories {
// Use Maven Central for resolving dependencies.
mavenCentral()
// need for gradle-tooling-api
maven { url = uri("https://repo.gradle.org/gradle/libs-releases/") }
}
dependencies {
val toolingApiVersion = "8.13" // Match the Gradle version you're working with
implementation("org.gradle:gradle-tooling-api:$toolingApiVersion")
runtimeOnly("org.slf4j:slf4j-simple:1.7.10")
implementation("com.google.code.gson:gson:2.10.1")
}
application {
// Define the main class for the application.
mainClass.set("dev.nx.gradle.NxBatchRunnerKt")
}
kotlin { jvmToolchain { languageVersion.set(JavaLanguageVersion.of(17)) } }

View File

@ -0,0 +1,33 @@
{
"name": "gradle-batch-runner",
"$schema": "node_modules/nx/schemas/project-schema.json",
"projectRoot": "packages/gradle/batch-runner",
"sourceRoot": "packages/gradle/batch-runner/src",
"targets": {
"assemble": {
"command": "./gradlew :batch-runner:assemble",
"inputs": [
"{projectRoot}/src/**",
"{projectRoot}/build.gradle.kts",
"{projectRoot}/settings.gradle.kts"
],
"outputs": ["{projectRoot}/build"],
"cache": true
},
"test": {
"command": "./gradlew :batch-runner:test",
"options": {
"args": []
},
"cache": true
},
"lint": {
"command": "./gradlew :batch-runner:ktfmtCheck",
"cache": true
},
"format": {
"command": "./gradlew :batch-runner:ktfmtFormat",
"cache": true
}
}
}

View File

@ -0,0 +1,17 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
* For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.5/userguide/building_swift_projects.html in the Gradle documentation.
* This project uses @Incubating APIs which are subject to change.
*/
pluginManagement {
repositories {
mavenLocal()
mavenCentral()
gradlePluginPortal()
}
}
rootProject.name = "batch-runner"

View File

@ -0,0 +1,51 @@
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.logger
import java.io.File
import kotlin.system.exitProcess
import org.gradle.tooling.GradleConnector
import org.gradle.tooling.ProjectConnection
fun main(args: Array<String>) {
val options = parseArgs(args)
configureLogger(options.quiet)
if (options.workspaceRoot.isBlank()) {
logger.severe("❌ Missing required arguments --workspaceRoot")
exitProcess(1)
}
if (options.tasks.isEmpty()) {
logger.severe("❌ Missing required arguments --tasks")
exitProcess(1)
}
var connection: ProjectConnection? = null
try {
connection =
GradleConnector.newConnector().forProjectDirectory(File(options.workspaceRoot)).connect()
val results = runTasksInParallel(connection, options.tasks, options.args)
val reportJson = Gson().toJson(results)
println(reportJson)
val summary = results.values.groupBy { it.success }
logger.info(
"📊 Summary: ✅ ${summary[true]?.size ?: 0} succeeded, ❌ ${summary[false]?.size ?: 0} failed")
} catch (e: Exception) {
logger.severe("💥 Failed to run tasks: ${e.message}")
exitProcess(1)
} finally {
try {
connection?.close()
logger.info("✅ Gradle connection closed.")
} catch (e: Exception) {
logger.warning("⚠️ Failed to close Gradle connection cleanly: ${e.message}")
}
}
}

View File

@ -0,0 +1,47 @@
package dev.nx.gradle.cli
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import dev.nx.gradle.data.GradleTask
import dev.nx.gradle.data.NxBatchOptions
import dev.nx.gradle.util.logger
fun parseArgs(args: Array<String>): NxBatchOptions {
val argMap = mutableMapOf<String, String>()
args.forEach {
when {
it.startsWith("--") && it.contains("=") -> {
val (key, value) = it.split("=", limit = 2)
argMap[key] = value
}
it.startsWith("--") -> {
argMap[it] = "true"
}
}
}
val gson = Gson()
val tasksJson = argMap["--tasks"]
val tasksMap: Map<String, GradleTask> =
if (tasksJson != null) {
val taskType = object : TypeToken<Map<String, GradleTask>>() {}.type
gson.fromJson(tasksJson, taskType)
} else emptyMap()
return NxBatchOptions(
workspaceRoot = argMap["--workspaceRoot"] ?: "",
tasks = tasksMap,
args = argMap["--args"] ?: "",
quiet = argMap["--quiet"]?.toBoolean() ?: false)
}
fun configureLogger(quiet: Boolean) {
if (quiet) {
logger.setLevel(java.util.logging.Level.OFF)
logger.useParentHandlers = false
logger.handlers.forEach { it.level = java.util.logging.Level.OFF }
} else {
logger.setLevel(java.util.logging.Level.INFO)
}
}

View File

@ -0,0 +1,3 @@
package dev.nx.gradle.data
data class GradleTask(val taskName: String, val testClassName: String? = null)

View File

@ -0,0 +1,8 @@
package dev.nx.gradle.data
data class NxBatchOptions(
val workspaceRoot: String,
val tasks: Map<String, GradleTask>,
val args: String,
val quiet: Boolean
)

View File

@ -0,0 +1,8 @@
package dev.nx.gradle.data
data class TaskResult(
val success: Boolean,
val startTime: Long,
val endTime: Long,
var terminalOutput: String
)

View File

@ -0,0 +1,53 @@
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
import org.gradle.tooling.events.task.TaskStartEvent
import org.gradle.tooling.events.task.TaskSuccessResult
fun buildListener(
tasks: Map<String, GradleTask>,
taskStartTimes: MutableMap<String, Long>,
taskResults: MutableMap<String, TaskResult>
): (ProgressEvent) -> Unit = { event ->
when (event) {
is TaskStartEvent -> {
tasks.entries
.find { it.value.taskName == event.descriptor.taskPath }
?.key
?.let { nxTaskId ->
taskStartTimes[nxTaskId] = min(System.currentTimeMillis(), event.eventTime)
}
}
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
}
tasks.entries
.find { it.value.taskName == taskPath }
?.key
?.let { nxTaskId ->
val endTime = max(System.currentTimeMillis(), event.eventTime)
val startTime = taskStartTimes[nxTaskId] ?: event.result.startTime
taskResults[nxTaskId] = TaskResult(success, startTime, endTime, "")
}
}
}
}

View File

@ -0,0 +1,175 @@
package dev.nx.gradle.runner
import dev.nx.gradle.data.GradleTask
import dev.nx.gradle.data.TaskResult
import dev.nx.gradle.runner.OutputProcessor.buildTerminalOutput
import dev.nx.gradle.runner.OutputProcessor.splitOutputPerTask
import dev.nx.gradle.util.logger
import java.io.ByteArrayOutputStream
import org.gradle.tooling.ProjectConnection
import org.gradle.tooling.events.OperationType
fun runTasksInParallel(
connection: ProjectConnection,
tasks: Map<String, GradleTask>,
additionalArgs: String,
): Map<String, TaskResult> {
logger.info("▶️ Running all tasks in a single Gradle run: ${tasks.keys.joinToString(", ")}")
val (testClassTasks, buildTasks) = tasks.entries.partition { it.value.testClassName != null }
logger.info("🧪 Test launcher tasks: ${testClassTasks.joinToString(", ") { it.key }}")
logger.info("🛠️ Build launcher tasks: ${buildTasks.joinToString(", ") { it.key }}")
val allResults = mutableMapOf<String, TaskResult>()
val outputStream = ByteArrayOutputStream()
val errorStream = ByteArrayOutputStream()
val args = buildList {
addAll(listOf("--info", "--continue", "--parallel", "--build-cache"))
addAll(additionalArgs.split(" ").filter { it.isNotBlank() })
}
val taskNames = tasks.values.map { it.taskName }.distinct()
if (buildTasks.isNotEmpty()) {
allResults.putAll(
runBuildLauncher(
connection,
buildTasks.associate { it.key to it.value },
taskNames,
args,
outputStream,
errorStream))
}
if (testClassTasks.isNotEmpty()) {
allResults.putAll(
runTestLauncher(
connection,
testClassTasks.associate { it.key to it.value },
taskNames,
args,
outputStream,
errorStream))
}
return allResults
}
fun runBuildLauncher(
connection: ProjectConnection,
tasks: Map<String, GradleTask>,
taskNames: List<String>,
args: List<String>,
outputStream: ByteArrayOutputStream,
errorStream: ByteArrayOutputStream
): Map<String, TaskResult> {
val taskStartTimes = mutableMapOf<String, Long>()
val taskResults = mutableMapOf<String, TaskResult>()
var globalOutput: String
try {
connection
.newBuild()
.apply {
forTasks(*taskNames.toTypedArray())
withArguments(*args.toTypedArray())
setStandardOutput(outputStream)
setStandardError(errorStream)
addProgressListener(buildListener(tasks, taskStartTimes, taskResults), OperationType.TASK)
}
.run()
globalOutput = buildTerminalOutput(outputStream, errorStream)
} catch (e: Exception) {
globalOutput =
buildTerminalOutput(outputStream, errorStream) + "\nException occurred: ${e.message}"
logger.warning("\ud83d\udca5 Gradle run failed: ${e.message}")
} finally {
outputStream.close()
errorStream.close()
}
val perTaskOutput = splitOutputPerTask(globalOutput)
tasks.forEach { (taskId, taskConfig) ->
val taskOutput = perTaskOutput[taskConfig.taskName] ?: globalOutput
taskResults[taskId]?.let { taskResults[taskId] = it.copy(terminalOutput = taskOutput) }
}
logger.info("\u2705 Finished build tasks")
return taskResults
}
fun runTestLauncher(
connection: ProjectConnection,
tasks: Map<String, GradleTask>,
taskNames: List<String>,
args: List<String>,
outputStream: ByteArrayOutputStream,
errorStream: ByteArrayOutputStream
): Map<String, TaskResult> {
val taskStartTimes = mutableMapOf<String, Long>()
val taskResults = mutableMapOf<String, TaskResult>()
val testTaskStatus = mutableMapOf<String, Boolean>()
val testStartTimes = mutableMapOf<String, Long>()
val testEndTimes = mutableMapOf<String, Long>()
tasks.forEach { (nxTaskId, taskConfig) ->
if (taskConfig.testClassName != null) {
testTaskStatus[nxTaskId] = true
}
}
val globalStart = System.currentTimeMillis()
var globalOutput: String
try {
connection
.newTestLauncher()
.apply {
forTasks(*taskNames.toTypedArray())
tasks.values.mapNotNull { it.testClassName }.forEach { withJvmTestClasses(it) }
withArguments(*args.toTypedArray())
setStandardOutput(outputStream)
setStandardError(errorStream)
addProgressListener(
testListener(
tasks, taskStartTimes, taskResults, testTaskStatus, testStartTimes, testEndTimes),
OperationType.TEST)
}
.run()
globalOutput = buildTerminalOutput(outputStream, errorStream)
} catch (e: Exception) {
globalOutput =
buildTerminalOutput(outputStream, errorStream) + "\nException occurred: ${e.message}"
logger.warning("\ud83d\udca5 Gradle test run failed: ${e.message}")
} finally {
outputStream.close()
errorStream.close()
}
val globalEnd = System.currentTimeMillis()
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, "")
}
}
}
val perTaskOutput = splitOutputPerTask(globalOutput)
tasks.forEach { (taskId, taskConfig) ->
val taskOutput = perTaskOutput[taskConfig.taskName] ?: globalOutput
taskResults[taskId]?.let { taskResults[taskId] = it.copy(terminalOutput = taskOutput) }
}
logger.info("\u2705 Finished test tasks")
return taskResults
}

View File

@ -0,0 +1,33 @@
package dev.nx.gradle.runner
import java.io.ByteArrayOutputStream
object OutputProcessor {
fun buildTerminalOutput(stdOut: ByteArrayOutputStream, stdErr: ByteArrayOutputStream): String {
val output = stdOut.toString("UTF-8")
val errorOutput = stdErr.toString("UTF-8")
return buildString {
if (output.isNotBlank()) append(output).append("\n")
if (errorOutput.isNotBlank()) append(errorOutput)
}
}
fun splitOutputPerTask(globalOutput: String): Map<String, String> {
val unescapedOutput = globalOutput.replace("\\u003e", ">").replace("\\n", "\n")
val taskHeaderRegex = Regex("(?=> Task (:[^\\s]+))")
val sections = unescapedOutput.split(taskHeaderRegex)
val taskOutputMap = mutableMapOf<String, String>()
for (section in sections) {
val lines = section.trim().lines()
if (lines.isEmpty()) continue
val header = lines.firstOrNull { it.startsWith("> Task ") }
if (header != null) {
val taskMatch = Regex("> Task (:[^\\s]+)").find(header)
val taskName = taskMatch?.groupValues?.get(1) ?: continue
taskOutputMap[taskName] = section.trim()
}
}
return taskOutputMap
}
}

View File

@ -0,0 +1,59 @@
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.TaskFinishEvent
import org.gradle.tooling.events.task.TaskStartEvent
import org.gradle.tooling.events.test.*
fun testListener(
tasks: Map<String, GradleTask>,
taskStartTimes: MutableMap<String, Long>,
taskResults: MutableMap<String, TaskResult>,
testTaskStatus: MutableMap<String, Boolean>,
testStartTimes: MutableMap<String, Long>,
testEndTimes: MutableMap<String, Long>
): (ProgressEvent) -> Unit = { event ->
when (event) {
is TaskStartEvent,
is TaskFinishEvent -> buildListener(tasks, taskStartTimes, taskResults)(event)
is TestStartEvent -> {
(event.descriptor as? JvmTestOperationDescriptor)?.className?.let { className ->
tasks.entries
.find { entry -> entry.value.testClassName?.let { className.endsWith(it) } ?: false }
?.key
?.let { nxTaskId ->
testStartTimes.compute(nxTaskId) { _, old ->
min(old ?: event.eventTime, event.eventTime)
}
}
}
}
is TestFinishEvent -> {
(event.descriptor as? JvmTestOperationDescriptor)?.className?.let { className ->
tasks.entries
.find { entry -> entry.value.testClassName?.let { className.endsWith(it) } ?: false }
?.key
?.let { nxTaskId ->
testEndTimes.compute(nxTaskId) { _, old ->
max(old ?: event.eventTime, event.eventTime)
}
when (event.result) {
is TestSuccessResult -> logger.info("\u2705 Test passed: $nxTaskId $className")
is TestFailureResult -> {
testTaskStatus[nxTaskId] = false
logger.warning("\u274C Test failed: $nxTaskId $className")
}
is TestSkippedResult ->
logger.warning("\u26A0\uFE0F Test skipped: $nxTaskId $className")
else -> logger.warning("\u26A0\uFE0F Unknown test result: $nxTaskId $className")
}
}
}
}
}
}

View File

@ -0,0 +1,5 @@
package dev.nx.gradle.util
import java.util.logging.Logger
val logger: Logger = Logger.getLogger("NxBatchRunner")

View File

@ -1,3 +1,10 @@
{ {
"executors": {} "executors": {
"gradle": {
"batchImplementation": "./src/executors/gradle/gradle-batch.impl",
"implementation": "./src/executors/gradle/gradle.impl",
"schema": "./src/executors/gradle/schema.json",
"description": "The Gradlew executor is used to run Gradle tasks."
}
}
} }

View File

@ -3,14 +3,14 @@ plugins {
`maven-publish` `maven-publish`
signing signing
id("com.ncorti.ktfmt.gradle") version "+" id("com.ncorti.ktfmt.gradle") version "+"
id("dev.nx.gradle.project-graph") version "0.0.2" id("dev.nx.gradle.project-graph") version "0.1.0"
id("org.jetbrains.kotlin.jvm") version "2.1.10" id("org.jetbrains.kotlin.jvm") version "2.1.10"
id("com.gradle.plugin-publish") version "1.2.1" id("com.gradle.plugin-publish") version "1.2.1"
} }
group = "dev.nx.gradle" group = "dev.nx.gradle"
version = "0.0.2" version = "0.1.0"
repositories { mavenCentral() } repositories { mavenCentral() }
@ -118,3 +118,5 @@ signing {
} }
tasks.test { useJUnitPlatform() } tasks.test { useJUnitPlatform() }
java { toolchain.languageVersion.set(JavaLanguageVersion.of(17)) }

View File

@ -1,90 +1,86 @@
package dev.nx.gradle.utils package dev.nx.gradle.utils
import dev.nx.gradle.data.* import dev.nx.gradle.data.NxTargets
import dev.nx.gradle.data.TargetGroups
import java.io.File import java.io.File
import org.gradle.api.Task import org.gradle.api.Task
import org.gradle.api.file.FileCollection import org.gradle.api.file.FileCollection
const val testCiTargetGroup = "verification" const val testCiTargetGroup = "verification"
/** private val testFileNameRegex =
* Add atomized ci test targets Going to loop through each test files and create a target for each Regex("^(?!(abstract|fake)).*?(Test)(s)?\\d*", RegexOption.IGNORE_CASE)
* It is going to modify targets and targetGroups in place
*/ private val classDeclarationRegex = Regex("""class\s+([A-Za-z_][A-Za-z0-9_]*)""")
fun addTestCiTargets( fun addTestCiTargets(
testFiles: FileCollection, testFiles: FileCollection,
projectBuildPath: String, projectBuildPath: String,
testTask: Task, testTask: Task,
testTargetName: String,
targets: NxTargets, targets: NxTargets,
targetGroups: TargetGroups, targetGroups: TargetGroups,
projectRoot: String, projectRoot: String,
workspaceRoot: String, workspaceRoot: String,
ciTargetName: String ciTestTargetName: String
) { ) {
ensureTargetGroupExists(targetGroups, testCiTargetGroup) ensureTargetGroupExists(targetGroups, testCiTargetGroup)
val gradlewCommand = getGradlewCommand()
val ciDependsOn = mutableListOf<Map<String, String>>() val ciDependsOn = mutableListOf<Map<String, String>>()
val filteredTestFiles = testFiles.filter { isTestFile(it, workspaceRoot) } testFiles
.filter { isTestFile(it, workspaceRoot) }
.forEach { testFile ->
val className = getTestClassNameIfAnnotated(testFile) ?: return@forEach
filteredTestFiles.forEach { testFile -> val targetName = "$ciTestTargetName--$className"
val className = getTestClassNameIfAnnotated(testFile) ?: return@forEach targets[targetName] =
buildTestCiTarget(
projectBuildPath, className, testFile, testTask, projectRoot, workspaceRoot)
targetGroups[testCiTargetGroup]?.add(targetName)
val testCiTarget = ciDependsOn.add(mapOf("target" to targetName, "projects" to "self", "params" to "forward"))
buildTestCiTarget( }
gradlewCommand = gradlewCommand,
projectBuildPath = projectBuildPath,
testClassName = className,
testFile = testFile,
testTask = testTask,
projectRoot = projectRoot,
workspaceRoot = workspaceRoot)
val targetName = "$ciTargetName--$className" testTask.logger.info("${testTask.path} generated CI targets: ${ciDependsOn.map { it["target"] }}")
targets[targetName] = testCiTarget
targetGroups[testCiTargetGroup]?.add(targetName)
ciDependsOn.add(mapOf("target" to targetName, "projects" to "self", "params" to "forward"))
}
testTask.logger.info("$testTask ci tasks: $ciDependsOn")
if (ciDependsOn.isNotEmpty()) { if (ciDependsOn.isNotEmpty()) {
ensureParentCiTarget( ensureParentCiTarget(
targets = targets, targets,
targetGroups = targetGroups, targetGroups,
ciTargetName = ciTargetName, ciTestTargetName,
projectBuildPath = projectBuildPath, projectBuildPath,
dependsOn = ciDependsOn) testTask,
testTargetName,
ciDependsOn)
} }
} }
private fun getTestClassNameIfAnnotated(file: File): String? { private fun getTestClassNameIfAnnotated(file: File): String? {
if (!file.exists()) return null return file
.takeIf { it.exists() }
val content = file.readText() ?.readText()
if (!content.contains("@Test")) return null ?.takeIf { it.contains("@Test") || it.contains("@TestTemplate") }
?.let { content ->
val classRegex = Regex("""class\s+([A-Za-z_][A-Za-z0-9_]*)""") val className = classDeclarationRegex.find(content)?.groupValues?.getOrNull(1)
val match = classRegex.find(content) return if (className != null && !className.startsWith("Fake")) {
return match?.groupValues?.get(1) className
} else {
null
}
}
} }
fun ensureTargetGroupExists(targetGroups: TargetGroups, group: String) { fun ensureTargetGroupExists(targetGroups: TargetGroups, group: String) {
if (!targetGroups.containsKey(group)) { targetGroups.getOrPut(group) { mutableListOf() }
targetGroups[group] = mutableListOf()
}
} }
private fun isTestFile(file: File, workspaceRoot: String): Boolean { private fun isTestFile(file: File, workspaceRoot: String): Boolean {
val fileName = file.name.substringBefore(".") val fileName = file.name.substringBefore(".")
val regex = "^(?!abstract).*?(Test)(s)?\\d*".toRegex(RegexOption.IGNORE_CASE) return file.path.startsWith(workspaceRoot) && testFileNameRegex.matches(fileName)
return file.path.startsWith(workspaceRoot) && regex.matches(fileName)
} }
private fun buildTestCiTarget( private fun buildTestCiTarget(
gradlewCommand: String,
projectBuildPath: String, projectBuildPath: String,
testClassName: String, testClassName: String,
testFile: File, testFile: File,
@ -94,7 +90,11 @@ private fun buildTestCiTarget(
): MutableMap<String, Any?> { ): MutableMap<String, Any?> {
val target = val target =
mutableMapOf<String, Any?>( mutableMapOf<String, Any?>(
"command" to "$gradlewCommand ${projectBuildPath}:test --tests $testClassName", "executor" to "@nx/gradle:gradle",
"options" to
mapOf(
"taskName" to "${projectBuildPath}:${testTask.name}",
"testClassName" to testClassName),
"metadata" to "metadata" to
getMetadata("Runs Gradle test $testClassName in CI", projectBuildPath, "test"), getMetadata("Runs Gradle test $testClassName in CI", projectBuildPath, "test"),
"cache" to true, "cache" to true,
@ -103,14 +103,14 @@ private fun buildTestCiTarget(
getDependsOnForTask(testTask, null) getDependsOnForTask(testTask, null)
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
?.let { ?.let {
testTask.logger.info("$testTask: processed ${it.size} dependsOn") testTask.logger.info("${testTask.path}: found ${it.size} dependsOn entries")
target["dependsOn"] = it target["dependsOn"] = it
} }
getOutputsForTask(testTask, projectRoot, workspaceRoot) getOutputsForTask(testTask, projectRoot, workspaceRoot)
?.takeIf { it.isNotEmpty() } ?.takeIf { it.isNotEmpty() }
?.let { ?.let {
testTask.logger.info("$testTask: processed ${it.size} outputs") testTask.logger.info("${testTask.path}: found ${it.size} outputs entries")
target["outputs"] = it target["outputs"] = it
} }
@ -120,24 +120,31 @@ private fun buildTestCiTarget(
private fun ensureParentCiTarget( private fun ensureParentCiTarget(
targets: NxTargets, targets: NxTargets,
targetGroups: TargetGroups, targetGroups: TargetGroups,
ciTargetName: String, ciTestTargetName: String,
projectBuildPath: String, projectBuildPath: String,
testTask: Task,
testTargetName: String,
dependsOn: List<Map<String, String>> dependsOn: List<Map<String, String>>
) { ) {
val ciTarget = val ciTarget =
targets.getOrPut(ciTargetName) { targets.getOrPut(ciTestTargetName) {
mutableMapOf<String, Any?>().apply { mutableMapOf<String, Any?>(
put("executor", "nx:noop") "executor" to "nx:noop",
put("metadata", getMetadata("Runs Gradle Tests in CI", projectBuildPath, "test", "test")) "metadata" to
put("dependsOn", mutableListOf<Map<String, String>>()) getMetadata(
put("cache", true) "Runs Gradle ${testTask.name} in CI",
} projectBuildPath,
testTask.name,
testTargetName),
"dependsOn" to mutableListOf<Any?>(),
"cache" to true)
} }
val dependsOnList = ciTarget.getOrPut("dependsOn") { mutableListOf<Any?>() } as MutableList<Any?> @Suppress("UNCHECKED_CAST")
val dependsOnList = ciTarget["dependsOn"] as? MutableList<Any?> ?: mutableListOf()
dependsOnList.addAll(dependsOn) dependsOnList.addAll(dependsOn)
if (targetGroups[testCiTargetGroup]?.contains(ciTargetName) != true) { if (!targetGroups[testCiTargetGroup].orEmpty().contains(ciTestTargetName)) {
targetGroups[testCiTargetGroup]?.add(ciTargetName) targetGroups[testCiTargetGroup]?.add(ciTestTargetName)
} }
} }

View File

@ -63,41 +63,41 @@ fun processTargetsForProject(
workspaceRoot: String, workspaceRoot: String,
cwd: String cwd: String
): GradleTargets { ): GradleTargets {
val targets: NxTargets = mutableMapOf<String, MutableMap<String, Any?>>() val targets: NxTargets = mutableMapOf()
val targetGroups: TargetGroups = mutableMapOf<String, MutableList<String>>() val targetGroups: TargetGroups = mutableMapOf()
val externalNodes = mutableMapOf<String, ExternalNode>() val externalNodes = mutableMapOf<String, ExternalNode>()
val projectRoot = project.projectDir.path val projectRoot = project.projectDir.path
project.logger.info("Using workspace root $workspaceRoot")
var projectBuildPath: String =
project
.buildTreePath // get the build path of project e.g. :app, :utils:number-utils, :buildSrc
if (projectBuildPath.endsWith(":")) { // root project is ":", manually remove last :
projectBuildPath = projectBuildPath.dropLast(1)
}
val logger = project.logger val logger = project.logger
logger.info("${Date()} ${project}: process targets") logger.info("Using workspace root: $workspaceRoot")
var gradleProject = project.buildTreePath val projectBuildPath = project.buildTreePath.trimEnd(':')
if (!gradleProject.endsWith(":")) {
gradleProject += ":" logger.info("${Date()} ${project}: Process targets")
}
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")
val testTasks = project.getTasksByName("test", false)
val intTestTasks = project.getTasksByName("intTest", false)
val hasCiTestTarget = ciTestTargetName != null && testTasks.isNotEmpty()
val hasCiIntTestTarget = ciIntTestTargetName != null && intTestTasks.isNotEmpty()
project.tasks.forEach { task -> project.tasks.forEach { task ->
try { try {
logger.info("${Date()} ${project.name}: Processing $task") val now = Date()
val taskName = targetNameOverrides.getOrDefault(task.name + "TargetName", task.name) logger.info("$now ${project.name}: Processing task ${task.path}")
// add task to target groups
val group: String? = task.group val taskName = targetNameOverrides.getOrDefault("${task.name}TargetName", task.name)
if (!group.isNullOrBlank()) {
if (targetGroups.contains(group)) { // Group task under its group if available
targetGroups[group]?.add(task.name) task.group
} else { ?.takeIf { it.isNotBlank() }
targetGroups[group] = mutableListOf(task.name) ?.let { group -> targetGroups.getOrPut(group) { mutableListOf() }.add(taskName) }
}
}
val target = val target =
processTask( processTask(
@ -105,53 +105,63 @@ fun processTargetsForProject(
projectBuildPath, projectBuildPath,
projectRoot, projectRoot,
workspaceRoot, workspaceRoot,
cwd,
externalNodes, externalNodes,
dependencies, dependencies,
targetNameOverrides) targetNameOverrides)
targets[taskName] = target targets[taskName] = target
val ciTargetName = targetNameOverrides.getOrDefault("ciTargetName", null) if (hasCiTestTarget && task.name.startsWith("compileTest")) {
ciTargetName?.let { addTestCiTargets(
if (task.name.startsWith("compileTest")) { task.inputs.sourceFiles,
val testTask = project.getTasksByName("test", false) projectBuildPath,
if (testTask.isNotEmpty()) { testTasks.first(),
addTestCiTargets( testTargetName,
task.inputs.sourceFiles, targets,
projectBuildPath, targetGroups,
testTask.first(), projectRoot,
targets, workspaceRoot,
targetGroups, ciTestTargetName!!)
projectRoot,
workspaceRoot,
it)
}
}
// Add the `$ciTargetName-check` target when processing the "check" task
if (task.name == "check") {
val replacedDependencies =
(target["dependsOn"] as? List<*>)?.map { dep ->
if (dep.toString() == targetNameOverrides.getOrDefault("testTargetName", "test"))
ciTargetName
else dep.toString()
} ?: emptyList()
// Copy the original target and override "dependsOn"
val newTarget = target.toMutableMap()
newTarget["dependsOn"] = replacedDependencies
val ciCheckTargetName = "$ciTargetName-check"
targets[ciCheckTargetName] = newTarget
ensureTargetGroupExists(targetGroups, testCiTargetGroup)
targetGroups[testCiTargetGroup]?.add(ciCheckTargetName)
}
} }
logger.info("${Date()} ${project.name}: Processed $task")
if (hasCiIntTestTarget && task.name.startsWith("compileIntTest")) {
addTestCiTargets(
task.inputs.sourceFiles,
projectBuildPath,
intTestTasks.first(),
intTestTargetName,
targets,
targetGroups,
projectRoot,
workspaceRoot,
ciIntTestTargetName!!)
}
if (task.name == "check" && (hasCiTestTarget || hasCiIntTestTarget)) {
val replacedDependencies =
(target["dependsOn"] as? List<*>)?.map { dep ->
when (dep.toString()) {
testTargetName -> ciTestTargetName ?: dep
intTestTargetName -> ciIntTestTargetName ?: dep
else -> dep
}.toString()
} ?: emptyList()
val newTarget: MutableMap<String, Any?> =
mutableMapOf(
"dependsOn" to replacedDependencies,
"executor" to "nx:noop",
"cache" to true,
"metadata" to getMetadata("Runs Gradle Check in CI", projectBuildPath, "check"))
targets[ciCheckTargetName] = newTarget
ensureTargetGroupExists(targetGroups, testCiTargetGroup)
targetGroups[testCiTargetGroup]?.add(ciCheckTargetName)
}
logger.info("$now ${project.name}: Processed task ${task.path}")
} catch (e: Exception) { } catch (e: Exception) {
logger.info("${task}: process task error $e") logger.error("Error processing task ${task.path}: ${e.message}", e)
logger.debug("Stack trace:", e)
} }
} }

View File

@ -1,10 +1,9 @@
package dev.nx.gradle.utils package dev.nx.gradle.utils
import dev.nx.gradle.data.* import dev.nx.gradle.data.Dependency
import org.gradle.api.Named import dev.nx.gradle.data.ExternalDepData
import org.gradle.api.NamedDomainObjectProvider import dev.nx.gradle.data.ExternalNode
import org.gradle.api.Task import org.gradle.api.Task
import org.gradle.api.tasks.TaskProvider
/** /**
* Process a task and convert it into target Going to populate: * Process a task and convert it into target Going to populate:
@ -13,14 +12,12 @@ import org.gradle.api.tasks.TaskProvider
* - outputs * - outputs
* - command * - command
* - metadata * - metadata
* - options with cwd and args
*/ */
fun processTask( fun processTask(
task: Task, task: Task,
projectBuildPath: String, projectBuildPath: String,
projectRoot: String, projectRoot: String,
workspaceRoot: String, workspaceRoot: String,
cwd: String,
externalNodes: MutableMap<String, ExternalNode>, externalNodes: MutableMap<String, ExternalNode>,
dependencies: MutableSet<Dependency>, dependencies: MutableSet<Dependency>,
targetNameOverrides: Map<String, String> targetNameOverrides: Map<String, String>
@ -51,13 +48,14 @@ fun processTask(
target["dependsOn"] = dependsOn target["dependsOn"] = dependsOn
} }
val gradlewCommand = getGradlewCommand() target["executor"] = "@nx/gradle:gradle"
target["command"] = "$gradlewCommand ${projectBuildPath}:${task.name}"
val metadata = getMetadata(task.description ?: "Run ${task.name}", projectBuildPath, task.name) val metadata =
getMetadata(
task.description ?: "Run ${projectBuildPath}.${task.name}", projectBuildPath, task.name)
target["metadata"] = metadata target["metadata"] = metadata
target["options"] = mapOf("cwd" to cwd) target["options"] = mapOf("taskName" to "${projectBuildPath}:${task.name}")
return target return target
} }
@ -190,54 +188,9 @@ fun getDependsOnForTask(
} }
return try { return try {
val dependsOnEntries = task.dependsOn // get depends on using taskDependencies.getDependencies(task) because task.dependsOn has
// missing deps
// Prefer task.dependsOn val dependsOn =
if (dependsOnEntries.isNotEmpty()) {
val resolvedTasks =
dependsOnEntries.flatMap { dep ->
when (dep) {
is Task -> listOf(dep)
is TaskProvider<*>,
is NamedDomainObjectProvider<*> -> {
val providerName = (dep as Named).name
val foundTask = task.project.tasks.findByName(providerName)
if (foundTask != null) {
listOf(foundTask)
} else {
task.logger.info(
"${dep::class.simpleName} '$providerName' did not resolve to a task in project ${task.project.name}")
emptyList()
}
}
is String -> {
val foundTask = task.project.tasks.findByPath(dep)
if (foundTask != null) {
listOf(foundTask)
} else {
task.logger.info(
"Task string '$dep' could not be resolved in project ${task.project.name}")
emptyList()
}
}
else -> {
task.logger.info(
"Unhandled dependency type ${dep::class.java} for task ${task.path}")
emptyList()
}
}
}
if (resolvedTasks.isNotEmpty()) {
return mapTasksToNames(resolvedTasks)
}
}
// Fallback: taskDependencies.getDependencies(task)
val fallbackDeps =
try { try {
task.taskDependencies.getDependencies(null) task.taskDependencies.getDependencies(null)
} catch (e: Exception) { } catch (e: Exception) {
@ -246,8 +199,8 @@ fun getDependsOnForTask(
emptySet<Task>() emptySet<Task>()
} }
if (fallbackDeps.isNotEmpty()) { if (dependsOn.isNotEmpty()) {
return mapTasksToNames(fallbackDeps) return mapTasksToNames(dependsOn)
} }
null null
@ -266,14 +219,15 @@ fun getDependsOnForTask(
fun getMetadata( fun getMetadata(
description: String?, description: String?,
projectBuildPath: String, projectBuildPath: String,
taskName: String, helpTaskName: String,
nonAtomizedTarget: String? = null nonAtomizedTarget: String? = null
): Map<String, Any?> { ): Map<String, Any?> {
val gradlewCommand = getGradlewCommand() val gradlewCommand = getGradlewCommand()
return mapOf( return mapOf(
"description" to description, "description" to description,
"technologies" to arrayOf("gradle"), "technologies" to arrayOf("gradle"),
"help" to mapOf("command" to "$gradlewCommand help --task ${projectBuildPath}:${taskName}"), "help" to
mapOf("command" to "$gradlewCommand help --task ${projectBuildPath}:${helpTaskName}"),
"nonAtomizedTarget" to nonAtomizedTarget) "nonAtomizedTarget" to nonAtomizedTarget)
} }

View File

@ -44,17 +44,18 @@ class AddTestCiTargetsTest {
val targets = mutableMapOf<String, MutableMap<String, Any?>>() val targets = mutableMapOf<String, MutableMap<String, Any?>>()
val targetGroups = mutableMapOf<String, MutableList<String>>() val targetGroups = mutableMapOf<String, MutableList<String>>()
val ciTargetName = "ci" val ciTestTargetName = "ci"
addTestCiTargets( addTestCiTargets(
testFiles = testFiles, testFiles = testFiles,
projectBuildPath = ":project-a", projectBuildPath = ":project-a",
testTask = testTask, testTask = testTask,
testTargetName = "test",
targets = targets, targets = targets,
targetGroups = targetGroups, targetGroups = targetGroups,
projectRoot = projectRoot.absolutePath, projectRoot = projectRoot.absolutePath,
workspaceRoot = workspaceRoot.absolutePath, workspaceRoot = workspaceRoot.absolutePath,
ciTargetName = ciTargetName) ciTestTargetName = ciTestTargetName)
// Assert each test file created a CI target // Assert each test file created a CI target
assertTrue(targets.containsKey("ci--MyFirstTest")) assertTrue(targets.containsKey("ci--MyFirstTest"))
@ -73,7 +74,7 @@ class AddTestCiTargetsTest {
assertEquals(2, dependsOn!!.size) assertEquals(2, dependsOn!!.size)
val firstTarget = targets["ci--MyFirstTest"]!! val firstTarget = targets["ci--MyFirstTest"]!!
assertTrue(firstTarget["command"].toString().contains("--tests MyFirstTest")) assertEquals(firstTarget["executor"], "@nx/gradle:gradle")
assertEquals(true, firstTarget["cache"]) assertEquals(true, firstTarget["cache"])
assertTrue((firstTarget["inputs"] as Array<*>)[0].toString().contains("{projectRoot}")) assertTrue((firstTarget["inputs"] as Array<*>)[0].toString().contains("{projectRoot}"))
assertEquals("nx:noop", parentCi["executor"]) assertEquals("nx:noop", parentCi["executor"])

View File

@ -45,7 +45,6 @@ class CreateNodeForProjectTest {
assertEquals(project.name, projectNode.name) assertEquals(project.name, projectNode.name)
assertNotNull(projectNode.targets["compileJava"], "Expected compileJava target") assertNotNull(projectNode.targets["compileJava"], "Expected compileJava target")
assertNotNull(projectNode.targets["test"], "Expected test target") assertNotNull(projectNode.targets["test"], "Expected test target")
assertEquals("build", projectNode.metadata.targetGroups.keys.firstOrNull())
// Dependencies and external nodes should default to empty // Dependencies and external nodes should default to empty
assertTrue(result.dependencies.isEmpty(), "Expected no dependencies") assertTrue(result.dependencies.isEmpty(), "Expected no dependencies")

View File

@ -83,13 +83,12 @@ class ProcessTaskUtilsTest {
projectBuildPath = ":project", projectBuildPath = ":project",
projectRoot = project.projectDir.path, projectRoot = project.projectDir.path,
workspaceRoot = project.rootDir.path, workspaceRoot = project.rootDir.path,
cwd = ".",
externalNodes = mutableMapOf(), externalNodes = mutableMapOf(),
dependencies = mutableSetOf(), dependencies = mutableSetOf(),
targetNameOverrides = emptyMap()) targetNameOverrides = emptyMap())
assertEquals(true, result["cache"]) assertEquals(true, result["cache"])
assertTrue((result["command"] as String).contains("gradlew")) assertEquals(result["executor"], "@nx/gradle:gradle")
assertNotNull(result["metadata"]) assertNotNull(result["metadata"])
assertNotNull(result["options"]) assertNotNull(result["options"])
} }

View File

@ -12,6 +12,11 @@
} }
}, },
"build-base": { "build-base": {
"dependsOn": [
"^build-base",
"build-native",
"gradle-batch-runner:assemble"
],
"executor": "@nx/js:tsc", "executor": "@nx/js:tsc",
"options": { "options": {
"assets": [ "assets": [
@ -42,6 +47,11 @@
"glob": "**/*.d.ts", "glob": "**/*.d.ts",
"output": "/" "output": "/"
}, },
{
"input": "packages/gradle/batch-runner",
"glob": "**/*.jar",
"output": "/batch-runner"
},
{ {
"input": "", "input": "",
"glob": "LICENSE", "glob": "LICENSE",

View File

@ -0,0 +1,103 @@
import { ExecutorContext, output, TaskGraph } from '@nx/devkit';
import {
LARGE_BUFFER,
RunCommandsOptions,
} from 'nx/src/executors/run-commands/run-commands.impl';
import { BatchResults } from 'nx/src/tasks-runner/batch/batch-messages';
import { gradleExecutorSchema } from './schema';
import { findGradlewFile } from '../../utils/exec-gradle';
import { dirname, join } from 'path';
import { execSync } from 'child_process';
export const batchRunnerPath = join(
__dirname,
'../../../batch-runner/build/libs/batch-runner-all.jar'
);
interface GradleTask {
taskName: string;
testClassName: string;
}
export default async function gradleBatch(
taskGraph: TaskGraph,
inputs: Record<string, gradleExecutorSchema>,
overrides: RunCommandsOptions,
context: ExecutorContext
): Promise<BatchResults> {
try {
const projectName = taskGraph.tasks[taskGraph.roots[0]]?.target?.project;
let projectRoot = context.projectGraph.nodes[projectName]?.data?.root ?? '';
const gradlewPath = findGradlewFile(join(projectRoot, 'project.json')); // find gradlew near project root
const root = join(context.root, dirname(gradlewPath));
// set args with passed in args and overrides in command line
const input = inputs[taskGraph.roots[0]];
let args =
typeof input.args === 'string'
? input.args.trim().split(' ')
: Array.isArray(input.args)
? input.args
: [];
if (overrides.__overrides_unparsed__.length) {
args.push(...overrides.__overrides_unparsed__);
}
const gradlewTasksToRun: Record<string, GradleTask> = Object.entries(
taskGraph.tasks
).reduce((gradlewTasksToRun, [taskId, task]) => {
const gradlewTaskName = inputs[task.id].taskName;
const testClassName = inputs[task.id].testClassName;
gradlewTasksToRun[taskId] = {
taskName: gradlewTaskName,
testClassName: testClassName,
};
return gradlewTasksToRun;
}, {});
const gradlewBatchStart = performance.mark(`gradlew-batch:start`);
const batchResults = execSync(
`java -jar ${batchRunnerPath} --tasks='${JSON.stringify(
gradlewTasksToRun
)}' --workspaceRoot=${root} --args='${args
.join(' ')
.replaceAll("'", '"')}' ${
process.env.NX_VERBOSE_LOGGING === 'true' ? '' : '--quiet'
}`,
{
windowsHide: true,
env: process.env,
maxBuffer: LARGE_BUFFER,
}
);
const gradlewBatchEnd = performance.mark(`gradlew-batch:end`);
performance.measure(
`gradlew-batch`,
gradlewBatchStart.name,
gradlewBatchEnd.name
);
const gradlewBatchResults = JSON.parse(
batchResults.toString()
) as BatchResults;
Object.keys(taskGraph.tasks).forEach((taskId) => {
if (!gradlewBatchResults[taskId]) {
gradlewBatchResults[taskId] = {
success: false,
terminalOutput: `Gradlew batch failed`,
};
}
});
return gradlewBatchResults;
} catch (e) {
output.error({
title: `Gradlew batch failed`,
bodyLines: [e.toString()],
});
return taskGraph.roots.reduce((acc, key) => {
acc[key] = { success: false, terminalOutput: e.toString() };
return acc;
}, {} as BatchResults);
}
}

View File

@ -0,0 +1,39 @@
import { ExecutorContext } from '@nx/devkit';
import { gradleExecutorSchema } from './schema';
import { findGradlewFile } from '../../utils/exec-gradle';
import { dirname, join } from 'node:path';
import runCommandsImpl from 'nx/src/executors/run-commands/run-commands.impl';
export default async function gradleExecutor(
options: gradleExecutorSchema,
context: ExecutorContext
): Promise<{ success: boolean }> {
let projectRoot =
context.projectGraph.nodes[context.projectName]?.data?.root ?? context.root;
let gradlewPath = findGradlewFile(join(projectRoot, 'project.json')); // find gradlew near project root
gradlewPath = join(context.root, gradlewPath);
let args =
typeof options.args === 'string'
? options.args.trim().split(' ')
: Array.isArray(options.args)
? options.args
: [];
if (options.testClassName) {
args.push(`--tests`, options.testClassName);
}
try {
await runCommandsImpl(
{
command: `${gradlewPath} ${options.taskName}`,
cwd: dirname(gradlewPath),
args: args,
__unparsed__: [],
},
context
);
return { success: true };
} catch (e) {
return { success: false };
}
}

View File

@ -0,0 +1,5 @@
export interface gradleExecutorSchema {
taskName: string;
testClassName?: string;
args?: string[] | string;
}

View File

@ -0,0 +1,33 @@
{
"$schema": "https://json-schema.org/schema",
"version": 2,
"title": "Gradle Impl executor",
"description": "The Gradle Impl executor is used to run Gradle tasks.",
"type": "object",
"properties": {
"taskName": {
"type": "string",
"description": "The name of the Gradle task to run."
},
"testClassName": {
"type": "string",
"description": "The test class name to run for test task."
},
"args": {
"oneOf": [
{
"type": "array",
"items": {
"type": "string"
}
},
{
"type": "string"
}
],
"description": "The arguments to pass to the Gradle task.",
"examples": [["--warning-mode", "all"], "--stracktrace"]
}
},
"required": ["taskName"]
}

View File

@ -1,7 +1,5 @@
import { AggregateCreateNodesError, logger, output } from '@nx/devkit'; import { AggregateCreateNodesError, logger, output } from '@nx/devkit';
import { execGradleAsync, newLineSeparator } from '../../utils/exec-gradle'; import { execGradleAsync, newLineSeparator } from '../../utils/exec-gradle';
import { existsSync } from 'fs';
import { dirname, join } from 'path';
/** /**
* This function executes the gradle projectReportAll task and returns the output as an array of lines. * This function executes the gradle projectReportAll task and returns the output as an array of lines.

View File

@ -84,7 +84,7 @@ exports[`@nx/gradle/plugin/nodes should create nodes based on gradle for nested
] ]
`; `;
exports[`@nx/gradle/plugin/nodes should create nodes with atomized tests targets based on gradle if ciTargetName is specified 1`] = ` exports[`@nx/gradle/plugin/nodes should create nodes with atomized tests targets based on gradle if ciTestTargetName is specified 1`] = `
[ [
[ [
"proj/application/build.gradle", "proj/application/build.gradle",
@ -466,7 +466,7 @@ exports[`@nx/gradle/plugin/nodes should create nodes with atomized tests targets
] ]
`; `;
exports[`@nx/gradle/plugin/nodes should not create nodes with atomized tests targets based on gradle if ciTargetName is not specified 1`] = ` exports[`@nx/gradle/plugin/nodes should not create nodes with atomized tests targets based on gradle if ciTestTargetName is not specified 1`] = `
[ [
[ [
"proj/application/build.gradle", "proj/application/build.gradle",

View File

@ -8,7 +8,7 @@ import {
validateDependency, validateDependency,
workspaceRoot, workspaceRoot,
} from '@nx/devkit'; } from '@nx/devkit';
import { relative } from 'node:path'; import { join, relative } from 'node:path';
import { import {
getCurrentProjectGraphReport, getCurrentProjectGraphReport,
@ -29,7 +29,11 @@ export const createDependencies: CreateDependencies<
Array.from(GRALDEW_FILES) Array.from(GRALDEW_FILES)
); );
const { gradlewFiles } = splitConfigFiles(files); const { gradlewFiles } = splitConfigFiles(files);
await populateProjectGraph(context.workspaceRoot, gradlewFiles, options); await populateProjectGraph(
context.workspaceRoot,
gradlewFiles.map((file) => join(workspaceRoot, file)),
options
);
const { dependencies: dependenciesFromReport } = const { dependencies: dependenciesFromReport } =
getCurrentProjectGraphReport(); getCurrentProjectGraphReport();

View File

@ -82,12 +82,12 @@ describe('@nx/gradle/plugin/nodes', () => {
expect(results).toMatchSnapshot(); expect(results).toMatchSnapshot();
}); });
it('should create nodes with atomized tests targets based on gradle if ciTargetName is specified', async () => { it('should create nodes with atomized tests targets based on gradle if ciTestTargetName is specified', async () => {
const results = await createNodesFunction( const results = await createNodesFunction(
['proj/application/build.gradle'], ['proj/application/build.gradle'],
{ {
buildTargetName: 'build', buildTargetName: 'build',
ciTargetName: 'test-ci', ciTestTargetName: 'test-ci',
}, },
context context
); );
@ -95,7 +95,7 @@ describe('@nx/gradle/plugin/nodes', () => {
expect(results).toMatchSnapshot(); expect(results).toMatchSnapshot();
}); });
it('should not create nodes with atomized tests targets based on gradle if ciTargetName is not specified', async () => { it('should not create nodes with atomized tests targets based on gradle if ciTestTargetName is not specified', async () => {
const results = await createNodesFunction( const results = await createNodesFunction(
['proj/application/build.gradle'], ['proj/application/build.gradle'],
{ {

View File

@ -80,6 +80,12 @@ export const makeCreateNodesForGradleConfigFile =
options: GradlePluginOptions | undefined, options: GradlePluginOptions | undefined,
context: CreateNodesContext context: CreateNodesContext
) => { ) => {
if (process.env.VERCEL) {
// Vercel does not allow JAVA_VERSION to be set
// skip on Vercel
return {};
}
const projectRoot = dirname(gradleFilePath); const projectRoot = dirname(gradleFilePath);
options = normalizeOptions(options); options = normalizeOptions(options);

View File

@ -1,6 +1,7 @@
export interface GradlePluginOptions { export interface GradlePluginOptions {
testTargetName?: string; testTargetName?: string;
ciTargetName?: string; ciTestTargetName?: string;
ciIntTestTargetName?: string;
[taskTargetName: string]: string | undefined | boolean; [taskTargetName: string]: string | undefined | boolean;
} }

View File

@ -1,4 +1,4 @@
export const nxVersion = require('../../package.json').version; export const nxVersion = require('../../package.json').version;
export const gradleProjectGraphPluginName = 'dev.nx.gradle.project-graph'; export const gradleProjectGraphPluginName = 'dev.nx.gradle.project-graph';
export const gradleProjectGraphVersion = '0.0.2'; export const gradleProjectGraphVersion = '0.1.0';

View File

@ -75,7 +75,7 @@ async function runTasks(
const results = await batchExecutor.batchImplementationFactory()( const results = await batchExecutor.batchImplementationFactory()(
batchTaskGraph, batchTaskGraph,
input, input,
tasks[0].overrides, tasks[tasks.length - 1].overrides,
context context
); );

View File

@ -321,6 +321,9 @@ export class TaskOrchestrator {
batch: Batch, batch: Batch,
groupId: number groupId: number
) { ) {
const applyFromCacheOrRunBatchStart = performance.mark(
'TaskOrchestrator-apply-from-cache-or-run-batch:start'
);
const taskEntries = Object.entries(batch.taskGraph.tasks); const taskEntries = Object.entries(batch.taskGraph.tasks);
const tasks = taskEntries.map(([, task]) => task); const tasks = taskEntries.map(([, task]) => task);
@ -374,9 +377,19 @@ export class TaskOrchestrator {
groupId groupId
); );
} }
// Batch is done, mark it as completed
const applyFromCacheOrRunBatchEnd = performance.mark(
'TaskOrchestrator-apply-from-cache-or-run-batch:end'
);
performance.measure(
'TaskOrchestrator-apply-from-cache-or-run-batch',
applyFromCacheOrRunBatchStart.name,
applyFromCacheOrRunBatchEnd.name
);
} }
private async runBatch(batch: Batch, env: NodeJS.ProcessEnv) { private async runBatch(batch: Batch, env: NodeJS.ProcessEnv) {
const runBatchStart = performance.mark('TaskOrchestrator-run-batch:start');
try { try {
const batchProcess = const batchProcess =
await this.forkedProcessTaskRunner.forkProcessForBatch( await this.forkedProcessTaskRunner.forkProcessForBatch(
@ -402,6 +415,13 @@ export class TaskOrchestrator {
task: this.taskGraph.tasks[rootTaskId], task: this.taskGraph.tasks[rootTaskId],
status: 'failure' as TaskStatus, status: 'failure' as TaskStatus,
})); }));
} finally {
const runBatchEnd = performance.mark('TaskOrchestrator-run-batch:end');
performance.measure(
'TaskOrchestrator-run-batch',
runBatchStart.name,
runBatchEnd.name
);
} }
} }

View File

@ -16,3 +16,4 @@ pluginManagement {
rootProject.name = "nx" rootProject.name = "nx"
includeBuild("./packages/gradle/project-graph") includeBuild("./packages/gradle/project-graph")
includeBuild("./packages/gradle/batch-runner")