nx/packages/nx/src/tasks-runner/life-cycles/dynamic-run-one-terminal-output-life-cycle.ts

363 lines
11 KiB
TypeScript

import * as cliCursor from 'cli-cursor';
import { dots } from 'cli-spinners';
import { EOL } from 'os';
import * as readline from 'readline';
import { output } from '../../utils/output';
import type { LifeCycle } from '../life-cycle';
import { prettyTime } from './pretty-time';
import { Task } from '../../config/task-graph';
import { formatFlags, formatTargetsAndProjects } from './formatting-utils';
import { viewLogsFooterRows } from './view-logs-utils';
const LEFT_PAD = ` `;
const EXTENDED_LEFT_PAD = ` `;
/**
* As tasks are completed the overall state moves from:
* 1. EXECUTING_DEPENDENT_TARGETS (dynamic lines, including a spinner are reprinted, task outputs not shown)
* 2. EXECUTING_INITIATING_PROJECT_TARGET (dynamic output effectively ends as initiating project tasks forward output)
* 3. COMPLETED_SUCCESSFULLY or COMPLETED_WITH_ERRORS (footer messaging is appended to the previous output)
*/
type State =
| 'EXECUTING_DEPENDENT_TARGETS'
| 'EXECUTING_INITIATING_PROJECT_TARGET'
| 'COMPLETED_SUCCESSFULLY'
| 'COMPLETED_WITH_ERRORS';
/**
* The following function is responsible for creating a life cycle with dynamic
* outputs, meaning previous outputs can be rewritten or modified as new outputs
* are added. It is therefore intended for use on a user's local machines.
*
* In CI environments the static equivalent of this life cycle should be used.
*
* NOTE: output.dim() should be preferred over output.colors.gray() because it
* is much more consistently readable across different terminal color themes.
*/
export async function createRunOneDynamicOutputRenderer({
initiatingProject,
tasks,
args,
overrides,
}: {
initiatingProject: string;
tasks: Task[];
args: { configuration?: string; parallel?: number };
overrides: Record<string, unknown>;
}): Promise<{ lifeCycle: LifeCycle; renderIsDone: Promise<void> }> {
cliCursor.hide();
let resolveRenderIsDonePromise: (value: void) => void;
const renderIsDone = new Promise<void>(
(resolve) => (resolveRenderIsDonePromise = resolve)
).then(() => {
clearRenderInterval();
cliCursor.show();
});
function clearRenderInterval() {
if (renderDependentTargetsIntervalId) {
clearInterval(renderDependentTargetsIntervalId);
}
}
process.on('exit', () => clearRenderInterval());
process.on('SIGINT', () => clearRenderInterval());
process.on('SIGTERM', () => clearRenderInterval());
process.on('SIGHUP', () => clearRenderInterval());
const lifeCycle = {} as Partial<LifeCycle>;
const start = process.hrtime();
const figures = await import('figures');
let state: State = 'EXECUTING_DEPENDENT_TARGETS';
const tasksToTerminalOutputs: Record<string, string> = {};
const totalTasks = tasks.length;
const totalDependentTasks = totalTasks - 1;
const totalTasksFromInitiatingProject = tasks.filter(
(t) => t.target.project === initiatingProject
).length;
// Tasks from the initiating project are treated differently, they forward their output
const totalDependentTasksNotFromInitiatingProject =
totalTasks - totalTasksFromInitiatingProject;
const targetName = tasks[0].target.target;
let dependentTargetsNumLines = 0;
let totalCompletedTasks = 0;
let totalSuccessfulTasks = 0;
let totalFailedTasks = 0;
let totalCachedTasks = 0;
// Used to control the rendering of the spinner
let dependentTargetsCurrentFrame = 0;
let renderDependentTargetsIntervalId: NodeJS.Timeout | undefined;
const moveCursorToStartOfDependentTargetLines = () => {
readline.moveCursor(process.stdout, 0, -dependentTargetsNumLines);
};
const renderLines = (
lines: string[],
dividerColor = 'cyan',
renderDivider = true
) => {
let additionalLines = 0;
if (renderDivider) {
const dividerLines = output.getVerticalSeparatorLines(dividerColor);
for (const line of dividerLines) {
output.overwriteLine(line);
}
additionalLines += dividerLines.length;
lines.push('');
}
for (const line of lines) {
output.overwriteLine(line);
}
dependentTargetsNumLines = lines.length + additionalLines;
// clear any possible text below the cursor's position
readline.clearScreenDown(process.stdout);
};
const renderDependentTargets = (renderDivider = true) => {
if (totalDependentTasksNotFromInitiatingProject <= 0) {
return;
}
const max = dots.frames.length - 1;
const curr = dependentTargetsCurrentFrame;
dependentTargetsCurrentFrame = curr >= max ? 0 : curr + 1;
const linesToRender: string[] = [''];
const remainingDependentTasksNotFromInitiatingProject =
totalDependentTasksNotFromInitiatingProject - totalCompletedTasks;
switch (state) {
case 'EXECUTING_DEPENDENT_TARGETS':
if (totalFailedTasks === 0) {
linesToRender.push(
`${LEFT_PAD}${output.colors.cyan(
dots.frames[dependentTargetsCurrentFrame]
)} ${output.dim(
`Nx is waiting on ${remainingDependentTasksNotFromInitiatingProject} dependent project tasks before running tasks from`
)} ${initiatingProject}${output.dim('...')}`
);
if (totalSuccessfulTasks > 0) {
linesToRender.push('');
}
}
break;
}
if (totalFailedTasks > 0) {
linesToRender.push(
output.colors.red.dim(
`${LEFT_PAD}${output.colors.red(
figures.cross
)} ${totalFailedTasks}${`/${totalCompletedTasks}`} dependent project tasks failed (see below)`
)
);
}
if (totalSuccessfulTasks > 0) {
linesToRender.push(
output.dim(
`${LEFT_PAD}${output.dim(
figures.tick
)} ${totalSuccessfulTasks}${`/${totalCompletedTasks}`} dependent project tasks succeeded ${output.dim(
`[${totalCachedTasks} read from cache]`
)}`
)
);
}
moveCursorToStartOfDependentTargetLines();
if (linesToRender.length > 1) {
renderLines(
linesToRender,
'gray',
renderDivider && state !== 'EXECUTING_DEPENDENT_TARGETS'
);
} else {
renderLines([]);
}
};
lifeCycle.startCommand = () => {
renderDependentTargets();
};
lifeCycle.startTasks = (tasks: Task[]) => {
for (const task of tasks) {
// Move from the dependent project tasks phase to the initiating project's targets
if (
task.target.project === initiatingProject &&
state !== 'EXECUTING_INITIATING_PROJECT_TARGET'
) {
state = 'EXECUTING_INITIATING_PROJECT_TARGET';
clearRenderInterval();
renderDependentTargets(false);
if (totalDependentTasksNotFromInitiatingProject > 0) {
output.addNewline();
process.stdout.write(
`${LEFT_PAD}${output.dim(
'Hint: you can run the command with'
)} --verbose ${output.dim(
'to see the full dependent project outputs'
)}` + EOL
);
output.addVerticalSeparator('gray');
}
}
}
if (
!renderDependentTargetsIntervalId &&
state === 'EXECUTING_DEPENDENT_TARGETS'
) {
renderDependentTargetsIntervalId = setInterval(
renderDependentTargets,
100
);
}
};
lifeCycle.printTaskTerminalOutput = (task, cacheStatus, terminalOutput) => {
if (task.target.project === initiatingProject) {
output.logCommandOutput(task.id, cacheStatus, terminalOutput);
} else {
tasksToTerminalOutputs[task.id] = terminalOutput;
}
};
lifeCycle.endTasks = (taskResults) => {
for (let t of taskResults) {
totalCompletedTasks++;
switch (t.status) {
case 'remote-cache':
case 'local-cache':
case 'local-cache-kept-existing':
totalCachedTasks++;
totalSuccessfulTasks++;
break;
case 'success':
totalSuccessfulTasks++;
break;
case 'failure':
totalFailedTasks++;
/**
* A dependent project has failed so we stop executing and update the relevant
* dependent project task messaging
*/
if (t.task.target.project !== initiatingProject) {
clearRenderInterval();
renderDependentTargets(false);
output.addVerticalSeparator('red');
output.logCommandOutput(
t.task.id,
t.status,
tasksToTerminalOutputs[t.task.id]
);
}
break;
}
delete tasksToTerminalOutputs[t.task.id];
}
};
lifeCycle.endCommand = () => {
clearRenderInterval();
const timeTakenText = prettyTime(process.hrtime(start));
if (totalSuccessfulTasks === totalTasks) {
state = 'COMPLETED_SUCCESSFULLY';
const text = `Successfully ran ${formatTargetsAndProjects(
[initiatingProject],
[targetName],
tasks
)}`;
const taskOverridesLines = [];
if (Object.keys(overrides).length > 0) {
taskOverridesLines.push('');
taskOverridesLines.push(
`${EXTENDED_LEFT_PAD}${output.dim.green('With additional flags:')}`
);
Object.entries(overrides)
.map(([flag, value]) =>
output.dim.green(formatFlags(EXTENDED_LEFT_PAD, flag, value))
)
.forEach((arg) => taskOverridesLines.push(arg));
}
const pinnedFooterLines = [
output.applyNxPrefix(
'green',
output.colors.green(text) + output.dim(` (${timeTakenText})`)
),
...taskOverridesLines,
];
if (totalCachedTasks > 0) {
pinnedFooterLines.push(
output.dim(
`${EOL}Nx read the output from the cache instead of running the command for ${totalCachedTasks} out of ${totalTasks} tasks.`
)
);
}
renderLines(pinnedFooterLines, 'green');
} else {
state = 'COMPLETED_WITH_ERRORS';
let text = `Ran target ${output.bold(
targetName
)} for project ${output.bold(initiatingProject)}`;
if (totalDependentTasks > 0) {
text += ` and ${output.bold(
totalDependentTasks
)} task(s) they depend on`;
}
const taskOverridesLines = [];
if (Object.keys(overrides).length > 0) {
taskOverridesLines.push('');
taskOverridesLines.push(
`${EXTENDED_LEFT_PAD}${output.dim.red('With additional flags:')}`
);
Object.entries(overrides)
.map(([flag, value]) =>
output.dim.red(formatFlags(EXTENDED_LEFT_PAD, flag, value))
)
.forEach((arg) => taskOverridesLines.push(arg));
}
const viewLogs = viewLogsFooterRows(totalFailedTasks);
renderLines(
[
output.applyNxPrefix(
'red',
output.colors.red(text) + output.dim(` (${timeTakenText})`)
),
...taskOverridesLines,
'',
`${LEFT_PAD}${output.colors.red(
figures.cross
)} ${totalFailedTasks}${`/${totalCompletedTasks}`} failed`,
`${LEFT_PAD}${output.dim(
figures.tick
)} ${totalSuccessfulTasks}${`/${totalCompletedTasks}`} succeeded ${output.dim(
`[${totalCachedTasks} read from cache]`
)}`,
...viewLogs,
],
'red'
);
}
resolveRenderIsDonePromise();
};
return { lifeCycle, renderIsDone };
}