Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
72bc5a6
fix(core): improve batch task result handling in TUI
FrozenPandaz May 8, 2026
dd57e99
fix(gradle): mark batch peers as skipped after a sibling fails
FrozenPandaz May 8, 2026
3f3b31b
fix(maven): mark batch peers as skipped after a sibling fails
FrozenPandaz May 8, 2026
e4fa69d
chore(core): add test for skipped batch peer in TUI summary lifecycle
FrozenPandaz May 8, 2026
df37d8e
chore(gradle): test skipped peer behavior in gradle batch executor
FrozenPandaz May 8, 2026
39f7401
chore(gradle): test ResultEmitter NX_RESULT protocol
FrozenPandaz May 8, 2026
e09ba7c
chore(gradle): drop ResultEmitter unit tests
FrozenPandaz May 8, 2026
4a66652
fix(maven): don't yield fallbacks for unyielded tasks on clean exit
FrozenPandaz May 8, 2026
2fe5b2d
fix(gradle): make Kotlin runner the source of truth for skipped peers
FrozenPandaz May 8, 2026
70bc0dc
fix(maven): emit explicit skipped NX_RESULT from Kotlin runner
FrozenPandaz May 8, 2026
3b0397d
chore(gradle): require explicit status on Kotlin TaskResult
FrozenPandaz May 8, 2026
9ac067c
chore(core): simplify batch task result handling
FrozenPandaz May 8, 2026
6973e4d
fix(gradle): apply ktfmt formatting
nx-cloud[bot] May 8, 2026
2006e6e
fix(core): emit cursor-hide on TUI task finalization regardless of PT…
FrozenPandaz May 8, 2026
6d99b0e
chore(core): test that append_task_output does not hide the cursor
FrozenPandaz May 8, 2026
d418f5a
fix(core): emit cursor-hide on TUI task finalization regardless of PT…
nx-cloud[bot] May 8, 2026
f629f37
chore(core): test that append_task_output does not hide the cursor [S…
nx-cloud[bot] May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,8 +1,34 @@
package dev.nx.gradle.data

/**
* Per-task result emitted over NX_RESULT. `status` is always sent — one of `"success"`,
* `"failure"`, or `"skipped"`. `success` is also sent for back-compat with Nx versions that don't
* read `status` yet.
*/
data class TaskResult(
val success: Boolean,
val status: String,
val startTime: Long,
val endTime: Long,
var terminalOutput: String
)
var terminalOutput: String,
) {
companion object {
fun success(startTime: Long, endTime: Long, terminalOutput: String) =
TaskResult(true, "success", startTime, endTime, terminalOutput)

fun failure(startTime: Long, endTime: Long, terminalOutput: String) =
TaskResult(false, "failure", startTime, endTime, terminalOutput)

fun skipped(startTime: Long, endTime: Long) =
TaskResult(false, "skipped", startTime, endTime, "")

fun fromBoolean(
success: Boolean,
startTime: Long,
endTime: Long,
terminalOutput: String,
): TaskResult =
if (success) success(startTime, endTime, terminalOutput)
else failure(startTime, endTime, terminalOutput)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ fun buildListener(
?.let { nxTaskId ->
val endTime = event.result.endTime
val startTime = taskStartTimes[nxTaskId] ?: event.result.startTime
taskResults[nxTaskId] = TaskResult(success, startTime, endTime, "")
taskResults[nxTaskId] = TaskResult.fromBoolean(success, startTime, endTime, "")
pendingEmit[taskPath] = nxTaskId
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,14 +132,19 @@ fun runBuildLauncher(
errorStream.close()
}

// The last task in the build has no successor TaskStartEvent to drain it; flush here. Tasks
// Gradle never ran (excluded, configuration error, never realized) aren't in taskResults at
// all — gradle-batch.impl.ts's post-loop fallback yields a result for them on the TS side.
// The last task has no successor TaskStartEvent to drain it; flush here.
pendingEmit.keys.toList().forEach { taskPath ->
emitForTaskPath(taskPath, capture.getOutput(taskPath))
}

val globalEnd = System.currentTimeMillis()
taskResults.putAll(
emitSkippedForUnreachedTasks(
requestedNxTaskIds = tasks.keys,
reportedNxTaskIds = taskResults.keys,
startTime = globalStart,
endTime = globalEnd,
))
val maxEndTime = taskResults.values.map { it.endTime }.maxOrNull() ?: globalEnd
val minStartTime = taskResults.values.map { it.startTime }.minOrNull() ?: globalStart
logger.info(
Expand All @@ -151,6 +156,23 @@ fun runBuildLauncher(
return taskResults
}

private fun emitSkippedForUnreachedTasks(
requestedNxTaskIds: Set<String>,
reportedNxTaskIds: Set<String>,
startTime: Long,
endTime: Long,
): Map<String, TaskResult> {
val skippedResults = mutableMapOf<String, TaskResult>()
requestedNxTaskIds.forEach { nxTaskId ->
if (nxTaskId !in reportedNxTaskIds) {
val skipped = TaskResult.skipped(startTime, endTime)
skippedResults[nxTaskId] = skipped
ResultEmitter.emit(nxTaskId, skipped)
}
}
return skippedResults
}

fun runTestLauncher(
connection: ProjectConnection,
tasks: Map<String, GradleTask>,
Expand Down Expand Up @@ -194,7 +216,7 @@ fun runTestLauncher(
val success = testTaskStatus[nxTaskId] ?: false
val startTime = testStartTimes[nxTaskId] ?: globalStart
val endTime = testEndTimes[nxTaskId] ?: System.currentTimeMillis()
ResultEmitter.emit(nxTaskId, TaskResult(success, startTime, endTime, captured))
ResultEmitter.emit(nxTaskId, TaskResult.fromBoolean(success, startTime, endTime, captured))
}
}

Expand Down Expand Up @@ -236,14 +258,17 @@ fun runTestLauncher(
val taskResults = mutableMapOf<String, TaskResult>()
tasks.forEach { (nxTaskId, taskConfig) ->
if (taskConfig.testClassName != null) {
val ranTask = testTaskStatus.containsKey(nxTaskId)
val success = testTaskStatus[nxTaskId] ?: false
val startTime = testStartTimes[nxTaskId] ?: globalStart
val endTime = testEndTimes[nxTaskId] ?: globalEnd
val result = TaskResult(success, startTime, endTime, capturedFor(nxTaskId))
// No test events fired → mark as skipped (peer compile failed, etc.) rather than failure.
val result =
if (!ranTask) TaskResult.skipped(startTime, endTime)
else TaskResult.fromBoolean(success, startTime, endTime, capturedFor(nxTaskId))
taskResults[nxTaskId] = result
// Streamed tasks are deduped; this emits the ones whose capturedFor was empty when
// emitTestTask fired during the build (e.g. test tasks Gradle skipped before producing
// any stdout).
// ResultEmitter dedupes by task id; this catches tests whose captured output was empty
// when emitTestTask fired during the build.
ResultEmitter.emit(nxTaskId, result)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,8 @@ import dev.nx.gradle.data.TaskResult
import java.util.concurrent.ConcurrentHashMap

/**
* Emits per-task results to stdout as `NX_RESULT:{json}` lines so the Nx batch executor can stream
* them to the task runner as an async iterator instead of waiting for a bulk JSON blob at the end.
*
* Matches the protocol used by the Maven batch runner.
* Emits per-task results as `NX_RESULT:{json}` lines on stdout so the Nx batch executor can stream
* them as an async iterator. Mirrors the Maven runner protocol. Each emit is deduped by task id.
*/
object ResultEmitter {
private val gson = Gson()
Expand All @@ -22,6 +20,7 @@ object ResultEmitter {
"result" to
mapOf(
"success" to result.success,
"status" to result.status,
"startTime" to result.startTime,
"endTime" to result.endTime,
"terminalOutput" to result.terminalOutput))
Expand Down
215 changes: 213 additions & 2 deletions packages/gradle/src/executors/gradle/gradle-batch.impl.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,52 @@
import { getGradlewTasksToRun } from './gradle-batch.impl';
import { TaskGraph, ProjectGraphProjectNode } from '@nx/devkit';
import gradleBatch, { getGradlewTasksToRun } from './gradle-batch.impl';
import {
ExecutorContext,
TaskGraph,
ProjectGraphProjectNode,
} from '@nx/devkit';
import { ChildProcess, spawn } from 'child_process';
import { EventEmitter } from 'events';
import { Readable } from 'stream';
import { GradleExecutorSchema } from './schema';

jest.mock('child_process', () => ({
...jest.requireActual('child_process'),
spawn: jest.fn(),
}));
jest.mock('../../utils/exec-gradle', () => ({
findGradlewFile: jest.fn(() => 'gradlew'),
getCustomGradleExecutableDirectoryFromPlugin: jest.fn(() => undefined),
}));

const spawnMock = spawn as jest.MockedFunction<typeof spawn>;

interface FakeChildOptions {
stdoutLines: string[];
exitCode: number;
}

function createFakeChild({
stdoutLines,
exitCode,
}: FakeChildOptions): ChildProcess {
const stdout = new Readable({ read() {} });
for (const line of stdoutLines) {
stdout.push(line.endsWith('\n') ? line : `${line}\n`);
}
stdout.push(null);

const child = new EventEmitter() as ChildProcess;
Object.assign(child, { stdout, stderr: null, stdin: null });
setImmediate(() => child.emit('close', exitCode));
return child;
}

async function collect<T>(iter: AsyncGenerator<T>): Promise<T[]> {
const out: T[] = [];
for await (const item of iter) out.push(item);
return out;
}

describe('getGradlewTasksToRun', () => {
let taskGraph: TaskGraph;
let inputs: Record<string, GradleExecutorSchema>;
Expand Down Expand Up @@ -206,6 +251,172 @@ describe('getGradlewTasksToRun', () => {
expect(result.excludeTestTasks).toEqual(new Set());
});

describe('gradleBatch (async generator)', () => {
let context: ExecutorContext;
let batchTaskGraph: TaskGraph;
let batchInputs: Record<string, GradleExecutorSchema>;

beforeEach(() => {
spawnMock.mockReset();
batchTaskGraph = {
tasks: {
'app2:build': taskGraph.tasks['app2:build'],
'app1:test': taskGraph.tasks['app1:test'],
},
dependencies: {
'app2:build': [],
'app1:test': ['app2:build'],
},
continuousDependencies: {},
roots: ['app2:build'],
};
batchInputs = {
'app2:build': { taskName: 'build', excludeDependsOn: false },
'app1:test': { taskName: 'test', excludeDependsOn: false },
};
context = {
root: '/workspace',
cwd: '/workspace',
isVerbose: false,
projectGraph: { nodes, dependencies: {}, externalNodes: {} },
projectsConfigurations: {
version: 2,
projects: {
app1: nodes.app1.data,
app2: nodes.app2.data,
app3: nodes.app3.data,
},
},
nxJsonConfiguration: {},
taskGraph: batchTaskGraph,
} as unknown as ExecutorContext;
});

it('relays skipped status emitted by the runner without modification', async () => {
// The Kotlin runner now emits an explicit `skipped` NX_RESULT for peers
// that didn't run because a sibling failed. The TS side just relays.
spawnMock.mockReturnValue(
createFakeChild({
stdoutLines: [
'NX_RESULT:' +
JSON.stringify({
task: 'app2:build',
result: {
success: false,
status: 'failure',
terminalOutput: 'compile error',
},
}),
'NX_RESULT:' +
JSON.stringify({
task: 'app1:test',
result: {
success: false,
status: 'skipped',
terminalOutput: '',
},
}),
],
exitCode: 1,
})
);

const results = await collect(
gradleBatch(
batchTaskGraph,
batchInputs,
{ __overrides_unparsed__: [] } as any,
context
)
);

const byTask = Object.fromEntries(results.map((r) => [r.task, r.result]));
expect(byTask['app2:build']).toEqual(
expect.objectContaining({
success: false,
status: 'failure',
terminalOutput: 'compile error',
})
);
expect(byTask['app1:test']).toEqual(
expect.objectContaining({
success: false,
status: 'skipped',
terminalOutput: '',
})
);
});

it('backfills unreported tasks as failure when the runner crashes', async () => {
// Runner exits non-zero with no NX_RESULT lines — TS side backfills as
// failure so Nx doesn't hang waiting for the missing task.
spawnMock.mockReturnValue(
createFakeChild({ stdoutLines: [], exitCode: 1 })
);

const results = await collect(
gradleBatch(
batchTaskGraph,
batchInputs,
{ __overrides_unparsed__: [] } as any,
context
)
);

expect(results).toHaveLength(2);
for (const r of results) {
expect(r.result.success).toBe(false);
expect(r.result.status).toBeUndefined();
}
});

it('passes through successful results without modification', async () => {
spawnMock.mockReturnValue(
createFakeChild({
stdoutLines: [
'NX_RESULT:' +
JSON.stringify({
task: 'app2:build',
result: { success: true, terminalOutput: 'a built' },
}),
'NX_RESULT:' +
JSON.stringify({
task: 'app1:test',
result: { success: true, terminalOutput: 'b tested' },
}),
],
exitCode: 0,
})
);

const results = await collect(
gradleBatch(
batchTaskGraph,
batchInputs,
{ __overrides_unparsed__: [] } as any,
context
)
);

expect(results).toEqual([
{
task: 'app2:build',
result: expect.objectContaining({
success: true,
terminalOutput: 'a built',
}),
},
{
task: 'app1:test',
result: expect.objectContaining({
success: true,
terminalOutput: 'b tested',
}),
},
]);
});
});

it('does not exclude transitive deps shared with an excludeDependsOn:false task', () => {
nodes['lib1'] = {
name: 'lib1',
Expand Down
13 changes: 1 addition & 12 deletions packages/gradle/src/executors/gradle/gradle-batch.impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,18 +95,6 @@ export default async function* gradleBatch(
};
}
}
return;
}

// Any tasks the batch runner did not report on are treated as failed so Nx
// does not hang waiting for results.
for (const taskId of taskIds) {
if (!yielded.has(taskId)) {
yield {
task: taskId,
result: { success: false, terminalOutput: `Gradlew batch failed` },
};
}
}
}

Expand Down Expand Up @@ -230,6 +218,7 @@ async function* streamTasksInBatch(
task: data.task,
result: {
success: data.result.success ?? false,
status: data.result.status,
terminalOutput: data.result.terminalOutput ?? '',
startTime: data.result.startTime,
endTime: data.result.endTime,
Expand Down
Loading
Loading