fix(core): tui summary should show cancelled when interrupting dev server (#31042)

## Current Behavior
Interrupting a serve task with `Control + C` displays a "Success"
message, which isn't quite accurate.

<img width="1077" alt="image"
src="https://github.com/user-attachments/assets/b7e7086d-2725-4c65-b1f6-9f8a5db5196a"
/>


## Expected Behavior
Interrupting a serve task displays a "Cancelled" message

<img width="1077" alt="image"
src="https://github.com/user-attachments/assets/698e6e43-a376-473a-ab4f-7d514026ff02"
/>


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

Fixes #
This commit is contained in:
Craigory Coppola 2025-05-05 12:08:02 -04:00 committed by GitHub
parent ce64f85069
commit 9fe487c0f3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 187 additions and 19 deletions

View File

@ -1,8 +1,10 @@
import { getTuiTerminalSummaryLifeCycle } from './tui-summary-life-cycle';
import { Task } from '../../config/task-graph';
import { stripVTControlCharacters } from 'util';
import { EOL } from 'os';
import { getTuiTerminalSummaryLifeCycle } from './tui-summary-life-cycle';
import { Task } from '../../config/task-graph';
import { TaskStatus as NativeTaskStatus } from '../../native';
let originalHrTime;
let originalColumns;
@ -43,6 +45,12 @@ describe('getTuiTerminalSummaryLifeCycle', () => {
args: {
targets: ['test'],
},
taskGraph: {
tasks: { dep },
dependencies: {},
continuousDependencies: {},
roots: [],
},
initiatingProject: 'test',
initiatingTasks: [],
overrides: {},
@ -95,6 +103,12 @@ describe('getTuiTerminalSummaryLifeCycle', () => {
args: {
targets: ['test'],
},
taskGraph: {
tasks: { dep, target },
dependencies: {},
continuousDependencies: {},
roots: [],
},
initiatingProject: 'test',
initiatingTasks: [],
overrides: {},
@ -147,6 +161,12 @@ describe('getTuiTerminalSummaryLifeCycle', () => {
},
initiatingProject: 'test',
initiatingTasks: [],
taskGraph: {
tasks: { dep, target },
dependencies: {},
continuousDependencies: {},
roots: [],
},
overrides: {},
projectNames: ['test'],
tasks: [target, dep],
@ -184,6 +204,60 @@ describe('getTuiTerminalSummaryLifeCycle', () => {
"
`);
});
it('should display cancelled for single continuous task', async () => {
const target = {
id: 'test:dev',
continuous: true,
target: {
target: 'dev',
project: 'test',
},
} as Partial<Task> as Task;
const { lifeCycle, printSummary } = getTuiTerminalSummaryLifeCycle({
args: {
targets: ['dev'],
},
initiatingProject: 'test',
initiatingTasks: [],
taskGraph: {
tasks: { [target.id]: target },
dependencies: {
[target.id]: [],
},
continuousDependencies: {},
roots: [],
},
overrides: {},
projectNames: ['test'],
tasks: [target],
resolveRenderIsDonePromise: jest.fn().mockResolvedValue(null),
});
lifeCycle.startTasks([target], null);
lifeCycle.setTaskStatus(target.id, NativeTaskStatus.Stopped);
lifeCycle.printTaskTerminalOutput(
target,
'success',
'I was a happy dev server'
);
lifeCycle.endCommand();
// Continuous tasks are marked as stopped when the command is stopped
const lines = getOutputLines(printSummary);
expect(lines.join('\n')).toMatchInlineSnapshot(`
"
> nx run test:dev
I was a happy dev server
NX Cancelled running target dev for project test (37w)
"
`);
});
});
describe('runMany', () => {
@ -209,6 +283,12 @@ describe('getTuiTerminalSummaryLifeCycle', () => {
},
initiatingProject: null,
initiatingTasks: [],
taskGraph: {
tasks: { foo, bar },
dependencies: {},
continuousDependencies: {},
roots: [],
},
overrides: {},
projectNames: ['foo', 'bar'],
tasks: [foo, bar],
@ -285,6 +365,12 @@ describe('getTuiTerminalSummaryLifeCycle', () => {
},
initiatingProject: null,
initiatingTasks: [],
taskGraph: {
tasks: { foo, bar },
dependencies: {},
continuousDependencies: {},
roots: [],
},
overrides: {},
projectNames: ['foo', 'bar'],
tasks: [foo, bar],
@ -349,6 +435,12 @@ describe('getTuiTerminalSummaryLifeCycle', () => {
initiatingProject: null,
initiatingTasks: [],
overrides: {},
taskGraph: {
tasks: { foo, bar },
dependencies: {},
continuousDependencies: {},
roots: [],
},
projectNames: ['foo', 'bar'],
tasks: [foo, bar],
resolveRenderIsDonePromise: jest.fn().mockResolvedValue(null),

View File

@ -1,6 +1,6 @@
import { EOL } from 'node:os';
import { TaskStatus as NativeTaskStatus } from '../../native';
import { Task } from '../../config/task-graph';
import { Task, TaskGraph } from '../../config/task-graph';
import { output } from '../../utils/output';
import type { LifeCycle } from '../life-cycle';
import type { TaskStatus } from '../tasks-runner';
@ -9,6 +9,7 @@ import { prettyTime } from './pretty-time';
import { viewLogsFooterRows } from './view-logs-utils';
import figures = require('figures');
import { getTasksHistoryLifeCycle } from './task-history-life-cycle';
import { getLeafTasks } from '../task-graph-utils';
const LEFT_PAD = ` `;
const SPACER = ` `;
@ -17,6 +18,7 @@ const EXTENDED_LEFT_PAD = ` `;
export function getTuiTerminalSummaryLifeCycle({
projectNames,
tasks,
taskGraph,
args,
overrides,
initiatingProject,
@ -25,6 +27,7 @@ export function getTuiTerminalSummaryLifeCycle({
}: {
projectNames: string[];
tasks: Task[];
taskGraph: TaskGraph;
args: { targets?: string[]; configuration?: string; parallel?: number };
overrides: Record<string, unknown>;
initiatingProject: string;
@ -110,17 +113,39 @@ export function getTuiTerminalSummaryLifeCycle({
return;
}
const failure = totalSuccessfulTasks + stoppedTasks.size !== totalTasks;
const cancelled =
// Some tasks were in progress...
inProgressTasks.size > 0 ||
// or some tasks had not started yet
totalTasks !== tasks.length ||
// the run had a continous task as a leaf...
// this is needed because continous tasks get marked as stopped when the process is interrupted
[...getLeafTasks(taskGraph)].filter((t) => taskGraph.tasks[t].continuous)
.length > 0;
if (isRunOne) {
printRunOneSummary();
printRunOneSummary({
failure,
cancelled,
});
} else {
printRunManySummary();
printRunManySummary({
failure,
cancelled,
});
}
getTasksHistoryLifeCycle()?.printFlakyTasksMessage();
};
const printRunOneSummary = () => {
const printRunOneSummary = ({
failure,
cancelled,
}: {
failure: boolean;
cancelled: boolean;
}) => {
let lines: string[] = [];
const failure = totalSuccessfulTasks + stoppedTasks.size !== totalTasks;
// Prints task outputs in the order they were completed
// above the summary, since run-one should print all task results.
@ -131,7 +156,7 @@ export function getTuiTerminalSummaryLifeCycle({
lines.push(...output.getVerticalSeparatorLines(failure ? 'red' : 'green'));
if (!failure) {
if (!failure && !cancelled) {
const text = `Successfully ran ${formatTargetsAndProjects(
[initiatingProject],
targets,
@ -167,7 +192,7 @@ export function getTuiTerminalSummaryLifeCycle({
);
}
lines = [output.colors.green(lines.join(EOL))];
} else if (inProgressTasks.size === 0) {
} else if (!cancelled) {
let text = `Ran target ${output.bold(
targets[0]
)} for project ${output.bold(initiatingProject)}`;
@ -230,11 +255,16 @@ export function getTuiTerminalSummaryLifeCycle({
console.log(lines.join(EOL));
};
const printRunManySummary = () => {
const printRunManySummary = ({
failure,
cancelled,
}: {
failure: boolean;
cancelled: boolean;
}) => {
console.log('');
const lines: string[] = [''];
const failure = totalSuccessfulTasks + stoppedTasks.size !== totalTasks;
for (const taskId of taskIdsInOrderOfCompletion) {
const { terminalOutput, taskStatus } = tasksToTerminalOutputs[taskId];
@ -296,7 +326,7 @@ export function getTuiTerminalSummaryLifeCycle({
lines.push(successSummaryRows.join(EOL));
} else {
const text = `${
inProgressTasks.size ? 'Cancelled while running' : 'Ran'
cancelled ? 'Cancelled while running' : 'Ran'
} ${formatTargetsAndProjects(projectNames, targets, tasks)}`;
const taskOverridesRows: string[] = [];
if (Object.keys(overrides).length > 0) {

View File

@ -60,6 +60,7 @@ import { getTuiTerminalSummaryLifeCycle } from './life-cycles/tui-summary-life-c
import {
assertTaskGraphDoesNotContainInvalidTargets,
findCycle,
getLeafTasks,
makeAcyclic,
validateNoAtomizedTasks,
} from './task-graph-utils';
@ -170,6 +171,7 @@ async function getTerminalOutputLifeCycle(
getTuiTerminalSummaryLifeCycle({
projectNames,
tasks,
taskGraph,
args: nxArgs,
overrides: overridesWithoutHidden,
initiatingProject,
@ -520,7 +522,10 @@ export async function runCommandForTasks(
await printNxKey();
return { taskResults, completed: didCommandComplete(tasks, taskResults) };
return {
taskResults,
completed: didCommandComplete(tasks, taskGraph, taskResults),
};
} catch (e) {
if (restoreTerminal) {
restoreTerminal();
@ -529,27 +534,34 @@ export async function runCommandForTasks(
}
}
function didCommandComplete(tasks: Task[], taskResults: TaskResults): boolean {
function didCommandComplete(
tasks: Task[],
taskGraph: TaskGraph,
taskResults: TaskResults
): boolean {
// If no tasks, then we can consider it complete
if (tasks.length === 0) {
return true;
}
let everyTaskIsContinuous = true;
let continousLeafTasks = false;
const leafTasks = getLeafTasks(taskGraph);
for (const task of tasks) {
if (!task.continuous) {
everyTaskIsContinuous = false;
// If any discrete task does not have a result then it did not run
if (!taskResults[task.id]) {
return false;
}
} else {
if (leafTasks.has(task.id)) {
continousLeafTasks = true;
}
}
}
// If every task is continuous, command cannot complete by definition
// If a leaf task is continous, we must have cancelled it.
// Otherwise, we've looped through all the discrete tasks and they have results
return !everyTaskIsContinuous;
return !continousLeafTasks;
}
async function ensureWorkspaceIsInSyncAndGetGraphs(

View File

@ -192,3 +192,37 @@ class NonParallelTaskDependsOnContinuousTasksError extends Error {
this.name = 'NonParallelTaskDependsOnContinuousTasksError';
}
}
export function getLeafTasks(taskGraph: TaskGraph): Set<string> {
const reversed = reverseTaskGraph(taskGraph);
const leafTasks = new Set<string>();
for (const [taskId, dependencies] of Object.entries(reversed.dependencies)) {
if (dependencies.length === 0) {
leafTasks.add(taskId);
}
}
return leafTasks;
}
function reverseTaskGraph(taskGraph: TaskGraph): TaskGraph {
const reversed = {
tasks: taskGraph.tasks,
dependencies: Object.fromEntries(
Object.entries(taskGraph.tasks).map(([taskId]) => [taskId, []])
),
} as TaskGraph;
for (const [taskId, dependencies] of Object.entries(taskGraph.dependencies)) {
for (const dependency of dependencies) {
reversed.dependencies[dependency].push(taskId);
}
}
for (const [taskId, dependencies] of Object.entries(
taskGraph.continuousDependencies
)) {
for (const dependency of dependencies) {
reversed.dependencies[dependency].push(taskId);
}
}
return reversed;
}