Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions packages/create-release-branch/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Show the CLI output in one block, with a spinner for long steps and the output of a failing Yarn step ([#159](https://github.com/MetaMask/release-tools/pull/159))
- Show errors without a stack trace (set `DEBUG=create-release-branch:*` to see it), with their causes, and list release spec problems by line and package ([#160](https://github.com/MetaMask/release-tools/pull/160))
- Group repeated warnings (packages without a tag, changelogs without updates) into one warning with a list of packages ([#162](https://github.com/MetaMask/release-tools/pull/162))
- Start the CLI output with a header, end each step with its result, and end the run with a summary of the released packages ([#163](https://github.com/MetaMask/release-tools/pull/163))

### Fixed

Expand Down
2 changes: 1 addition & 1 deletion packages/create-release-branch/src/cli/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ describe('main', () => {
output: expect.any(Object) as CliOutput,
});
expect(stdout.data().join('')).toContain(
'Project appears to have workspaces. Following monorepo workflow.',
'create-release-branch root · monorepo · legacy mode',
);
});

Expand Down
109 changes: 109 additions & 0 deletions packages/create-release-branch/src/core/cli-output.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
S_BAR,
S_BAR_END,
S_BAR_START,
S_ERROR,
S_INFO,
S_STEP_SUBMIT,
Expand Down Expand Up @@ -494,6 +495,114 @@ describe('cli-output', () => {
);
});

it('header writes the intro', () => {
const plainStdout = new MockWritable();
const styledStdout = buildTerminalStream();
const stderr = new MockWritable();

createCliOutput({ stdout: plainStdout, stderr }).header(
'Some title',
'some details',
);
createCliOutput({ stdout: styledStdout, stderr }).header(
'Some title',
'some details',
);

expect(textOf(plainStdout)).toBe(
`${S_BAR_START} Some title some details\n`,
);
expect(textOf(styledStdout)).toContain(
`${styleText(['inverse', 'cyan'], ' Some title ', { validateStream: false })} ${styleText('dim', 'some details', { validateStream: false })}`,
);
expect(stderr.data()).toStrictEqual([]);
});

it('done writes a completed step', () => {
const stdout = new MockWritable();
const stderr = new MockWritable();

createCliOutput({ stdout, stderr }).done('Did something');

expect(textOf(stdout)).toBe(logLine(S_STEP_SUBMIT, 'Did something'));
expect(stderr.data()).toStrictEqual([]);
});

it('summary writes the note and the outro', () => {
const plainStdout = new MockWritable();
const styledStdout = buildTerminalStream();
const stderr = new MockWritable();

createCliOutput({ stdout: plainStdout, stderr }).summary(
'Some title',
['Line 1', '', 'Line 2'],
'All done',
);
createCliOutput({ stdout: styledStdout, stderr }).summary(
'Some title',
['Line 1'],
'All done',
);

const text = textOf(plainStdout);
expect(text).toContain(`${S_STEP_SUBMIT} Some title `);
expect(text).toContain(`${S_BAR} Line 1 `);
expect(text).toContain(`${S_BAR} Line 2 `);
expect(text.endsWith(`${S_BAR}\n${S_BAR_END} All done\n\n`)).toBe(true);
expect(textOf(styledStdout)).toContain(
styleText('green', 'All done', { validateStream: false }),
);
expect(stderr.data()).toStrictEqual([]);
});

it('step ends with the result text', async () => {
const stderr = buildTerminalStream();
const animated = createCliOutput({ stdout: new MockWritable(), stderr });
const stdout = new MockWritable();
const plainStderr = new MockWritable();
const plain = createCliOutput({ stdout, stderr: plainStderr });
const options = { result: (value: number): string => `Did ${value}` };

for (const output of [animated, plain]) {
expect(await output.step('Doing', async () => 42, options)).toBe(42);
await expect(
output.step(
'Failing',
async () => {
throw new Error('oops');
},
options,
),
).rejects.toThrow('oops');
}

expect(indicators.spinner.stop).toHaveBeenCalledExactlyOnceWith('Did 42');
expect(indicators.spinner.error).toHaveBeenCalledExactlyOnceWith(
'Failing',
);
expect(textOf(stdout)).toBe(logLine(S_STEP_SUBMIT, 'Did 42'));
expect(textOf(plainStderr)).toBe(logLine(S_ERROR, 'Failing'));
});

it('task ends with the result text', async () => {
const animated = createCliOutput({
stdout: new MockWritable(),
stderr: buildTerminalStream(),
});
const stdout = new MockWritable();
const plain = createCliOutput({ stdout, stderr: new MockWritable() });
const options = { result: (value: number): string => `Ran ${value}` };

for (const output of [animated, plain]) {
expect(await output.task('Running', async () => 42, options)).toBe(42);
}

expect(indicators.taskLog.success).toHaveBeenCalledExactlyOnceWith(
'Ran 42',
);
expect(textOf(stdout)).toBe(logLine(S_STEP_SUBMIT, 'Ran 42'));
});

it('formats names and dim text for stderr only when stderr is styled', () => {
const styled = createCliOutput({
stdout: new MockWritable(),
Expand Down
78 changes: 67 additions & 11 deletions packages/create-release-branch/src/core/cli-output.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { cancel, log, spinner, taskLog } from '@clack/prompts';
import {
cancel,
intro,
log,
note,
outro,
spinner,
taskLog,
} from '@clack/prompts';
import { Writable } from 'node:stream';
import { stripVTControlCharacters, styleText } from 'node:util';

Expand All @@ -8,9 +16,15 @@ import { stripVTControlCharacters, styleText } from 'node:util';
* Each message is laid out by clack, which adds its own spacing, so any
* trailing newlines in the text are dropped.
*
* @property header - Writes the first line of the run, with a title and
* details, to standard out.
* @property info - Writes an informational message to standard out.
* @property success - Writes a message about a completed action to standard
* out.
* @property done - Writes a completed step that has no spinner to standard
* out.
* @property summary - Writes a box with a title and lines, then the last line
* of the run, to standard out.
* @property warn - Writes a warning to standard error.
* @property warnList - Writes a warning to standard error, with each item, such
* as a package name, on its own line below it.
Expand All @@ -19,26 +33,45 @@ import { stripVTControlCharacters, styleText } from 'node:util';
* details, and ends the output, to standard error.
* @property format - Styles parts of the text for standard error: `name` for
* names, such as package names, and `dim` for secondary text.
* @property step - Runs a long-running action and returns its result.
* @property step - Runs a long-running action and returns its result. The
* step ends with the text from `options.result`, or else with the label.
* @property task - Runs a long-running action that prints lines of output,
* such as a command, and returns its result. The lines are shown only while
* the action runs, or if it fails.
* the action runs, or if it fails. The task ends like a step.
*/
export type CliOutput = {
header(title: string, details: string): void;
info(text: string): void;
success(text: string): void;
done(text: string): void;
summary(title: string, lines: string[], outro: string): void;
warn(text: string): void;
warnList(title: string, items: string[]): void;
error(text: string): void;
failure(title: string, lines: string[]): void;
format: { name(text: string): string; dim(text: string): string };
step<Result>(label: string, run: () => Promise<Result>): Promise<Result>;
step<Result>(
label: string,
run: () => Promise<Result>,
options?: StepOptions<Result>,
): Promise<Result>;
task<Result>(
label: string,
run: (log: (line: string) => void) => Promise<Result>,
options?: StepOptions<Result>,
): Promise<Result>;
};

/**
* Options for a step or a task.
*
* @property result - Builds the text that the step ends with when it succeeds,
* from the result of the action.
*/
export type StepOptions<Result> = {
result?: (value: Result) => string;
};

/**
* A stream that the output can write to, such as `process.stdout`.
*/
Expand All @@ -48,6 +81,11 @@ export type OutputStream = Writable & {
hasColors?: () => boolean;
};

/**
* A style, or a list of styles, that `styleText` accepts.
*/
type Style = Parameters<typeof styleText>[0];

/**
* A key that `readline` reports in a `keypress` event.
*/
Expand Down Expand Up @@ -159,7 +197,8 @@ function trimTrailingNewlines(text: string): string {
/**
* Creates a `CliOutput` that writes to the given streams.
*
* Messages, and lines for completed steps and tasks, go to standard out.
* The header, messages, lines for completed steps and tasks, and the summary
* go to standard out.
* Warnings, errors, spinners, and task output go to standard error. A stream
* that is not styled gets no ANSI codes. When animating, a step shows a
* spinner and a task streams its output until it ends. Otherwise, each ends
Expand Down Expand Up @@ -194,6 +233,8 @@ export function createCliOutput({
}): CliOutput {
const toStdout = { output: styled.stdout ? stdout : withoutColors(stdout) };
const toStderr = { output: styled.stderr ? stderr : withoutColors(stderr) };
const styleStdout = (style: Style, text: string): string =>
styled.stdout ? styleText(style, text, { stream: stdout }) : text;
const format = {
name: (text: string): string =>
styled.stderr ? styleText('cyan', text, { stream: stderr }) : text,
Expand All @@ -202,12 +243,27 @@ export function createCliOutput({
};

return {
header(title, details) {
intro(
styled.stdout
? `${styleStdout(['inverse', 'cyan'], ` ${title} `)} ${styleStdout('dim', details)}`
: `${title} ${details}`,
toStdout,
);
},
info(text) {
log.info(trimTrailingNewlines(text), toStdout);
},
success(text) {
log.success(trimTrailingNewlines(text), toStdout);
},
done(text) {
log.step(text, toStdout);
},
summary(title, lines, outroText) {
note(lines.join('\n'), title, toStdout);
outro(styleStdout('green', outroText), toStdout);
},
warn(text) {
log.warn(trimTrailingNewlines(text), toStderr);
},
Expand All @@ -234,11 +290,11 @@ export function createCliOutput({
cancel('The release did not finish.', toStderr);
},
format,
async step(label, run) {
async step(label, run, options) {
if (!animate) {
try {
const result = await run();
log.step(label, toStdout);
log.step(options?.result?.(result) ?? label, toStdout);
return result;
} catch (error) {
log.error(label, toStderr);
Expand All @@ -252,22 +308,22 @@ export function createCliOutput({
indicator.start(label);
try {
const result = await run();
indicator.stop(label);
indicator.stop(options?.result?.(result) ?? label);
return result;
} catch (error) {
indicator.error(label);
throw error;
}
});
},
async task(label, run) {
async task(label, run, options) {
if (!animate) {
const lines: string[] = [];
try {
const result = await run((line) => {
lines.push(line);
});
log.step(label, toStdout);
log.step(options?.result?.(result) ?? label, toStdout);
return result;
} catch (error) {
log.error(label, toStderr);
Expand All @@ -290,7 +346,7 @@ export function createCliOutput({
const result = await run((line) => {
taskOutput.message(line);
});
taskOutput.success(label);
taskOutput.success(options?.result?.(result) ?? label);
return result;
} catch (error) {
taskOutput.error(label, { showLog: true });
Expand Down
Loading
Loading