diff --git a/common/changes/@microsoft/rush/terminal-table-improvements_2026-04-18-22-04.json b/common/changes/@microsoft/rush/terminal-table-improvements_2026-04-18-22-04.json new file mode 100644 index 0000000000..c3db9d4960 --- /dev/null +++ b/common/changes/@microsoft/rush/terminal-table-improvements_2026-04-18-22-04.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Fix an issue where `rush list --detailed` did not print horizontal table separators.", + "type": "none", + "packageName": "@microsoft/rush" + } + ], + "packageName": "@microsoft/rush", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-04.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-04.json new file mode 100644 index 0000000000..61a50482dd --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-04.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Fix `TerminalTable` rendering spurious border lines when separator characters are set to empty strings", + "type": "patch", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-05.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-05.json new file mode 100644 index 0000000000..61bd63d176 --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-05.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add borderColor and headingColor styling options to `TerminalTable`", + "type": "minor", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-06.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-06.json new file mode 100644 index 0000000000..d402981e54 --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-06.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add borderColor and messageColor styling options to `PrintUtilities.printMessageInBox`", + "type": "minor", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} \ No newline at end of file diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-08.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-08.json new file mode 100644 index 0000000000..c33126485f --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-08.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add row separators between data rows in `TerminalTable`.", + "type": "patch", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} diff --git a/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-09.json b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-09.json new file mode 100644 index 0000000000..7d7397aee8 --- /dev/null +++ b/common/changes/@rushstack/terminal/terminal-table-improvements_2026-04-18-22-09.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "Add `TerminalTable.printToTerminal()` function.", + "type": "minor", + "packageName": "@rushstack/terminal" + } + ], + "packageName": "@rushstack/terminal", + "email": "iclanton@users.noreply.github.com" +} diff --git a/common/config/rush/version-policies.json b/common/config/rush/version-policies.json index f130c5939c..2ff301abba 100644 --- a/common/config/rush/version-policies.json +++ b/common/config/rush/version-policies.json @@ -103,7 +103,7 @@ "policyName": "rush", "definitionName": "lockStepVersion", "version": "5.175.0", - "nextBump": "minor", + "nextBump": "patch", "mainProject": "@microsoft/rush" } ] diff --git a/common/reviews/api/terminal.api.md b/common/reviews/api/terminal.api.md index 0736636071..c478b48bc2 100644 --- a/common/reviews/api/terminal.api.md +++ b/common/reviews/api/terminal.api.md @@ -167,6 +167,13 @@ export interface IPrefixProxyTerminalProviderOptionsBase { terminalProvider: ITerminalProvider; } +// @public +export interface IPrintMessageInBoxOptions { + borderColor?: (text: string) => string; + boxWidth?: number; + messageColor?: (text: string) => string; +} + // @beta export interface IProblemCollector { get problems(): ReadonlySet; @@ -268,9 +275,11 @@ export interface ITerminalTableChars { // @public export interface ITerminalTableOptions { borderCharacters?: Partial; + borderColor?: (text: string) => string; borderless?: boolean; colWidths?: number[]; head?: string[]; + headingColor?: (text: string) => string; } // @public @@ -347,6 +356,10 @@ export class PrefixProxyTerminalProvider implements ITerminalProvider { export class PrintUtilities { static getConsoleWidth(): number | undefined; // Warning: (ae-incompatible-release-tags) The symbol "printMessageInBox" is marked as @public, but its signature references "ITerminal" which is marked as @beta + static printMessageInBox(message: string, terminal: ITerminal, options?: IPrintMessageInBoxOptions): void; + // Warning: (ae-incompatible-release-tags) The symbol "printMessageInBox" is marked as @public, but its signature references "ITerminal" which is marked as @beta + // + // @deprecated (undocumented) static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number): void; static wrapWords(text: string, maxLineLength?: number, indent?: number): string; static wrapWords(text: string, maxLineLength?: number, linePrefix?: string): string; @@ -491,6 +504,8 @@ export class TerminalTable { constructor(options?: ITerminalTableOptions); // (undocumented) getLines(): string[]; + // Warning: (ae-incompatible-release-tags) The symbol "printToTerminal" is marked as @public, but its signature references "ITerminal" which is marked as @beta + printToTerminal(terminal: ITerminal): void; push(...rows: string[][]): void; toString(): string; } diff --git a/libraries/rush-lib/src/cli/actions/ListAction.ts b/libraries/rush-lib/src/cli/actions/ListAction.ts index 3793fb19b1..7658301553 100644 --- a/libraries/rush-lib/src/cli/actions/ListAction.ts +++ b/libraries/rush-lib/src/cli/actions/ListAction.ts @@ -277,7 +277,6 @@ export class ListAction extends BaseRushAction { table.push(packageRow); } - // eslint-disable-next-line no-console - console.log(table.toString()); + table.printToTerminal(this.terminal); } } diff --git a/libraries/terminal/src/PrintUtilities.ts b/libraries/terminal/src/PrintUtilities.ts index 6fe00c1365..4b01f18e72 100644 --- a/libraries/terminal/src/PrintUtilities.ts +++ b/libraries/terminal/src/PrintUtilities.ts @@ -5,6 +5,40 @@ import { Text } from '@rushstack/node-core-library'; import type { ITerminal } from './ITerminal'; +/** + * Options for {@link PrintUtilities.(printMessageInBox:1)}. + * + * @public + */ +export interface IPrintMessageInBoxOptions { + /** + * The width of the box in characters. Defaults to half of the console width. + */ + boxWidth?: number; + + /** + * A function to apply styling to the box border characters. + * + * @example + * ```typescript + * import { Colorize } from '@rushstack/terminal'; + * PrintUtilities.printMessageInBox('Hello!', terminal, { borderColor: Colorize.cyan }) + * ``` + */ + borderColor?: (text: string) => string; + + /** + * A function to apply styling to the message text inside the box. + * + * @example + * ```typescript + * import { Colorize } from '@rushstack/terminal'; + * PrintUtilities.printMessageInBox('Hello!', terminal, { messageColor: Colorize.bold }) + * ``` + */ + messageColor?: (text: string) => string; +} + /** * A sensible fallback column width for consoles. * @@ -186,14 +220,45 @@ export class PrintUtilities { * * @param message - The message to display. * @param terminal - The terminal to write the message to. - * @param boxWidth - The width of the box, defaults to half of the console width. + * @param options - Controls the box width and optional styling for the border and message text. + * {@label WITH_OPTIONS} + */ + public static printMessageInBox( + message: string, + terminal: ITerminal, + options?: IPrintMessageInBoxOptions + ): void; + /** + * @deprecated Use the {@link PrintUtilities.(printMessageInBox:1)} overload instead. + * Pass `boxWidth` via the {@link IPrintMessageInBoxOptions.boxWidth} property. */ - public static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number): void { - if (!boxWidth) { + public static printMessageInBox(message: string, terminal: ITerminal, boxWidth?: number): void; + public static printMessageInBox( + message: string, + terminal: ITerminal, + optionsOrBoxWidth?: IPrintMessageInBoxOptions | number + ): void { + let options: IPrintMessageInBoxOptions; + if (typeof optionsOrBoxWidth === 'number') { + options = { + boxWidth: optionsOrBoxWidth + }; + } else { + options = optionsOrBoxWidth ?? {}; + } + + const { borderColor, messageColor, boxWidth: optionsBoxWidth } = options ?? {}; + let boxWidth: number; + if (!optionsBoxWidth) { const consoleWidth: number = PrintUtilities.getConsoleWidth() || DEFAULT_CONSOLE_WIDTH; boxWidth = Math.floor(consoleWidth / 2); + } else { + boxWidth = optionsBoxWidth; } + const styleBorder = (s: string): string => borderColor?.(s) ?? s; + const styleMessage = (s: string): string => messageColor?.(s) ?? s; + const maxLineLength: number = boxWidth - 10; const wrappedMessageLines: string[] = PrintUtilities.wrapWordsToLines(message, maxLineLength); let longestLineLength: number = 0; @@ -209,25 +274,29 @@ export class PrintUtilities { // ═════════════ // Message // ═════════════ - const headerAndFooter: string = ` ${'═'.repeat(boxWidth)}`; + const headerAndFooter: string = ` ${styleBorder('═'.repeat(boxWidth))}`; terminal.writeLine(headerAndFooter); for (const line of wrappedMessageLines) { - terminal.writeLine(` ${line}`); + terminal.writeLine(` ${styleMessage(line)}`); } terminal.writeLine(headerAndFooter); } else { + const verticalBorder: string = styleBorder('║'); + // ╔═══════════╗ // ║ Message ║ // ╚═══════════╝ - terminal.writeLine(` ╔${'═'.repeat(boxWidth - 2)}╗`); + terminal.writeLine(` ${styleBorder(`╔${'═'.repeat(boxWidth - 2)}╗`)}`); for (const trimmedLine of trimmedLines) { const padding: number = boxWidth - trimmedLine.length - 2; const leftPadding: number = Math.floor(padding / 2); const rightPadding: number = padding - leftPadding; - terminal.writeLine(` ║${' '.repeat(leftPadding)}${trimmedLine}${' '.repeat(rightPadding)}║`); + terminal.writeLine( + ` ${verticalBorder}${' '.repeat(leftPadding)}${styleMessage(trimmedLine)}${' '.repeat(rightPadding)}${verticalBorder}` + ); } - terminal.writeLine(` ╚${'═'.repeat(boxWidth - 2)}╝`); + terminal.writeLine(` ${styleBorder(`╚${'═'.repeat(boxWidth - 2)}╝`)}`); } } } diff --git a/libraries/terminal/src/TerminalTable.ts b/libraries/terminal/src/TerminalTable.ts index fd1100f647..6f1d94645a 100644 --- a/libraries/terminal/src/TerminalTable.ts +++ b/libraries/terminal/src/TerminalTable.ts @@ -2,6 +2,7 @@ // See LICENSE in the project root for license information. import { AnsiEscape } from './AnsiEscape'; +import type { ITerminal } from './ITerminal'; /** * The set of characters used to draw table borders. @@ -81,6 +82,28 @@ export interface ITerminalTableOptions { * borderless mode. */ borderCharacters?: Partial; + + /** + * A function to apply styling to all border and grid line characters. + * + * @example + * ```typescript + * import { Colorize } from '@rushstack/terminal'; + * new TerminalTable({ borderColor: Colorize.gray }) + * ``` + */ + borderColor?: (text: string) => string; + + /** + * A function to apply styling to the text within header row cells. + * + * @example + * ```typescript + * import { Colorize } from '@rushstack/terminal'; + * new TerminalTable({ headingColor: Colorize.bold }) + * ``` + */ + headingColor?: (text: string) => string; } const BORDERLESS_CHARS: ITerminalTableChars = { @@ -122,9 +145,6 @@ const DEFAULT_CHARS: ITerminalTableChars = { /** * Renders text data as a fixed-column table suitable for terminal output. * - * Designed as a drop-in replacement for the `cli-table` and `cli-table3` npm packages, - * with correct handling of ANSI escape sequences when calculating column widths. - * * @example * ```typescript * const table = new TerminalTable({ head: ['Name', 'Version'] }); @@ -139,16 +159,20 @@ export class TerminalTable { private readonly _head: string[]; private readonly _specifiedColWidths: (number | undefined)[]; private readonly _borderCharacters: ITerminalTableChars; + private readonly _borderColor: ((text: string) => string) | undefined; + private readonly _headingColor: ((text: string) => string) | undefined; private readonly _rows: string[][]; public constructor(options: ITerminalTableOptions = {}) { - const { head, colWidths, borderless, borderCharacters } = options; + const { head, colWidths, borderless, borderCharacters, borderColor, headingColor } = options; this._head = head ?? []; this._specifiedColWidths = colWidths ?? []; this._borderCharacters = { ...(borderless ? BORDERLESS_CHARS : DEFAULT_CHARS), ...borderCharacters }; + this._borderColor = borderColor; + this._headingColor = headingColor; this._rows = []; } @@ -166,6 +190,8 @@ export class TerminalTable { _head: head, _rows: rows, _specifiedColWidths: specifiedColWidths, + _borderColor: borderColor, + _headingColor: headingColor, _borderCharacters: { top: topSeparator, topCenter: topCenterSeparator, @@ -213,69 +239,80 @@ export class TerminalTable { } } - // Renders a horizontal separator line. Returns undefined if the result would be empty. - const renderSeparator = ( + // Builds a styled horizontal separator line; returns undefined if fillChar is empty (suppressed). + const buildSepLine = ( leftChar: string, fillChar: string, midChar: string, rightChar: string ): string | undefined => { + if (fillChar.length === 0) { + return undefined; + } const line: string = leftChar + columnWidths.map((w) => fillChar.repeat(w)).join(midChar) + rightChar; - return line.length > 0 ? line : undefined; + return borderColor ? borderColor(line) : line; }; - // Renders a single data row. - const renderRow = (row: string[]): string => { + // Pre-compute all separator lines (borderColor applied once per line, not per character). + const topLine: string | undefined = buildSepLine( + topLeftSeparator, + topSeparator, + topCenterSeparator, + topRightSeparator + ); + const centerLine: string | undefined = buildSepLine( + leftCenterSeparator, + horizontalCenterSeparator, + centerCenterSeparator, + rightCenterSeparator + ); + const bottomLine: string | undefined = buildSepLine( + bottomLeftSeparator, + bottomSeparator, + bottomCenterSeparator, + bottomRightSeparator + ); + + // Pre-colorize vertical border chars used in data rows. + const styledLeft: string = borderColor && leftSeparator ? borderColor(leftSeparator) : leftSeparator; + const styledMid: string = + borderColor && verticalCenterSeparator ? borderColor(verticalCenterSeparator) : verticalCenterSeparator; + const styledRight: string = borderColor && rightSeparator ? borderColor(rightSeparator) : rightSeparator; + + // Renders a single data row. If contentColor is provided, it is applied to each cell's text. + const renderRow = (row: string[], contentColor?: (text: string) => string): string => { const cells: string[] = []; for (let col: number = 0; col < columnCount; col++) { const content: string = col < row.length ? row[col] : ''; const visualWidth: number = AnsiEscape.removeCodes(content).length; // 1 char of left-padding; right-padding fills the remainder of the column width. const padRight: number = Math.max(columnWidths[col] - 1 - visualWidth, 0); - cells.push(' ' + content + ' '.repeat(padRight)); + const styledContent: string = content && contentColor ? contentColor(content) : content; + cells.push(' ' + styledContent + ' '.repeat(padRight)); } - return leftSeparator + cells.join(verticalCenterSeparator) + rightSeparator; + return styledLeft + cells.join(styledMid) + styledRight; }; const lines: string[] = []; - // Top border - const topLine: string | undefined = renderSeparator( - topLeftSeparator, - topSeparator, - topCenterSeparator, - topRightSeparator - ); if (topLine !== undefined) { lines.push(topLine); } - // Header row + separator if (head.length > 0) { - lines.push(renderRow(head)); - const headerSep: string | undefined = renderSeparator( - leftCenterSeparator, - horizontalCenterSeparator, - centerCenterSeparator, - rightCenterSeparator - ); - if (headerSep !== undefined) { - lines.push(headerSep); + lines.push(renderRow(head, headingColor)); + if (centerLine !== undefined) { + lines.push(centerLine); } } - // Data rows (no separator between them) - for (const row of this._rows) { - lines.push(renderRow(row)); + for (let i: number = 0; i < this._rows.length; i++) { + lines.push(renderRow(this._rows[i])); + if (i < this._rows.length - 1 && centerLine !== undefined) { + lines.push(centerLine); + } } - // Bottom border - const bottomLine: string | undefined = renderSeparator( - bottomLeftSeparator, - bottomSeparator, - bottomCenterSeparator, - bottomRightSeparator - ); if (bottomLine !== undefined) { lines.push(bottomLine); } @@ -290,4 +327,13 @@ export class TerminalTable { const lines: string[] = this.getLines(); return lines.join('\n'); } + + /** + * Writes the rendered table to the provided terminal, one line at a time. + */ + public printToTerminal(terminal: ITerminal): void { + for (const line of this.getLines()) { + terminal.writeLine(line); + } + } } diff --git a/libraries/terminal/src/index.ts b/libraries/terminal/src/index.ts index b1d26c135c..89d732acc5 100644 --- a/libraries/terminal/src/index.ts +++ b/libraries/terminal/src/index.ts @@ -21,7 +21,7 @@ export { type INormalizeNewlinesTextRewriterOptions, NormalizeNewlinesTextRewriter } from './NormalizeNewlinesTextRewriter'; -export { DEFAULT_CONSOLE_WIDTH, PrintUtilities } from './PrintUtilities'; +export { DEFAULT_CONSOLE_WIDTH, type IPrintMessageInBoxOptions, PrintUtilities } from './PrintUtilities'; export { RemoveColorsTextRewriter } from './RemoveColorsTextRewriter'; export { type ISplitterTransformOptions, SplitterTransform } from './SplitterTransform'; export { type IStdioLineTransformOptions, StderrLineTransform } from './StdioLineTransform'; diff --git a/libraries/terminal/src/test/PrintUtilities.test.ts b/libraries/terminal/src/test/PrintUtilities.test.ts index f90cf0807a..888c0f8efc 100644 --- a/libraries/terminal/src/test/PrintUtilities.test.ts +++ b/libraries/terminal/src/test/PrintUtilities.test.ts @@ -3,17 +3,11 @@ import { StringBufferTerminalProvider } from '../StringBufferTerminalProvider'; import { Terminal } from '../Terminal'; +import { AnsiEscape } from '../AnsiEscape'; +import { Colorize } from '../Colorize'; import { PrintUtilities } from '../PrintUtilities'; describe(PrintUtilities.name, () => { - let terminalProvider: StringBufferTerminalProvider; - let terminal: Terminal; - - beforeEach(() => { - terminalProvider = new StringBufferTerminalProvider(false); - terminal = new Terminal(terminalProvider); - }); - afterEach(() => { jest.resetAllMocks(); }); @@ -97,25 +91,39 @@ describe(PrintUtilities.name, () => { }); describe(PrintUtilities.printMessageInBox.name, () => { + let terminalProvider: StringBufferTerminalProvider; + let terminal: Terminal; + + beforeEach(() => { + terminalProvider = new StringBufferTerminalProvider(true); + terminal = new Terminal(terminalProvider); + }); + + afterEach(() => { + expect(terminalProvider.getAllOutputAsChunks({ asLines: true })).toMatchSnapshot(); + }); + function validateOutput(expectedWidth: number): void { const outputLines: string[] = terminalProvider .getOutput({ normalizeSpecialCharacters: false }) .split('\n'); - expect(outputLines).toMatchSnapshot(); - - expect(outputLines.every((x) => x.length <= expectedWidth)); + expect( + outputLines.every((x) => { + return AnsiEscape.removeCodes(x).length <= expectedWidth; + }) + ); } const MESSAGE: string = 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna.'; it('prints a long message wrapped in narrow box', () => { - PrintUtilities.printMessageInBox(MESSAGE, terminal, 20); + PrintUtilities.printMessageInBox(MESSAGE, terminal, { boxWidth: 20 }); validateOutput(20); }); it('prints a long message wrapped in a wide box', () => { - PrintUtilities.printMessageInBox(MESSAGE, terminal, 300); + PrintUtilities.printMessageInBox(MESSAGE, terminal, { boxWidth: 300 }); validateOutput(300); }); @@ -134,7 +142,7 @@ describe(PrintUtilities.name, () => { '' ].join('\n'); - PrintUtilities.printMessageInBox(userMessage, terminal, 50); + PrintUtilities.printMessageInBox(userMessage, terminal, { boxWidth: 50 }); validateOutput(50); }); @@ -146,10 +154,38 @@ describe(PrintUtilities.name, () => { '' ].join('\n'); - PrintUtilities.printMessageInBox(userMessage, terminal, 50); + PrintUtilities.printMessageInBox(userMessage, terminal, { boxWidth: 50 }); validateOutput(50); }); + it('applies borderColor to all box border characters', () => { + PrintUtilities.printMessageInBox('Hello world', terminal, { boxWidth: 30, borderColor: Colorize.cyan }); + validateOutput(30); + }); + + it('applies messageColor to message text inside the box', () => { + PrintUtilities.printMessageInBox('Hello world', terminal, { + boxWidth: 30, + messageColor: Colorize.bold + }); + validateOutput(30); + }); + + it('applies borderColor and messageColor together', () => { + PrintUtilities.printMessageInBox('Hello world', terminal, { + boxWidth: 30, + borderColor: Colorize.gray, + messageColor: Colorize.bold + }); + + validateOutput(30); + }); + + it('applies borderColor in the banner (wide-content) fallback layout', () => { + PrintUtilities.printMessageInBox('Hello world', terminal, { boxWidth: 5, borderColor: Colorize.cyan }); + validateOutput(5); + }); + it('word-wraps a message with a trailing fragment', () => { const lines: string[] = PrintUtilities.wrapWordsToLines( 'This Thursday, we will complete the Node.js version upgrade. Any pipelines that still have not upgraded will be temporarily disabled.', diff --git a/libraries/terminal/src/test/TerminalTable.test.ts b/libraries/terminal/src/test/TerminalTable.test.ts index 287e276eef..7504249037 100644 --- a/libraries/terminal/src/test/TerminalTable.test.ts +++ b/libraries/terminal/src/test/TerminalTable.test.ts @@ -1,21 +1,29 @@ // Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. // See LICENSE in the project root for license information. +import { AnsiEscape } from '../AnsiEscape'; +import { Colorize } from '../Colorize'; +import { StringBufferTerminalProvider } from '../StringBufferTerminalProvider'; +import { Terminal } from '../Terminal'; import { TerminalTable } from '../TerminalTable'; +function expectSnapshot(table: TerminalTable): void { + expect(AnsiEscape.formatForTests(table.toString())).toMatchSnapshot(); +} + describe(TerminalTable.name, () => { it('renders a table with a header and rows', () => { const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version'] }); table.push(['@rushstack/terminal', '1.0.0']); table.push(['@rushstack/heft', '2.0.0']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); it('renders a table without a header', () => { const table: TerminalTable = new TerminalTable(); table.push(['foo', 'bar']); table.push(['baz', 'qux']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); it('auto-sizes columns to the widest content', () => { @@ -26,15 +34,13 @@ describe(TerminalTable.name, () => { const lines: string[] = output.split('\n'); const dataRow: string = lines.find((l) => l.includes('short'))!; expect(dataRow).toContain('a very long value here'); - expect(output).toMatchSnapshot(); + expectSnapshot(table); }); it('respects fixed colWidths', () => { const table: TerminalTable = new TerminalTable({ colWidths: [10, 8] }); table.push(['hi', 'there']); - const row: string = table.toString(); - // Cell 0 padded to 10, cell 1 padded to 8 - expect(row).toMatchSnapshot(); + expectSnapshot(table); }); it('borderless: true suppresses all borders', () => { @@ -44,7 +50,7 @@ describe(TerminalTable.name, () => { }); table.push(['alpha', 'beta', 'g']); table.push(['longer text', 'x', 'y']); - expect(table.toString()).toMatchSnapshot(); + expectSnapshot(table); }); it('produces one line per row when borderless (for inquirer-style usage)', () => { @@ -66,25 +72,89 @@ describe(TerminalTable.name, () => { colWidths: [10, 8] }); table.push(['hello', 'world']); - const row: string = table.toString(); - expect(row).toContain(' | '); - expect(table.toString()).toMatchSnapshot(); + expect(table.toString()).toContain(' | '); + expectSnapshot(table); }); it('strips ANSI codes when calculating column widths', () => { const table: TerminalTable = new TerminalTable({ head: ['Package'] }); // Simulate a colored package name — ANSI codes should not inflate the column width - const colored: string = '\x1b[33mmy-package\x1b[0m'; // yellow "my-package" (10 chars visible) + const colored: string = Colorize.yellow('my-package'); // yellow "my-package" (10 chars visible) table.push([colored]); - const output: string = table.toString(); // Column width should be 10 + 2 = 12 (not inflated by escape codes) - const dataRow: string = output.split('\n').find((l) => l.includes('my-package'))!; + const dataRow: string = table.getLines().find((l) => l.includes('my-package'))!; expect(dataRow).toBeDefined(); - expect(output).toMatchSnapshot(); + expectSnapshot(table); }); it('returns empty string for an empty table', () => { const table: TerminalTable = new TerminalTable(); expect(table.toString()).toBe(''); }); + + it('renders a table with more than two columns', () => { + const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version', 'License'] }); + table.push(['@rushstack/terminal', '1.0.0', 'MIT']); + table.push(['@rushstack/heft', '2.0.0', 'MIT']); + expectSnapshot(table); + }); + + it('renders a single data row with no spurious trailing separator', () => { + const table: TerminalTable = new TerminalTable(); + table.push(['only', 'row']); + expectSnapshot(table); + }); + + it('renders a header with no data rows', () => { + const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version'] }); + expectSnapshot(table); + }); + + it('borderColor is applied to all border characters', () => { + const table: TerminalTable = new TerminalTable({ + head: ['Name', 'Version'], + borderColor: Colorize.cyan + }); + table.push(['foo', '1.0.0']); + table.push(['bar', '2.0.0']); + expectSnapshot(table); + }); + + it('headingColor is applied to header cell text but not to borders or data rows', () => { + const table: TerminalTable = new TerminalTable({ + head: ['Name', 'Version'], + headingColor: Colorize.bold + }); + table.push(['foo', '1.0.0']); + expectSnapshot(table); + }); + + it('borderColor and headingColor can be combined', () => { + const table: TerminalTable = new TerminalTable({ + head: ['Name', 'Version'], + borderColor: Colorize.gray, + headingColor: Colorize.bold + }); + table.push(['foo', '1.0.0']); + expectSnapshot(table); + }); + + it('setting horizontalCenter to empty suppresses row and header separators', () => { + const table: TerminalTable = new TerminalTable({ + head: ['A', 'B'], + borderCharacters: { horizontalCenter: '' } + }); + table.push(['x', 'y']); + table.push(['z', 'w']); + expectSnapshot(table); + }); + + it('printToTerminal writes each line to the terminal', () => { + const terminalProvider: StringBufferTerminalProvider = new StringBufferTerminalProvider(true); + const terminal: Terminal = new Terminal(terminalProvider); + const table: TerminalTable = new TerminalTable({ head: ['Name', 'Version'] }); + table.push(['@rushstack/terminal', '1.0.0']); + table.printToTerminal(terminal); + expect(terminalProvider.getAllOutputAsChunks({ asLines: true })).toMatchSnapshot(); + }); }); diff --git a/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap b/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap index ba195e50a0..66ac471b38 100644 --- a/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/PrintUtilities.test.ts.snap @@ -2,96 +2,127 @@ exports[`PrintUtilities printMessageInBox Handles a case where there is a word longer than the boxwidth 1`] = ` Array [ - " ══════════════════════════════════════════════════", - " Annnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn", - " error occurred while pushing commits to", - " git remote. Please make sure you have", - " installed and enabled git lfs. The", - " easiest way to do that is run the", - " provided setup script:", - " ", - " common/scripts/setup.sh", - " ", - " ══════════════════════════════════════════════════", - "", + "[ log] ══════════════════════════════════════════════════[n]", + "[ log] Annnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnn[n]", + "[ log] error occurred while pushing commits to[n]", + "[ log] git remote. Please make sure you have[n]", + "[ log] installed and enabled git lfs. The[n]", + "[ log] easiest way to do that is run the[n]", + "[ log] provided setup script:[n]", + "[ log] [n]", + "[ log] common/scripts/setup.sh[n]", + "[ log] [n]", + "[ log] ══════════════════════════════════════════════════[n]", +] +`; + +exports[`PrintUtilities printMessageInBox applies borderColor and messageColor together 1`] = ` +Array [ + "[ log] [gray]╔════════════════════════════╗[default][n]", + "[ log] [gray]║[default] [bold]Hello world[normal] [gray]║[default][n]", + "[ log] [gray]╚════════════════════════════╝[default][n]", +] +`; + +exports[`PrintUtilities printMessageInBox applies borderColor in the banner (wide-content) fallback layout 1`] = ` +Array [ + "[ log] [cyan]═════[default][n]", + "[ log] Hello[n]", + "[ log] [n]", + "[ log] world[n]", + "[ log] [cyan]═════[default][n]", +] +`; + +exports[`PrintUtilities printMessageInBox applies borderColor to all box border characters 1`] = ` +Array [ + "[ log] [cyan]╔════════════════════════════╗[default][n]", + "[ log] [cyan]║[default] Hello world [cyan]║[default][n]", + "[ log] [cyan]╚════════════════════════════╝[default][n]", +] +`; + +exports[`PrintUtilities printMessageInBox applies messageColor to message text inside the box 1`] = ` +Array [ + "[ log] ╔════════════════════════════╗[n]", + "[ log] ║ [bold]Hello world[normal] ║[n]", + "[ log] ╚════════════════════════════╝[n]", ] `; exports[`PrintUtilities printMessageInBox prints a long message wrapped in a box using the console width 1`] = ` Array [ - " ╔══════════════════════════════╗", - " ║ Lorem ipsum dolor sit ║", - " ║ amet, consectetuer ║", - " ║ adipiscing elit. ║", - " ║ Maecenas porttitor ║", - " ║ congue massa. Fusce ║", - " ║ posuere, magna sed ║", - " ║ pulvinar ultricies, ║", - " ║ purus lectus malesuada ║", - " ║ libero, sit amet ║", - " ║ commodo magna eros ║", - " ║ quis urna. ║", - " ╚══════════════════════════════╝", - "", + "[ log] ╔══════════════════════════════╗[n]", + "[ log] ║ Lorem ipsum dolor sit ║[n]", + "[ log] ║ amet, consectetuer ║[n]", + "[ log] ║ adipiscing elit. ║[n]", + "[ log] ║ Maecenas porttitor ║[n]", + "[ log] ║ congue massa. Fusce ║[n]", + "[ log] ║ posuere, magna sed ║[n]", + "[ log] ║ pulvinar ultricies, ║[n]", + "[ log] ║ purus lectus malesuada ║[n]", + "[ log] ║ libero, sit amet ║[n]", + "[ log] ║ commodo magna eros ║[n]", + "[ log] ║ quis urna. ║[n]", + "[ log] ╚══════════════════════════════╝[n]", ] `; exports[`PrintUtilities printMessageInBox prints a long message wrapped in a wide box 1`] = ` Array [ - " ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗", - " ║ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna. ║", - " ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝", - "", + "[ log] ╔══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╗[n]", + "[ log] ║ Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Maecenas porttitor congue massa. Fusce posuere, magna sed pulvinar ultricies, purus lectus malesuada libero, sit amet commodo magna eros quis urna. ║[n]", + "[ log] ╚══════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════════╝[n]", ] `; exports[`PrintUtilities printMessageInBox prints a long message wrapped in narrow box 1`] = ` Array [ - " ╔══════════════════╗", - " ║ Lorem ║", - " ║ ipsum ║", - " ║ dolor sit ║", - " ║ amet, ║", - " ║ consectetuer ║", - " ║ adipiscing elit. ║", - " ║ Maecenas ║", - " ║ porttitor ║", - " ║ congue ║", - " ║ massa. ║", - " ║ Fusce ║", - " ║ posuere, ║", - " ║ magna sed ║", - " ║ pulvinar ║", - " ║ ultricies, ║", - " ║ purus ║", - " ║ lectus ║", - " ║ malesuada ║", - " ║ libero, ║", - " ║ sit amet ║", - " ║ commodo ║", - " ║ magna eros ║", - " ║ quis urna. ║", - " ╚══════════════════╝", - "", + "[ log] ╔══════════════════╗[n]", + "[ log] ║ Lorem ║[n]", + "[ log] ║ ipsum ║[n]", + "[ log] ║ dolor sit ║[n]", + "[ log] ║ amet, ║[n]", + "[ log] ║ consectetuer ║[n]", + "[ log] ║ adipiscing elit. ║[n]", + "[ log] ║ Maecenas ║[n]", + "[ log] ║ porttitor ║[n]", + "[ log] ║ congue ║[n]", + "[ log] ║ massa. ║[n]", + "[ log] ║ Fusce ║[n]", + "[ log] ║ posuere, ║[n]", + "[ log] ║ magna sed ║[n]", + "[ log] ║ pulvinar ║[n]", + "[ log] ║ ultricies, ║[n]", + "[ log] ║ purus ║[n]", + "[ log] ║ lectus ║[n]", + "[ log] ║ malesuada ║[n]", + "[ log] ║ libero, ║[n]", + "[ log] ║ sit amet ║[n]", + "[ log] ║ commodo ║[n]", + "[ log] ║ magna eros ║[n]", + "[ log] ║ quis urna. ║[n]", + "[ log] ╚══════════════════╝[n]", ] `; exports[`PrintUtilities printMessageInBox respects spaces and newlines in a pre-formatted message 1`] = ` Array [ - " ╔════════════════════════════════════════════════╗", - " ║ An error occurred while pushing commits ║", - " ║ to git remote. Please make sure you have ║", - " ║ installed and enabled git lfs. The ║", - " ║ easiest way to do that is run the ║", - " ║ provided setup script: ║", - " ║ ║", - " ║ common/scripts/setup.sh ║", - " ║ ║", - " ╚════════════════════════════════════════════════╝", - "", + "[ log] ╔════════════════════════════════════════════════╗[n]", + "[ log] ║ An error occurred while pushing commits ║[n]", + "[ log] ║ to git remote. Please make sure you have ║[n]", + "[ log] ║ installed and enabled git lfs. The ║[n]", + "[ log] ║ easiest way to do that is run the ║[n]", + "[ log] ║ provided setup script: ║[n]", + "[ log] ║ ║[n]", + "[ log] ║ common/scripts/setup.sh ║[n]", + "[ log] ║ ║[n]", + "[ log] ╚════════════════════════════════════════════════╝[n]", ] `; +exports[`PrintUtilities printMessageInBox word-wraps a message with a trailing fragment 2`] = `Array []`; + exports[`PrintUtilities wrapWordsToLines with prefix="| " applies pre-existing indents on both margins 1`] = ` Array [ "| Lorem ipsum dolor sit amet, consectetuer", diff --git a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap index 6046e64bac..b4d2790078 100644 --- a/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap +++ b/libraries/terminal/src/test/__snapshots__/TerminalTable.test.ts.snap @@ -8,6 +8,24 @@ exports[`TerminalTable auto-sizes columns to the widest content 1`] = ` └───────┴────────────────────────┘" `; +exports[`TerminalTable borderColor and headingColor can be combined 1`] = ` +"[gray]┌──────┬─────────┐[default] +[gray]│[default] [bold]Name[normal] [gray]│[default] [bold]Version[normal] [gray]│[default] +[gray]├──────┼─────────┤[default] +[gray]│[default] foo [gray]│[default] 1.0.0 [gray]│[default] +[gray]└──────┴─────────┘[default]" +`; + +exports[`TerminalTable borderColor is applied to all border characters 1`] = ` +"[cyan]┌──────┬─────────┐[default] +[cyan]│[default] Name [cyan]│[default] Version [cyan]│[default] +[cyan]├──────┼─────────┤[default] +[cyan]│[default] foo [cyan]│[default] 1.0.0 [cyan]│[default] +[cyan]├──────┼─────────┤[default] +[cyan]│[default] bar [cyan]│[default] 2.0.0 [cyan]│[default] +[cyan]└──────┴─────────┘[default]" +`; + exports[`TerminalTable borderless: true suppresses all borders 1`] = ` " alpha beta g longer text x y " @@ -15,18 +33,61 @@ exports[`TerminalTable borderless: true suppresses all borders 1`] = ` exports[`TerminalTable chars overrides are applied on top of borderless 1`] = `" hello | world "`; +exports[`TerminalTable headingColor is applied to header cell text but not to borders or data rows 1`] = ` +"┌──────┬─────────┐ +│ [bold]Name[normal] │ [bold]Version[normal] │ +├──────┼─────────┤ +│ foo │ 1.0.0 │ +└──────┴─────────┘" +`; + +exports[`TerminalTable printToTerminal writes each line to the terminal 1`] = ` +Array [ + "[ log] ┌─────────────────────┬─────────┐[n]", + "[ log] │ Name │ Version │[n]", + "[ log] ├─────────────────────┼─────────┤[n]", + "[ log] │ @rushstack/terminal │ 1.0.0 │[n]", + "[ log] └─────────────────────┴─────────┘[n]", +] +`; + +exports[`TerminalTable renders a header with no data rows 1`] = ` +"┌──────┬─────────┐ +│ Name │ Version │ +├──────┼─────────┤ +└──────┴─────────┘" +`; + +exports[`TerminalTable renders a single data row with no spurious trailing separator 1`] = ` +"┌──────┬─────┐ +│ only │ row │ +└──────┴─────┘" +`; + exports[`TerminalTable renders a table with a header and rows 1`] = ` "┌─────────────────────┬─────────┐ │ Name │ Version │ ├─────────────────────┼─────────┤ │ @rushstack/terminal │ 1.0.0 │ +├─────────────────────┼─────────┤ │ @rushstack/heft │ 2.0.0 │ └─────────────────────┴─────────┘" `; +exports[`TerminalTable renders a table with more than two columns 1`] = ` +"┌─────────────────────┬─────────┬─────────┐ +│ Name │ Version │ License │ +├─────────────────────┼─────────┼─────────┤ +│ @rushstack/terminal │ 1.0.0 │ MIT │ +├─────────────────────┼─────────┼─────────┤ +│ @rushstack/heft │ 2.0.0 │ MIT │ +└─────────────────────┴─────────┴─────────┘" +`; + exports[`TerminalTable renders a table without a header 1`] = ` "┌─────┬─────┐ │ foo │ bar │ +├─────┼─────┤ │ baz │ qux │ └─────┴─────┘" `; @@ -37,10 +98,18 @@ exports[`TerminalTable respects fixed colWidths 1`] = ` └──────────┴────────┘" `; +exports[`TerminalTable setting horizontalCenter to empty suppresses row and header separators 1`] = ` +"┌───┬───┐ +│ A │ B │ +│ x │ y │ +│ z │ w │ +└───┴───┘" +`; + exports[`TerminalTable strips ANSI codes when calculating column widths 1`] = ` "┌────────────┐ │ Package │ ├────────────┤ -│ my-package │ +│ [yellow]my-package[default] │ └────────────┘" `;