From 61dcc6c2e9c384a9e6c3e6719d25d34abf3c8fda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 7 Nov 2025 17:57:26 +0100 Subject: [PATCH 1/9] refactor: use default ansis import for consistency --- .../cli/src/lib/implementation/formatting.ts | 8 +++--- .../implementation/formatting.unit.test.ts | 12 ++++----- packages/cli/src/lib/yargs-cli.ts | 26 +++++++++---------- .../src/lib/implementation/execute-plugin.ts | 6 ++--- .../core/src/lib/implementation/runner.ts | 4 +-- .../mocks/fixtures/execute-progress.mock.mjs | 6 ++--- packages/utils/src/lib/progress.ts | 14 +++++----- .../src/lib/utils/test-folder-setup.ts | 6 ++--- .../eslint-formatter-multi/src/lib/stylish.ts | 20 +++++++------- 9 files changed, 51 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/lib/implementation/formatting.ts b/packages/cli/src/lib/implementation/formatting.ts index d1e8e8b51..ad7201d63 100644 --- a/packages/cli/src/lib/implementation/formatting.ts +++ b/packages/cli/src/lib/implementation/formatting.ts @@ -1,15 +1,15 @@ -import { bold, dim, green } from 'ansis'; +import ansis from 'ansis'; export function titleStyle(title: string) { - return `${bold(title)}`; + return `${ansis.bold(title)}`; } export function headerStyle(title: string) { - return `${green(title)}`; + return `${ansis.green(title)}`; } export function descriptionStyle(title: string) { - return `${dim(title)}`; + return `${ansis.dim(title)}`; } export function formatObjectValue(opts: T, propName: keyof T) { diff --git a/packages/cli/src/lib/implementation/formatting.unit.test.ts b/packages/cli/src/lib/implementation/formatting.unit.test.ts index 3ba992c11..c1b9fc943 100644 --- a/packages/cli/src/lib/implementation/formatting.unit.test.ts +++ b/packages/cli/src/lib/implementation/formatting.unit.test.ts @@ -1,4 +1,4 @@ -import { bold, dim, green } from 'ansis'; +import ansis from 'ansis'; import { describe, expect } from 'vitest'; import { descriptionStyle, @@ -10,13 +10,13 @@ import { describe('titleStyle', () => { it('should return a string with green color', () => { - expect(titleStyle('Code Pushup CLI')).toBe(bold('Code Pushup CLI')); + expect(titleStyle('Code Pushup CLI')).toBe(ansis.bold('Code Pushup CLI')); }); }); describe('headerStyle', () => { it('should return a string with green color', () => { - expect(headerStyle('Options')).toBe(green('Options')); + expect(headerStyle('Options')).toBe(ansis.green('Options')); }); }); @@ -27,7 +27,7 @@ describe('descriptionStyle', () => { 'Run collect using custom tsconfig to parse code-pushup.config.ts file.', ), ).toBe( - dim( + ansis.dim( 'Run collect using custom tsconfig to parse code-pushup.config.ts file.', ), ); @@ -44,7 +44,7 @@ describe('formatObjectValue', () => { 'describe', ), ).toEqual({ - describe: dim('Directory for the produced reports'), + describe: ansis.dim('Directory for the produced reports'), }); }); }); @@ -62,7 +62,7 @@ describe('formatNestedValues', () => { ), ).toEqual({ outputDir: { - describe: dim('Directory for the produced reports'), + describe: ansis.dim('Directory for the produced reports'), }, }); }); diff --git a/packages/cli/src/lib/yargs-cli.ts b/packages/cli/src/lib/yargs-cli.ts index 0dbc9ec35..e8c8fdcff 100644 --- a/packages/cli/src/lib/yargs-cli.ts +++ b/packages/cli/src/lib/yargs-cli.ts @@ -1,5 +1,5 @@ /* eslint-disable max-lines-per-function */ -import { blue, dim, green } from 'ansis'; +import ansis from 'ansis'; import { createRequire } from 'node:module'; import yargs, { type Argv, @@ -24,17 +24,17 @@ import { import { logErrorBeforeThrow } from './implementation/global.utils.js'; export const yargsDecorator = { - 'Commands:': `${green('Commands')}:`, - 'Options:': `${green('Options')}:`, - 'Examples:': `${green('Examples')}:`, - boolean: blue('boolean'), - count: blue('count'), - string: blue('string'), - array: blue('array'), - required: blue('required'), - 'default:': `${blue('default')}:`, - 'choices:': `${blue('choices')}:`, - 'aliases:': `${blue('aliases')}:`, + 'Commands:': `${ansis.green('Commands')}:`, + 'Options:': `${ansis.green('Options')}:`, + 'Examples:': `${ansis.green('Examples')}:`, + boolean: ansis.blue('boolean'), + count: ansis.blue('count'), + string: ansis.blue('string'), + array: ansis.blue('array'), + required: ansis.blue('required'), + 'default:': `${ansis.blue('default')}:`, + 'choices:': `${ansis.blue('choices')}:`, + 'aliases:': `${ansis.blue('aliases')}:`, }; /** @@ -80,7 +80,7 @@ export function yargsCli( .help('help', descriptionStyle('Show help')) .alias('h', 'help') .showHelpOnFail(false) - .version('version', dim`Show version`, packageJson.version) + .version('version', ansis.dim('Show version'), packageJson.version) .check(args => { const persist = args['persist'] as PersistConfig | undefined; return persist == null || validatePersistFormat(persist); diff --git a/packages/core/src/lib/implementation/execute-plugin.ts b/packages/core/src/lib/implementation/execute-plugin.ts index 09acb5108..e12dda8be 100644 --- a/packages/core/src/lib/implementation/execute-plugin.ts +++ b/packages/core/src/lib/implementation/execute-plugin.ts @@ -1,4 +1,4 @@ -import { bold } from 'ansis'; +import ansis from 'ansis'; import { type AuditOutput, type AuditReport, @@ -120,7 +120,7 @@ const wrapProgress = async ( progressBar: ProgressBar | null, ) => { const { plugin: pluginCfg, ...rest } = cfg; - progressBar?.updateTitle(`Executing ${bold(pluginCfg.title)}`); + progressBar?.updateTitle(`Executing ${ansis.bold(pluginCfg.title)}`); try { const pluginReport = await executePlugin(pluginCfg, rest); progressBar?.incrementInSteps(steps); @@ -128,7 +128,7 @@ const wrapProgress = async ( } catch (error) { progressBar?.incrementInSteps(steps); throw new Error( - `- Plugin ${bold(pluginCfg.title)} (${bold( + `- Plugin ${ansis.bold(pluginCfg.title)} (${ansis.bold( pluginCfg.slug, )}) produced the following error:\n - ${stringifyError(error)}`, ); diff --git a/packages/core/src/lib/implementation/runner.ts b/packages/core/src/lib/implementation/runner.ts index 7bf9fb650..8aa6174e1 100644 --- a/packages/core/src/lib/implementation/runner.ts +++ b/packages/core/src/lib/implementation/runner.ts @@ -1,4 +1,4 @@ -import { bold } from 'ansis'; +import ansis from 'ansis'; import { writeFile } from 'node:fs/promises'; import path from 'node:path'; import { @@ -64,7 +64,7 @@ export async function executeRunnerFunction( export class AuditOutputsMissingAuditError extends Error { constructor(auditSlug: string) { super( - `Audit metadata not present in plugin config. Missing slug: ${bold( + `Audit metadata not present in plugin config. Missing slug: ${ansis.bold( auditSlug, )}`, ); diff --git a/packages/utils/mocks/fixtures/execute-progress.mock.mjs b/packages/utils/mocks/fixtures/execute-progress.mock.mjs index 728b0e88f..557375f32 100644 --- a/packages/utils/mocks/fixtures/execute-progress.mock.mjs +++ b/packages/utils/mocks/fixtures/execute-progress.mock.mjs @@ -1,4 +1,4 @@ -import { bold, gray } from 'ansis'; +import ansis from 'ansis'; import { getProgressBar } from '../../../../dist/packages/utils'; const _arg = (name, fallback = '') => @@ -27,8 +27,8 @@ const verbose = Boolean(_arg('verbose', false)); (async () => { verbose && console.info( - gray( - `Start progress with duration: ${bold(duration)}, steps: ${bold( + ansis.gray( + `Start progress with duration: ${ansis.bold(duration)}, steps: ${ansis.bold( steps, )}`, ), diff --git a/packages/utils/src/lib/progress.ts b/packages/utils/src/lib/progress.ts index a7d33276f..4a4594499 100644 --- a/packages/utils/src/lib/progress.ts +++ b/packages/utils/src/lib/progress.ts @@ -1,19 +1,19 @@ -import { black, bold, gray, green } from 'ansis'; +import ansis from 'ansis'; import { type CtorOptions, MultiProgressBars } from 'multi-progress-bars'; import { TERMINAL_WIDTH } from './reports/constants.js'; type BarStyles = 'active' | 'done' | 'idle'; type StatusStyles = Record string>; export const barStyles: StatusStyles = { - active: (s: string) => green(s), - done: (s: string) => gray(s), - idle: (s: string) => gray(s), + active: (s: string) => ansis.green(s), + done: (s: string) => ansis.gray(s), + idle: (s: string) => ansis.gray(s), }; export const messageStyles: StatusStyles = { - active: (s: string) => black(s), - done: (s: string) => bold.green(s), - idle: (s: string) => gray(s), + active: (s: string) => ansis.black(s), + done: (s: string) => ansis.bold.green(s), + idle: (s: string) => ansis.gray(s), }; export type ProgressBar = { diff --git a/testing/test-utils/src/lib/utils/test-folder-setup.ts b/testing/test-utils/src/lib/utils/test-folder-setup.ts index 6e7e47518..c6064292e 100644 --- a/testing/test-utils/src/lib/utils/test-folder-setup.ts +++ b/testing/test-utils/src/lib/utils/test-folder-setup.ts @@ -1,4 +1,4 @@ -import { bold } from 'ansis'; +import ansis from 'ansis'; import { mkdir, readdir, rename, rm, stat } from 'node:fs/promises'; import path from 'node:path'; @@ -12,7 +12,7 @@ export async function teardownTestFolder(dirName: string) { const stats = await stat(dirName); if (!stats.isDirectory()) { console.warn( - `⚠️ You are trying to delete a file instead of a directory - ${bold( + `⚠️ You are trying to delete a file instead of a directory - ${ansis.bold( dirName, )}.`, ); @@ -31,7 +31,7 @@ export async function teardownTestFolder(dirName: string) { }); } catch { console.warn( - `⚠️ Failed to delete test artefact ${bold( + `⚠️ Failed to delete test artefact ${ansis.bold( dirName, )} so the folder is still in the file system!\nIt may require a deletion before running e2e tests again.`, ); diff --git a/tools/eslint-formatter-multi/src/lib/stylish.ts b/tools/eslint-formatter-multi/src/lib/stylish.ts index 43834d3d0..3497f41e6 100644 --- a/tools/eslint-formatter-multi/src/lib/stylish.ts +++ b/tools/eslint-formatter-multi/src/lib/stylish.ts @@ -4,7 +4,7 @@ * @fileoverview Stylish reporter * @author Sindre Sorhus */ -import { bold, dim, red, reset, underline, yellow } from 'ansis'; +import ansis from 'ansis'; import type { ESLint } from 'eslint'; import { stripVTControlCharacters } from 'node:util'; import { textTable } from './text-table.js'; @@ -49,7 +49,7 @@ export function stylishFormatter(results: ESLint.LintResult[]): string { fixableErrorCount += result.fixableErrorCount; fixableWarningCount += result.fixableWarningCount; - output += `${underline(result.filePath)}\n`; + output += `${ansis.underline(result.filePath)}\n`; output += `${textTable( messages.map(message => { @@ -57,10 +57,10 @@ export function stylishFormatter(results: ESLint.LintResult[]): string { let messageType; if (message.fatal || message.severity === 2) { - messageType = red('error'); + messageType = ansis.red('error'); summaryColor = 'red'; } else { - messageType = yellow('warning'); + messageType = ansis.yellow('warning'); } return [ @@ -69,7 +69,7 @@ export function stylishFormatter(results: ESLint.LintResult[]): string { String(message.column || 0), messageType, message.message.replace(/([^ ])\.$/u, '$1'), - dim(message.ruleId || ''), + ansis.dim(message.ruleId || ''), ]; }), { @@ -81,7 +81,7 @@ export function stylishFormatter(results: ESLint.LintResult[]): string { ) .split('\n') .map(el => - el.replace(/(\d+)\s+(\d+)/u, (m, p1, p2) => dim(`${p1}:${p2}`)), + el.replace(/(\d+)\s+(\d+)/u, (m, p1, p2) => ansis.dim(`${p1}:${p2}`)), ) .join('\n')}\n\n`; }); @@ -89,8 +89,8 @@ export function stylishFormatter(results: ESLint.LintResult[]): string { const total = errorCount + warningCount; if (total > 0) { - const colorFn = summaryColor === 'red' ? red : yellow; - output += bold( + const colorFn = summaryColor === 'red' ? ansis.red : ansis.yellow; + output += ansis.bold( colorFn( [ '\u2716 ', @@ -108,7 +108,7 @@ export function stylishFormatter(results: ESLint.LintResult[]): string { ); if (fixableErrorCount > 0 || fixableWarningCount > 0) { - output += bold( + output += ansis.bold( colorFn( [ ' ', @@ -125,5 +125,5 @@ export function stylishFormatter(results: ESLint.LintResult[]): string { } // Resets output color, for prevent change on top level - return total > 0 ? reset(output) : ''; + return total > 0 ? ansis.reset(output) : ''; } From 2998c5880eaa7b00bb5ab076b73bbb4a56e7b6a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Tue, 11 Nov 2025 18:43:37 +0100 Subject: [PATCH 2/9] feat(utils): implement and test ascii table formatting --- packages/utils/src/index.ts | 1 - packages/utils/src/lib/logging.ts | 2 +- packages/utils/src/lib/progress.ts | 2 +- packages/utils/src/lib/reports/constants.ts | 3 - .../src/lib/reports/log-stdout-summary.ts | 2 +- .../utils/src/lib/text-formats/ascii/table.ts | 224 ++++++++++++ .../lib/text-formats/ascii/table.unit.test.ts | 336 ++++++++++++++++++ .../utils/src/lib/text-formats/constants.ts | 3 + packages/utils/src/lib/text-formats/index.ts | 8 +- 9 files changed, 573 insertions(+), 8 deletions(-) create mode 100644 packages/utils/src/lib/text-formats/ascii/table.ts create mode 100644 packages/utils/src/lib/text-formats/ascii/table.unit.test.ts diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 3ecf22b51..674ae0e0f 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -118,7 +118,6 @@ export { CODE_PUSHUP_UNICODE_LOGO, FOOTER_PREFIX, README_LINK, - TERMINAL_WIDTH, } from './lib/reports/constants.js'; export { listAuditsFromAllPlugins, diff --git a/packages/utils/src/lib/logging.ts b/packages/utils/src/lib/logging.ts index d056198ed..94708407d 100644 --- a/packages/utils/src/lib/logging.ts +++ b/packages/utils/src/lib/logging.ts @@ -1,7 +1,7 @@ import isaacs_cliui from '@isaacs/cliui'; import { cliui } from '@poppinss/cliui'; import ansis from 'ansis'; -import { TERMINAL_WIDTH } from './reports/constants.js'; +import { TERMINAL_WIDTH } from './text-formats/constants.js'; // TODO: remove once logger is used everywhere diff --git a/packages/utils/src/lib/progress.ts b/packages/utils/src/lib/progress.ts index 4a4594499..fa642151c 100644 --- a/packages/utils/src/lib/progress.ts +++ b/packages/utils/src/lib/progress.ts @@ -1,6 +1,6 @@ import ansis from 'ansis'; import { type CtorOptions, MultiProgressBars } from 'multi-progress-bars'; -import { TERMINAL_WIDTH } from './reports/constants.js'; +import { TERMINAL_WIDTH } from './text-formats/constants.js'; type BarStyles = 'active' | 'done' | 'idle'; type StatusStyles = Record string>; diff --git a/packages/utils/src/lib/reports/constants.ts b/packages/utils/src/lib/reports/constants.ts index a40ac5e98..e2f6f9c0a 100644 --- a/packages/utils/src/lib/reports/constants.ts +++ b/packages/utils/src/lib/reports/constants.ts @@ -1,8 +1,5 @@ import { HIERARCHY } from '../text-formats/index.js'; -// https://stackoverflow.com/questions/4651012/why-is-the-default-terminal-width-80-characters/4651037#4651037 -export const TERMINAL_WIDTH = 80; - export const SCORE_COLOR_RANGE = { /* eslint-disable @typescript-eslint/no-magic-numbers */ GREEN_MIN: 0.9, diff --git a/packages/utils/src/lib/reports/log-stdout-summary.ts b/packages/utils/src/lib/reports/log-stdout-summary.ts index 39425b3a8..38a0ba4ed 100644 --- a/packages/utils/src/lib/reports/log-stdout-summary.ts +++ b/packages/utils/src/lib/reports/log-stdout-summary.ts @@ -3,12 +3,12 @@ import ansis from 'ansis'; import type { AuditReport } from '@code-pushup/models'; import { logger } from '../logger.js'; import { ui } from '../logging.js'; +import { TERMINAL_WIDTH } from '../text-formats/constants.js'; import { CODE_PUSHUP_DOMAIN, FOOTER_PREFIX, REPORT_HEADLINE_TEXT, REPORT_RAW_OVERVIEW_TABLE_HEADERS, - TERMINAL_WIDTH, } from './constants.js'; import type { ScoredReport } from './types.js'; import { diff --git a/packages/utils/src/lib/text-formats/ascii/table.ts b/packages/utils/src/lib/text-formats/ascii/table.ts new file mode 100644 index 000000000..f8df75d82 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/table.ts @@ -0,0 +1,224 @@ +import ansis from 'ansis'; +import type { TableCellAlignment } from 'build-md'; +import type { + Table, + TableAlignment, + TableCellValue, + TableColumnObject, +} from '@code-pushup/models'; +import { TERMINAL_WIDTH } from '../constants.js'; + +type AsciiTableOptions = { + borderless?: boolean; + padding?: number; +}; + +type NormalizedOptions = Required; + +type NormalizedTable = { + rows: TableCell[][]; + columns?: TableCell[]; +}; + +type TableCell = { text: string; alignment: TableAlignment }; + +const DEFAULT_PADDING = 1; +const DEFAULT_ALIGNMENT = 'left' satisfies TableAlignment; +const MAX_WIDTH = TERMINAL_WIDTH; // TODO: use process.stdout.columns? +const DEFAULT_OPTIONS: NormalizedOptions = { + borderless: false, + padding: DEFAULT_PADDING, +}; + +const BORDERS = { + single: { + vertical: '│', + horizontal: '─', + }, + double: { + top: { left: '┌', right: '┐' }, + middle: { left: '├', right: '┤' }, + bottom: { left: '└', right: '┘' }, + }, + triple: { + top: '┬', + middle: '┼', + bottom: '┴', + }, +}; + +export function formatAsciiTable( + table: Table, + options?: AsciiTableOptions, +): string { + const normalizedOptions = { ...DEFAULT_OPTIONS, ...options }; + const normalizedTable = normalizeTable(table); + const formattedTable = formatTable(normalizedTable, normalizedOptions); + + if (table.title) { + return `${table.title}\n\n${formattedTable}`; + } + + return formattedTable; +} + +function formatTable( + table: NormalizedTable, + options: NormalizedOptions, +): string { + // TODO: enforce MAX_WIDTH + const columnWidths = getColumnWidths(table); + + return [ + formatBorderRow('top', columnWidths, options), + ...(table.columns + ? [ + formatContentRow(table.columns, columnWidths, options), + formatBorderRow('middle', columnWidths, options), + ] + : []), + ...table.rows.map(cells => formatContentRow(cells, columnWidths, options)), + formatBorderRow('bottom', columnWidths, options), + ] + .filter(Boolean) + .join('\n'); +} + +function formatBorderRow( + position: 'top' | 'middle' | 'bottom', + columnWidths: number[], + options: NormalizedOptions, +): string { + if (options.borderless) { + return ''; + } + return ansis.dim( + [ + BORDERS.double[position].left, + columnWidths + .map(width => + BORDERS.single.horizontal.repeat(width + 2 * options.padding), + ) + .join(BORDERS.triple[position]), + BORDERS.double[position].right, + ].join(''), + ); +} + +function formatContentRow( + cells: TableCell[], + columnWidths: number[], + options: NormalizedOptions, +): string { + const aligned = cells.map(({ text, alignment }, index) => + alignText(text, alignment, columnWidths[index]), + ); + const spaces = ' '.repeat(options.padding); + const inner = aligned.join( + options.borderless + ? spaces.repeat(2) + : `${spaces}${ansis.dim(BORDERS.single.vertical)}${spaces}`, + ); + if (options.borderless) { + return inner.trimEnd(); + } + return `${ansis.dim(BORDERS.single.vertical)}${spaces}${inner}${spaces}${ansis.dim(BORDERS.single.vertical)}`; +} + +function alignText( + text: string, + alignment: TableAlignment, + width: number | undefined, +): string { + if (!width) { + return text; + } + const missing = width - getTextWidth(text); + switch (alignment) { + case 'left': + return `${text}${' '.repeat(missing)}`; + case 'right': + return `${' '.repeat(missing)}${text}`; + case 'center': + const missingLeft = Math.floor(missing / 2); + const missingRight = missing - missingLeft; + return `${' '.repeat(missingLeft)}${text}${' '.repeat(missingRight)}`; + } +} + +function getColumnWidths(table: NormalizedTable): number[] { + const columnCount = table.columns?.length ?? table.rows[0]?.length ?? 0; + return Array.from({ length: columnCount }).map((_, index) => { + const cells: TableCell[] = [ + table.columns?.[index], + ...table.rows.map(row => row[index]), + ].filter(cell => cell != null); + const texts = cells.map(cell => cell.text); + const widths = texts.map(getTextWidth); + return Math.max(...widths); + }); +} + +function getTextWidth(text: string): number { + return ansis.strip(text).length; +} + +function normalizeTable(table: Table): NormalizedTable { + const rows = normalizeTableRows(table.rows, table.columns); + const columns = normalizeTableColumns(table.columns); + return { rows, ...(columns && { columns }) }; +} + +function normalizeTableColumns( + columns: Table['columns'], +): TableCell[] | undefined { + if ( + columns == null || + columns.length === 0 || + columns.every(column => typeof column === 'string') || + columns.every(column => !normalizeColumnTitle(column)) + ) { + return undefined; + } + return columns.map(column => + createCell(normalizeColumnTitle(column), column.align), + ); +} + +function normalizeColumnTitle(column: TableColumnObject): string { + return column.label ?? column.key; +} + +function normalizeTableRows( + rows: Table['rows'], + columns: Table['columns'], +): TableCell[][] { + const columnCount = + columns?.length ?? Math.max(...rows.map(row => Object.keys(row).length)); + + return rows.map((row): TableCell[] => { + const rowEntries = new Map(Object.entries(row)); + + if (!columns) { + return Array.from({ length: columnCount }).map((_, i): TableCell => { + const value = rowEntries.get(i.toString()); + return createCell(value); + }); + } + + return columns.map((column, index): TableCell => { + const align = typeof column === 'string' ? column : column.align; + const key = typeof column === 'object' ? column.key : index.toString(); + const value = rowEntries.get(key); + return createCell(value, align); + }); + }); +} + +function createCell( + value: TableCellValue | undefined, + alignment: TableCellAlignment = DEFAULT_ALIGNMENT, +): TableCell { + const text = value?.toString()?.trim() ?? ''; + return { text, alignment }; +} diff --git a/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts b/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts new file mode 100644 index 000000000..58aa07b7d --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts @@ -0,0 +1,336 @@ +import ansis from 'ansis'; +import { formatAsciiTable } from './table.js'; + +describe('formatAsciiTable', () => { + it('should format table with primitive rows and no header', () => { + const output = formatAsciiTable({ + rows: [ + ['x', '0'], + ['y', '0'], + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌───┬───┐ +│ x │ 0 │ +│ y │ 0 │ +└───┴───┘ +`.trim(), + ); + }); + + it('should format table with objects rows and column headers', () => { + const output = formatAsciiTable({ + columns: [ + { key: 'x', label: 'X' }, + { key: 'y', label: 'Y' }, + ], + rows: [ + { x: 0, y: 0 }, + { x: 2, y: 0 }, + { x: 0, y: 1 }, + { x: 2, y: 1 }, + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌───┬───┐ +│ X │ Y │ +├───┼───┤ +│ 0 │ 0 │ +│ 2 │ 0 │ +│ 0 │ 1 │ +│ 2 │ 1 │ +└───┴───┘ +`.trim(), + ); + }); + + it('should support various primitive cell values', () => { + const output = formatAsciiTable({ + rows: [['foo', 42, true, null]], + }); + expect(ansis.strip(output)).toBe( + ` +┌─────┬────┬──────┬──┐ +│ foo │ 42 │ true │ │ +└─────┴────┴──────┴──┘ +`.trim(), + ); + }); + + it('should align columns', () => { + const output = formatAsciiTable({ + columns: [ + { key: 'property', label: 'Property', align: 'left' }, + { key: 'required', label: 'Required', align: 'center' }, + { key: 'default', label: 'Default', align: 'right' }, + ], + rows: [ + { property: 'elevation', required: false, default: 0 }, + { property: 'bbox', required: true, default: null }, + { property: 'offset', required: false, default: 0 }, + { property: 'gain', required: false, default: 1 }, + { property: 'proj4', required: false, default: 'EPSG:3857' }, + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌───────────┬──────────┬───────────┐ +│ Property │ Required │ Default │ +├───────────┼──────────┼───────────┤ +│ elevation │ false │ 0 │ +│ bbox │ true │ │ +│ offset │ false │ 0 │ +│ gain │ false │ 1 │ +│ proj4 │ false │ EPSG:3857 │ +└───────────┴──────────┴───────────┘ +`.trim(), + ); + }); + + it('should align columns without header', () => { + const output = formatAsciiTable({ + columns: ['left', 'center', 'right'], + rows: [ + ['elevation', false, 0], + ['altitude', '(*)', null], + ['bbox', true, null], + ['offset', false, 0], + ['gain', false, 1], + ['proj4', false, 'EPSG:3857'], + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌───────────┬───────┬───────────┐ +│ elevation │ false │ 0 │ +│ altitude │ (*) │ │ +│ bbox │ true │ │ +│ offset │ false │ 0 │ +│ gain │ false │ 1 │ +│ proj4 │ false │ EPSG:3857 │ +└───────────┴───────┴───────────┘ +`.trim(), + ); + }); + + it('should fill in empty columns if rows have varying numbers of cells', () => { + const output = formatAsciiTable({ + rows: [ + ['Angular', 'Apollo Client', 'Ant Design'], + ['Angular'], + ['Apollo Server', 'MongoDB'], + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌───────────────┬───────────────┬────────────┐ +│ Angular │ Apollo Client │ Ant Design │ +│ Angular │ │ │ +│ Apollo Server │ MongoDB │ │ +└───────────────┴───────────────┴────────────┘ +`.trim(), + ); + }); + + it("should omit extra columns that aren't listed in header", () => { + const output = formatAsciiTable({ + columns: [ + { key: 'r', label: 'Red' }, + { key: 'g', label: 'Green' }, + { key: 'b', label: 'Blue' }, + ], + rows: [ + { r: 44, g: 61, b: 121 }, + { r: 251, g: 252, b: 255, a: 1 }, + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌─────┬───────┬──────┐ +│ Red │ Green │ Blue │ +├─────┼───────┼──────┤ +│ 44 │ 61 │ 121 │ +│ 251 │ 252 │ 255 │ +└─────┴───────┴──────┘ +`.trim(), + ); + }); + + it('should order cells by columns keys', () => { + const output = formatAsciiTable({ + columns: [ + { key: 'url', label: 'URL' }, + { key: 'duration', label: 'Duration' }, + ], + rows: [ + { duration: '1.2 s', url: 'https://example.com' }, + { duration: '612 ms', url: 'https://example.com/contact' }, + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌─────────────────────────────┬──────────┐ +│ URL │ Duration │ +├─────────────────────────────┼──────────┤ +│ https://example.com │ 1.2 s │ +│ https://example.com/contact │ 612 ms │ +└─────────────────────────────┴──────────┘ +`.trim(), + ); + }); + + it('should use column key if label is missing', () => { + const output = formatAsciiTable({ + columns: [ + { key: 'order', label: '' }, // empty label overrides + { key: 'url' }, + { key: 'duration' }, + ], + rows: [ + { order: '1.', url: '/', duration: '1.2 s' }, + { order: '2.', url: '/contact', duration: '612 ms' }, + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌────┬──────────┬──────────┐ +│ │ url │ duration │ +├────┼──────────┼──────────┤ +│ 1. │ / │ 1.2 s │ +│ 2. │ /contact │ 612 ms │ +└────┴──────────┴──────────┘ +`.trim(), + ); + }); + + it('should ignore color codes when aligning columns', () => { + expect( + ansis.strip( + formatAsciiTable({ + columns: [ + { key: 'category', label: ansis.bold('Category'), align: 'left' }, + { key: 'score', label: ansis.bold('Score'), align: 'right' }, + ], + rows: [ + { + category: ansis.bold('Performance'), + score: `${ansis.red('42')} → ${ansis.yellow('51')}`, + }, + { + category: 'Accessibility', + score: ansis.green('100'), + }, + { + category: 'Coverage', + score: ansis.yellow('66'), + }, + ], + }), + ), + ).toBe( + ` +┌───────────────┬─────────┐ +│ Category │ Score │ +├───────────────┼─────────┤ +│ Performance │ 42 → 51 │ +│ Accessibility │ 100 │ +│ Coverage │ 66 │ +└───────────────┴─────────┘ +`.trim(), + ); + }); + + it('should dim borders and preserve ansis styles in cells', () => { + const output = formatAsciiTable({ + columns: [ + { key: 'perf', label: ansis.bold('Performance') }, + { key: 'a11y', label: ansis.bold('Accessibility') }, + ], + rows: [{ perf: ansis.red('42'), a11y: ansis.green('93') }], + }); + expect(output).toBe( + ` +${ansis.dim('┌─────────────┬───────────────┐')} +${ansis.dim('│')} ${ansis.bold('Performance')} ${ansis.dim('│')} ${ansis.bold('Accessibility')} ${ansis.dim('│')} +${ansis.dim('├─────────────┼───────────────┤')} +${ansis.dim('│')} ${ansis.red('42')} ${ansis.dim('│')} ${ansis.green('93')} ${ansis.dim('│')} +${ansis.dim('└─────────────┴───────────────┘')} +`.trim(), + ); + }); + + it('should include table title', () => { + const output = formatAsciiTable({ + title: 'Code coverage:', + rows: [ + ['front-office', '42%'], + ['back-office', '12%'], + ['api', '88%'], + ], + }); + expect(ansis.strip(output)).toBe( + ` +Code coverage: + +┌──────────────┬─────┐ +│ front-office │ 42% │ +│ back-office │ 12% │ +│ api │ 88% │ +└──────────────┴─────┘ + `.trim(), + ); + }); + + it('should support custom padding', () => { + const output = formatAsciiTable( + { + columns: [ + { key: 'url', label: 'URL', align: 'left' }, + { key: 'duration', label: 'Duration', align: 'right' }, + ], + rows: [ + { url: '/', duration: '1.2 s' }, + { url: '/contact', duration: '612 ms' }, + ], + }, + { + padding: 3, + }, + ); + expect(ansis.strip(output)).toBe( + ` +┌──────────────┬──────────────┐ +│ URL │ Duration │ +├──────────────┼──────────────┤ +│ / │ 1.2 s │ +│ /contact │ 612 ms │ +└──────────────┴──────────────┘ +`.trim(), + ); + }); + + it('should support border-less tables', () => { + const output = formatAsciiTable( + { + columns: ['left', 'right', 'center'], + rows: [ + ['/', '2.4 s', '✖'], + ['/about', '720 ms', '✔'], + ['/contact', 'n/a', ''], + ], + }, + { + borderless: true, + }, + ); + expect(output).toBe( + ` +/ 2.4 s ✖ +/about 720 ms ✔ +/contact n/a +`.trim(), + ); + }); +}); diff --git a/packages/utils/src/lib/text-formats/constants.ts b/packages/utils/src/lib/text-formats/constants.ts index 70ea49db7..7139953e5 100644 --- a/packages/utils/src/lib/text-formats/constants.ts +++ b/packages/utils/src/lib/text-formats/constants.ts @@ -12,3 +12,6 @@ export const HIERARCHY = { level_6: 6, /* eslint-enable @typescript-eslint/no-magic-numbers */ } as const; + +// https://stackoverflow.com/questions/4651012/why-is-the-default-terminal-width-80-characters/4651037#4651037 +export const TERMINAL_WIDTH = 80; diff --git a/packages/utils/src/lib/text-formats/index.ts b/packages/utils/src/lib/text-formats/index.ts index fb5f84fe9..0eb27e529 100644 --- a/packages/utils/src/lib/text-formats/index.ts +++ b/packages/utils/src/lib/text-formats/index.ts @@ -3,7 +3,13 @@ import { bold, code, italic } from './html/font-style.js'; import { link } from './html/link.js'; import { table } from './html/table.js'; -export { NEW_LINE, SPACE, TAB, HIERARCHY } from './constants.js'; +export { + HIERARCHY, + NEW_LINE, + SPACE, + TAB, + TERMINAL_WIDTH, +} from './constants.js'; export const html = { bold, From 5f57d07fc772c6549ae86fcf238fb92321f624a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Wed, 12 Nov 2025 15:34:08 +0100 Subject: [PATCH 3/9] fix(utils): align table columns with unicode characters --- package-lock.json | 88 +++++++++++++------ package.json | 1 + packages/utils/package.json | 1 + .../utils/src/lib/text-formats/ascii/table.ts | 9 +- .../lib/text-formats/ascii/table.unit.test.ts | 17 ++++ 5 files changed, 85 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 24b239579..154a9e910 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "rimraf": "^6.0.1", "semver": "^7.6.3", "simple-git": "^3.26.0", + "string-width": "^8.1.0", "ts-morph": "^24.0.0", "tslib": "^2.6.2", "vscode-material-icons": "^0.1.1", @@ -3454,6 +3455,23 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -15068,7 +15086,8 @@ "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" }, "node_modules/easy-table": { "version": "1.2.0", @@ -15154,7 +15173,8 @@ "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==" + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" }, "node_modules/emojis-list": { "version": "3.0.0", @@ -23454,6 +23474,23 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/multi-progress-bars/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/multi-progress-bars/node_modules/strip-ansi": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.0.1.tgz", @@ -24312,22 +24349,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/ora/node_modules/string-width": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", - "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", - "license": "MIT", - "dependencies": { - "get-east-asian-width": "^1.3.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/ora/node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -28128,16 +28149,16 @@ } }, "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz", + "integrity": "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==", + "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "get-east-asian-width": "^1.3.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=20" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -31789,6 +31810,23 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/wrappy": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", diff --git a/package.json b/package.json index 7f473b2d6..07aeeaf7e 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "rimraf": "^6.0.1", "semver": "^7.6.3", "simple-git": "^3.26.0", + "string-width": "^8.1.0", "ts-morph": "^24.0.0", "tslib": "^2.6.2", "vscode-material-icons": "^0.1.1", diff --git a/packages/utils/package.json b/packages/utils/package.json index 8ed308121..0017b96aa 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -37,6 +37,7 @@ "multi-progress-bars": "^5.0.3", "semver": "^7.6.0", "simple-git": "^3.20.0", + "string-width": "^8.1.0", "ora": "^9.0.0", "zod": "^4.0.5" }, diff --git a/packages/utils/src/lib/text-formats/ascii/table.ts b/packages/utils/src/lib/text-formats/ascii/table.ts index f8df75d82..8a2a0dd24 100644 --- a/packages/utils/src/lib/text-formats/ascii/table.ts +++ b/packages/utils/src/lib/text-formats/ascii/table.ts @@ -1,5 +1,6 @@ import ansis from 'ansis'; import type { TableCellAlignment } from 'build-md'; +import stringWidth from 'string-width'; import type { Table, TableAlignment, @@ -133,7 +134,7 @@ function alignText( if (!width) { return text; } - const missing = width - getTextWidth(text); + const missing = width - stringWidth(text); switch (alignment) { case 'left': return `${text}${' '.repeat(missing)}`; @@ -154,15 +155,11 @@ function getColumnWidths(table: NormalizedTable): number[] { ...table.rows.map(row => row[index]), ].filter(cell => cell != null); const texts = cells.map(cell => cell.text); - const widths = texts.map(getTextWidth); + const widths = texts.map(text => stringWidth(text)); return Math.max(...widths); }); } -function getTextWidth(text: string): number { - return ansis.strip(text).length; -} - function normalizeTable(table: Table): NormalizedTable { const rows = normalizeTableRows(table.rows, table.columns); const columns = normalizeTableColumns(table.columns); diff --git a/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts b/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts index 58aa07b7d..b78b5c16e 100644 --- a/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts +++ b/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts @@ -330,6 +330,23 @@ Code coverage: / 2.4 s ✖ /about 720 ms ✔ /contact n/a +`.trim(), + ); + }); + + it('should align columns with unicode characters correctly', () => { + const output = formatAsciiTable({ + rows: [ + ['❌', '/', '1.2 s'], + ['✅', '/contact', '612 ms'], + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌────┬──────────┬────────┐ +│ ❌ │ / │ 1.2 s │ +│ ✅ │ /contact │ 612 ms │ +└────┴──────────┴────────┘ `.trim(), ); }); From e5f50c42ae58f8f2755d52846f086045ab2719e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 14 Nov 2025 12:01:29 +0100 Subject: [PATCH 4/9] feat(utils): wrap columns in ascii table --- package-lock.json | 102 ++++++------ package.json | 1 + packages/utils/package.json | 3 +- .../utils/src/lib/text-formats/ascii/table.ts | 154 +++++++++++++++++- .../lib/text-formats/ascii/table.unit.test.ts | 154 +++++++++++++++--- 5 files changed, 330 insertions(+), 84 deletions(-) diff --git a/package-lock.json b/package-lock.json index 154a9e910..5b6c5d732 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,7 @@ "ts-morph": "^24.0.0", "tslib": "^2.6.2", "vscode-material-icons": "^0.1.1", + "wrap-ansi": "^9.0.2", "yaml": "^2.5.1", "yargs": "^17.7.2", "zod": "^4.0.5" @@ -3455,6 +3456,18 @@ "node": ">=12" } }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/@isaacs/cliui/node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -3472,6 +3485,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -22894,11 +22924,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/log-update/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==" - }, "node_modules/log-update/node_modules/is-fullwidth-code-point": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", @@ -22928,38 +22953,6 @@ "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, - "node_modules/log-update/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/wrap-ansi": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.0.tgz", - "integrity": "sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==", - "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, "node_modules/log4js": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", @@ -31700,16 +31693,17 @@ "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" }, "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "license": "MIT", "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" @@ -31810,18 +31804,24 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrap-ansi/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "license": "MIT" + }, "node_modules/wrap-ansi/node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" }, "engines": { - "node": ">=12" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" diff --git a/package.json b/package.json index 07aeeaf7e..5233da16b 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "ts-morph": "^24.0.0", "tslib": "^2.6.2", "vscode-material-icons": "^0.1.1", + "wrap-ansi": "^9.0.2", "yaml": "^2.5.1", "yargs": "^17.7.2", "zod": "^4.0.5" diff --git a/packages/utils/package.json b/packages/utils/package.json index 0017b96aa..a8b720227 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -39,7 +39,8 @@ "simple-git": "^3.20.0", "string-width": "^8.1.0", "ora": "^9.0.0", - "zod": "^4.0.5" + "zod": "^4.0.5", + "wrap-ansi": "^9.0.2" }, "files": [ "src", diff --git a/packages/utils/src/lib/text-formats/ascii/table.ts b/packages/utils/src/lib/text-formats/ascii/table.ts index 8a2a0dd24..207d36287 100644 --- a/packages/utils/src/lib/text-formats/ascii/table.ts +++ b/packages/utils/src/lib/text-formats/ascii/table.ts @@ -1,6 +1,7 @@ import ansis from 'ansis'; import type { TableCellAlignment } from 'build-md'; import stringWidth from 'string-width'; +import wrapAnsi from 'wrap-ansi'; import type { Table, TableAlignment, @@ -12,6 +13,7 @@ import { TERMINAL_WIDTH } from '../constants.js'; type AsciiTableOptions = { borderless?: boolean; padding?: number; + maxWidth?: number; }; type NormalizedOptions = Required; @@ -23,12 +25,14 @@ type NormalizedTable = { type TableCell = { text: string; alignment: TableAlignment }; +type ColumnStats = { maxWidth: number; maxWord: string }; + const DEFAULT_PADDING = 1; const DEFAULT_ALIGNMENT = 'left' satisfies TableAlignment; -const MAX_WIDTH = TERMINAL_WIDTH; // TODO: use process.stdout.columns? const DEFAULT_OPTIONS: NormalizedOptions = { borderless: false, padding: DEFAULT_PADDING, + maxWidth: TERMINAL_WIDTH, // TODO: use process.stdout.columns? }; const BORDERS = { @@ -67,18 +71,23 @@ function formatTable( table: NormalizedTable, options: NormalizedOptions, ): string { - // TODO: enforce MAX_WIDTH - const columnWidths = getColumnWidths(table); + const columnWidths = getColumnWidths(table, options); return [ formatBorderRow('top', columnWidths, options), ...(table.columns ? [ - formatContentRow(table.columns, columnWidths, options), + ...wrapRow(table.columns, columnWidths).map(row => + formatContentRow(row, columnWidths, options), + ), formatBorderRow('middle', columnWidths, options), ] : []), - ...table.rows.map(cells => formatContentRow(cells, columnWidths, options)), + ...table.rows.flatMap(row => + wrapRow(row, columnWidths).map(cells => + formatContentRow(cells, columnWidths, options), + ), + ), formatBorderRow('bottom', columnWidths, options), ] .filter(Boolean) @@ -126,6 +135,62 @@ function formatContentRow( return `${ansis.dim(BORDERS.single.vertical)}${spaces}${inner}${spaces}${ansis.dim(BORDERS.single.vertical)}`; } +function wrapRow(cells: TableCell[], columnWidths: number[]): TableCell[][] { + const emptyCell: TableCell = { text: '', alignment: DEFAULT_ALIGNMENT }; + + return cells.reduce((acc, cell, colIndex) => { + const wrapped: string = wrapText(cell.text, columnWidths[colIndex]); + const lines = wrapped.split('\n').filter(Boolean); + + const rowCount = Math.max(acc.length, lines.length); + + return Array.from({ length: rowCount }).map((_, rowIndex) => { + const prevCols = + acc[rowIndex] ?? Array.from({ length: colIndex }).map(() => emptyCell); + const currCol = { ...cell, text: lines[rowIndex] ?? '' }; + return [...prevCols, currCol]; + }); + }, []); +} + +function wrapText(text: string, width: number | undefined): string { + if (!width || stringWidth(text) <= width) { + return text; + } + const words = extractWords(text); + const longWords = words.filter(word => word.length > width); + const replacements = longWords.map(original => { + const parts = original.includes('-') + ? original.split('-') + : partitionString(original, width - 1); + const replacement = parts.join('-\n'); + return { original, replacement }; + }); + const textWithSplitLongWords = replacements.reduce( + (acc, { original, replacement }) => acc.replace(original, replacement), + text, + ); + return wrapAnsi(textWithSplitLongWords, width); +} + +function extractWords(text: string): string[] { + return ansis + .strip(text) + .split(' ') + .map(word => word.trim()); +} + +function partitionString(text: string, maxChars: number): string[] { + const groups = [...text].reduce>( + (acc, char, index) => { + const key = Math.floor(index / maxChars); + return { ...acc, [key]: [...(acc[key] ?? []), char] }; + }, + {}, + ); + return Object.values(groups).map(chars => chars.join('')); +} + function alignText( text: string, alignment: TableAlignment, @@ -147,19 +212,92 @@ function alignText( } } -function getColumnWidths(table: NormalizedTable): number[] { +function getColumnWidths( + table: NormalizedTable, + options: NormalizedOptions, +): number[] { + const columnTexts = getColumnTexts(table); + const columnStats = aggregateColumnsStats(columnTexts); + return adjustColumnWidthsToMax(columnStats, options); +} + +function getColumnTexts(table: NormalizedTable): string[][] { const columnCount = table.columns?.length ?? table.rows[0]?.length ?? 0; return Array.from({ length: columnCount }).map((_, index) => { const cells: TableCell[] = [ table.columns?.[index], ...table.rows.map(row => row[index]), ].filter(cell => cell != null); - const texts = cells.map(cell => cell.text); + return cells.map(cell => cell.text); + }); +} + +function aggregateColumnsStats(columnTexts: string[][]): ColumnStats[] { + return columnTexts.map(texts => { const widths = texts.map(text => stringWidth(text)); - return Math.max(...widths); + const longestWords = texts + .flatMap(extractWords) + .toSorted((a, b) => b.length - a.length); + return { + maxWidth: Math.max(...widths), + maxWord: longestWords[0] ?? '', + }; }); } +function adjustColumnWidthsToMax( + columnStats: ColumnStats[], + options: NormalizedOptions, +): number[] { + const tableWidth = getTableWidth(columnStats, options); + if (tableWidth <= options.maxWidth) { + return columnStats.map(({ maxWidth }) => maxWidth); + } + const overflow = tableWidth - options.maxWidth; + + return truncateColumns(columnStats, overflow); +} + +function truncateColumns( + columnStats: ColumnStats[], + overflow: number, +): number[] { + const sortedColumns = columnStats + .map((stats, index) => ({ ...stats, index })) + .toSorted( + (a, b) => b.maxWidth - a.maxWidth || b.maxWord.length - a.maxWord.length, + ); + + let remaining = overflow; + const newWidths = new Map(); + for (const { index, maxWidth, maxWord } of sortedColumns) { + const newWidth = Math.max( + maxWidth - remaining, + Math.ceil(maxWidth / 2), + Math.ceil(maxWord.length / 2) + 1, + ); + newWidths.set(index, newWidth); + remaining -= maxWidth - newWidth; + if (remaining <= 0) { + break; + } + } + return columnStats.map( + ({ maxWidth }, index) => newWidths.get(index) ?? maxWidth, + ); +} + +function getTableWidth( + columnStats: ColumnStats[], + options: NormalizedOptions, +): number { + const contents = columnStats.reduce((acc, { maxWidth }) => acc + maxWidth, 0); + const paddings = + options.padding * columnStats.length * 2 - (options.borderless ? 2 : 0); + const borders = options.borderless ? 0 : columnStats.length + 1; + return contents + paddings + borders; +} + function normalizeTable(table: Table): NormalizedTable { const rows = normalizeTableRows(table.rows, table.columns); const columns = normalizeTableColumns(table.columns); diff --git a/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts b/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts index b78b5c16e..52bd6cc64 100644 --- a/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts +++ b/packages/utils/src/lib/text-formats/ascii/table.unit.test.ts @@ -206,30 +206,27 @@ describe('formatAsciiTable', () => { }); it('should ignore color codes when aligning columns', () => { - expect( - ansis.strip( - formatAsciiTable({ - columns: [ - { key: 'category', label: ansis.bold('Category'), align: 'left' }, - { key: 'score', label: ansis.bold('Score'), align: 'right' }, - ], - rows: [ - { - category: ansis.bold('Performance'), - score: `${ansis.red('42')} → ${ansis.yellow('51')}`, - }, - { - category: 'Accessibility', - score: ansis.green('100'), - }, - { - category: 'Coverage', - score: ansis.yellow('66'), - }, - ], - }), - ), - ).toBe( + const output = formatAsciiTable({ + columns: [ + { key: 'category', label: ansis.bold('Category'), align: 'left' }, + { key: 'score', label: ansis.bold('Score'), align: 'right' }, + ], + rows: [ + { + category: ansis.bold('Performance'), + score: `${ansis.red('42')} → ${ansis.yellow('51')}`, + }, + { + category: 'Accessibility', + score: ansis.green('100'), + }, + { + category: 'Coverage', + score: ansis.yellow('66'), + }, + ], + }); + expect(ansis.strip(output)).toBe( ` ┌───────────────┬─────────┐ │ Category │ Score │ @@ -347,6 +344,115 @@ Code coverage: │ ❌ │ / │ 1.2 s │ │ ✅ │ /contact │ 612 ms │ └────┴──────────┴────────┘ +`.trim(), + ); + }); + + it('should wrap columns to enforce a maximum width', () => { + const output = formatAsciiTable({ + rows: [ + ['Audit production dependencies', '0 vulnerabilities'], + [ + 'Audit development dependencies', + '12 vulnerabilities (1 critical, 3 high, 7 moderate, 5 low)', + ], + [ + 'Outdated production dependencies', + '2 outdated packages (1 minor, 1 patch)', + ], + [ + 'Outdated development dependencies', + '8 outdated packages (2 major, 2 minor, 4 patch)', + ], + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌───────────────────────────────────┬──────────────────────────────────────────┐ +│ Audit production dependencies │ 0 vulnerabilities │ +│ Audit development dependencies │ 12 vulnerabilities (1 critical, 3 high, │ +│ │ 7 moderate, 5 low) │ +│ Outdated production dependencies │ 2 outdated packages (1 minor, 1 patch) │ +│ Outdated development dependencies │ 8 outdated packages (2 major, 2 minor, 4 │ +│ │ patch) │ +└───────────────────────────────────┴──────────────────────────────────────────┘ +`.trim(), + ); + }); + + it('should wrap columns in border-less tables', () => { + const output = formatAsciiTable( + { + columns: ['center', 'left', 'right'], + rows: [ + [ + ansis.green('●'), + 'Audit production dependencies', + '0 vulnerabilities', + ], + [ + ansis.red('●'), + 'Audit development dependencies', + '12 vulnerabilities (1 critical, 3 high, 7 moderate, 5 low)', + ], + [ + ansis.green('●'), + 'Outdated production dependencies', + '2 outdated packages (1 minor, 1 patch)', + ], + [ + ansis.yellow('●'), + 'Outdated development dependencies', + '8 outdated packages (2 major, 2 minor, 4 patch)', + ], + ], + }, + { borderless: true }, + ); + expect(ansis.strip(output)).toBe( + ` +● Audit production dependencies 0 vulnerabilities +● Audit development dependencies 12 vulnerabilities (1 critical, 3 high, 7 + moderate, 5 low) +● Outdated production dependencies 2 outdated packages (1 minor, 1 patch) +● Outdated development dependencies 8 outdated packages (2 major, 2 minor, 4 + patch) +`.trim(), + ); + }); + + it('should wrap columns in header and break long words', () => { + const output = formatAsciiTable({ + columns: [ + { key: 'a11y', label: 'Accessibility', align: 'center' }, + { key: 'coverage', label: 'Code coverage', align: 'center' }, + { key: 'bug-prevention', label: 'Bug prevention', align: 'center' }, + { key: 'code-style', label: 'Code style', align: 'center' }, + { key: 'security', label: 'Security', align: 'center' }, + { key: 'updates', label: 'Updates', align: 'center' }, + { key: 'docs', label: 'Documentation', align: 'center' }, + ], + rows: [ + { + a11y: 81, + coverage: 64, + 'bug-prevention': 92, + 'code-style': 100, + security: 95, + updates: 62, + docs: 6, + }, + ], + }); + expect(ansis.strip(output)).toBe( + ` +┌──────────┬────────────┬─────────┬────────────┬──────────┬─────────┬──────────┐ +│ Accessi- │ Code │ Bug │ Code style │ Security │ Updates │ Documen- │ +│ bility │ coverage │ preven- │ │ │ │ tation │ +│ │ │ tion │ │ │ │ │ +├──────────┼────────────┼─────────┼────────────┼──────────┼─────────┼──────────┤ +│ 81 │ 64 │ 92 │ 100 │ 95 │ 62 │ 6 │ +└──────────┴────────────┴─────────┴────────────┴──────────┴─────────┴──────────┘ `.trim(), ); }); From e2f27b400f786d9c8adfcbf4cec73cf8823d3a2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 14 Nov 2025 14:04:30 +0100 Subject: [PATCH 5/9] feat(utils): replace @isaacs/cliui for printing audits to stdout --- .../tests/collect.e2e.test.ts | 4 +- package-lock.json | 1 - package.json | 1 - packages/utils/package.json | 1 - packages/utils/src/index.ts | 12 +- packages/utils/src/lib/logging.ts | 50 +------ .../report-stdout-all-perfect-scores.txt | 16 +- .../report-stdout-no-categories.txt | 46 +++--- .../__snapshots__/report-stdout-verbose.txt | 141 ++++++++---------- .../reports/__snapshots__/report-stdout.txt | 46 +++--- .../src/lib/reports/log-stdout-summary.ts | 76 +++++----- .../reports/log-stdout-summary.unit.test.ts | 2 +- 12 files changed, 163 insertions(+), 233 deletions(-) diff --git a/e2e/plugin-lighthouse-e2e/tests/collect.e2e.test.ts b/e2e/plugin-lighthouse-e2e/tests/collect.e2e.test.ts index 63337fa90..d00350679 100644 --- a/e2e/plugin-lighthouse-e2e/tests/collect.e2e.test.ts +++ b/e2e/plugin-lighthouse-e2e/tests/collect.e2e.test.ts @@ -7,7 +7,6 @@ import { E2E_ENVIRONMENTS_DIR, TEST_OUTPUT_DIR, omitVariableReportData, - removeColorCodes, restoreNxIgnoredFiles, teardownTestFolder, } from '@code-pushup/test-utils'; @@ -42,8 +41,7 @@ describe('PLUGIN collect report with lighthouse-plugin NPM package', () => { }); expect(code).toBe(0); - const cleanStdout = removeColorCodes(stdout); - expect(cleanStdout).toContain('● Largest Contentful Paint'); + expect(stdout).toContain('Largest Contentful Paint'); const report = await readJsonFile( path.join(defaultSetupDir, '.code-pushup', 'report.json'), diff --git a/package-lock.json b/package-lock.json index 5b6c5d732..b2beeeeda 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,6 @@ "dependencies": { "@axe-core/playwright": "^4.11.0", "@code-pushup/portal-client": "^0.16.0", - "@isaacs/cliui": "^8.0.2", "@nx/devkit": "21.4.1", "@poppinss/cliui": "6.4.1", "@swc/helpers": "0.5.13", diff --git a/package.json b/package.json index 5233da16b..3e717e566 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,6 @@ "dependencies": { "@axe-core/playwright": "^4.11.0", "@code-pushup/portal-client": "^0.16.0", - "@isaacs/cliui": "^8.0.2", "@nx/devkit": "21.4.1", "@poppinss/cliui": "6.4.1", "@swc/helpers": "0.5.13", diff --git a/packages/utils/package.json b/packages/utils/package.json index a8b720227..9c5b1922f 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,7 +28,6 @@ }, "dependencies": { "@code-pushup/models": "0.87.2", - "@isaacs/cliui": "^8.0.2", "@poppinss/cliui": "^6.4.0", "ansis": "^3.3.0", "build-md": "^0.4.2", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 674ae0e0f..db0742a40 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -88,13 +88,8 @@ export { export { interpolate } from './lib/interpolate.js'; export { logMultipleResults } from './lib/log-results.js'; export { Logger, logger } from './lib/logger.js'; -export { link, ui, type CliUi, type Column } from './lib/logging.js'; +export { link, ui, type CliUi } from './lib/logging.js'; export { mergeConfigs } from './lib/merge-configs.js'; -export { - getUrlIdentifier, - normalizeUrlInput, - type PluginUrlContext, -} from './lib/plugin-url-config.js'; export { addIndex, ContextValidationError, @@ -106,6 +101,11 @@ export { shouldExpandForUrls, validateUrlContext, } from './lib/plugin-url-aggregation.js'; +export { + getUrlIdentifier, + normalizeUrlInput, + type PluginUrlContext, +} from './lib/plugin-url-config.js'; export { getProgressBar, type ProgressBar } from './lib/progress.js'; export { asyncSequential, diff --git a/packages/utils/src/lib/logging.ts b/packages/utils/src/lib/logging.ts index 94708407d..d2e7535cb 100644 --- a/packages/utils/src/lib/logging.ts +++ b/packages/utils/src/lib/logging.ts @@ -1,58 +1,16 @@ -import isaacs_cliui from '@isaacs/cliui'; import { cliui } from '@poppinss/cliui'; import ansis from 'ansis'; -import { TERMINAL_WIDTH } from './text-formats/constants.js'; // TODO: remove once logger is used everywhere -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type ArgumentsType = T extends (...args: infer U) => any ? U : never; -export type CliUiBase = ReturnType; -type UI = ReturnType; -type CliExtension = { - row: (r: ArgumentsType) => void; -}; -export type Column = { - text: string; - width?: number; - align?: 'right' | 'left' | 'center'; - padding: number[]; - border?: boolean; -}; -export type CliUi = CliUiBase & CliExtension; +export type CliUi = ReturnType; // eslint-disable-next-line functional/no-let -let cliUISingleton: CliUiBase | undefined; -// eslint-disable-next-line functional/no-let -let cliUIExtendedSingleton: CliUi | undefined; +let cliUISingleton: CliUi | undefined; export function ui(): CliUi { - if (cliUISingleton === undefined) { - cliUISingleton = cliui(); - } - if (!cliUIExtendedSingleton) { - cliUIExtendedSingleton = { - ...cliUISingleton, - row: args => { - logListItem(args); - }, - }; - } - - return cliUIExtendedSingleton; -} - -// eslint-disable-next-line functional/no-let -let singletonisaacUi: UI | undefined; -export function logListItem(args: ArgumentsType) { - if (singletonisaacUi === undefined) { - singletonisaacUi = isaacs_cliui({ width: TERMINAL_WIDTH }); - } - singletonisaacUi.div(...args); - const content = singletonisaacUi.toString(); - // eslint-disable-next-line functional/immutable-data - singletonisaacUi.rows = []; - cliUIExtendedSingleton?.logger.log(content); + cliUISingleton ??= cliui(); + return cliUISingleton; } export function link(text: string) { diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt index 48b72e8ee..23a981c37 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt @@ -3,18 +3,17 @@ Code PushUp Report - @code-pushup/core@0.0.1 ESLint audits -● ... All 47 audits have perfect scores ... +● ... All 47 audits have perfect scores ... Lighthouse audits -● Minimize third-party usage Third-party code - blocked the main - thread for 6,850 ms -● First Contentful Paint 1.2 s -● Largest Contentful Paint 1.5 s -● Speed Index 1.2 s -● ... 2 audits with perfect scores omitted for brevity ... +● Minimize third-party usage Third-party code blocked the main + thread for 6,850 ms +● First Contentful Paint 1.2 s +● Largest Contentful Paint 1.5 s +● Speed Index 1.2 s +● ... + 2 audits with perfect scores ... Categories @@ -29,3 +28,4 @@ Categories └─────────────────────────────────────────────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev + diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout-no-categories.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout-no-categories.txt index a2cb18f1f..5f608893e 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout-no-categories.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout-no-categories.txt @@ -3,33 +3,31 @@ Code PushUp Report - @code-pushup/core@0.0.1 ESLint audits -● Disallow missing props validation in a React component 6 warnings - definition -● Disallow variable declarations from shadowing variables 3 warnings - declared in the outer scope -● Require or disallow method and property shorthand 3 warnings - syntax for object literals -● verifies the list of dependencies for Hooks like 2 warnings - useEffect and similar -● Disallow missing `key` props in iterators/collection 1 warning - literals -● Disallow unused variables 1 warning -● Enforce a maximum number of lines of code in a function 1 warning -● Require `const` declarations for variables that are 1 warning - never reassigned after declared -● Require braces around arrow function bodies 1 warning -● Require the use of `===` and `!==` 1 warning -● ... 37 audits with perfect scores omitted for brevity ... +● Disallow missing props validation in a React component definition 6 warnings +● Disallow variable declarations from shadowing variables declared 3 warnings + in the outer scope +● Require or disallow method and property shorthand syntax for 3 warnings + object literals +● verifies the list of dependencies for Hooks like useEffect and 2 warnings + similar +● Disallow missing `key` props in iterators/collection literals 1 warning +● Disallow unused variables 1 warning +● Enforce a maximum number of lines of code in a function 1 warning +● Require `const` declarations for variables that are never 1 warning + reassigned after declared +● Require braces around arrow function bodies 1 warning +● Require the use of `===` and `!==` 1 warning +● ... + 37 audits with perfect scores ... Lighthouse audits -● Minimize third-party usage Third-party code - blocked the main - thread for 6,850 ms -● First Contentful Paint 1.2 s -● Largest Contentful Paint 1.5 s -● Speed Index 1.2 s -● ... 2 audits with perfect scores omitted for brevity ... +● Minimize third-party usage Third-party code blocked the main + thread for 6,850 ms +● First Contentful Paint 1.2 s +● Largest Contentful Paint 1.5 s +● Speed Index 1.2 s +● ... + 2 audits with perfect scores ... Made with ❤ by code-pushup.dev + diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt index 0a5bb1a56..bfb4f30bb 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt @@ -3,87 +3,73 @@ Code PushUp Report - @code-pushup/core@0.0.1 ESLint audits -● Disallow missing props validation in a React component 6 warnings - definition -● Disallow variable declarations from shadowing variables 3 warnings - declared in the outer scope -● Require or disallow method and property shorthand 3 warnings - syntax for object literals -● verifies the list of dependencies for Hooks like 2 warnings - useEffect and similar -● Disallow missing `key` props in iterators/collection 1 warning - literals -● Disallow unused variables 1 warning -● Enforce a maximum number of lines of code in a function 1 warning -● Require `const` declarations for variables that are 1 warning - never reassigned after declared -● Require braces around arrow function bodies 1 warning -● Require the use of `===` and `!==` 1 warning -● Disallow `target="_blank"` attribute without passed - `rel="noreferrer"` -● Disallow assignment operators in conditional passed - expressions -● Disallow comments from being inserted as text nodes passed -● Disallow direct mutation of this.state passed -● Disallow duplicate properties in JSX passed -● Disallow invalid regular expression strings in `RegExp` passed - constructors -● Disallow loops with a body that allows only one passed - iteration -● Disallow missing displayName in a React component passed - definition -● Disallow missing React when using JSX passed -● Disallow negating the left operand of relational passed - operators -● Disallow passing of children as props passed -● Disallow React to be incorrectly marked as unused passed -● Disallow reassigning `const` variables passed -● Disallow the use of `debugger` passed -● Disallow the use of undeclared variables unless passed - mentioned in `/*global */` comments -● Disallow undeclared variables in JSX passed -● Disallow unescaped HTML entities from appearing in passed - markup -● Disallow usage of deprecated methods passed -● Disallow usage of findDOMNode passed -● Disallow usage of isMounted passed -● Disallow usage of the return value of ReactDOM.render passed -● Disallow usage of unknown DOM property passed -● Disallow use of optional chaining in contexts where the passed - `undefined` value is not allowed -● Disallow using Object.assign with an object literal as passed - the first argument and prefer the use of object spread - instead -● Disallow using string references passed -● Disallow variables used in JSX to be incorrectly marked passed - as unused -● Disallow when a DOM element is using both children and passed - dangerouslySetInnerHTML -● Enforce a maximum number of lines per file passed -● Enforce camelcase naming convention passed -● Enforce comparing `typeof` expressions against valid passed - strings -● Enforce consistent brace style for all control passed - statements -● Enforce ES5 or ES6 class for returning value in render passed - function -● enforces the Rules of Hooks passed -● Require `let` or `const` instead of `var` passed -● Require calls to `isNaN()` when checking for `NaN` passed -● Require or disallow "Yoda" conditions passed -● Require using arrow functions for callbacks passed +● Disallow missing props validation in a React component definition 6 warnings +● Disallow variable declarations from shadowing variables declared 3 warnings + in the outer scope +● Require or disallow method and property shorthand syntax for 3 warnings + object literals +● verifies the list of dependencies for Hooks like useEffect and 2 warnings + similar +● Disallow missing `key` props in iterators/collection literals 1 warning +● Disallow unused variables 1 warning +● Enforce a maximum number of lines of code in a function 1 warning +● Require `const` declarations for variables that are never 1 warning + reassigned after declared +● Require braces around arrow function bodies 1 warning +● Require the use of `===` and `!==` 1 warning +● Disallow `target="_blank"` attribute without `rel="noreferrer"` passed +● Disallow assignment operators in conditional expressions passed +● Disallow comments from being inserted as text nodes passed +● Disallow direct mutation of this.state passed +● Disallow duplicate properties in JSX passed +● Disallow invalid regular expression strings in `RegExp` passed + constructors +● Disallow loops with a body that allows only one iteration passed +● Disallow missing displayName in a React component definition passed +● Disallow missing React when using JSX passed +● Disallow negating the left operand of relational operators passed +● Disallow passing of children as props passed +● Disallow React to be incorrectly marked as unused passed +● Disallow reassigning `const` variables passed +● Disallow the use of `debugger` passed +● Disallow the use of undeclared variables unless mentioned in passed + `/*global */` comments +● Disallow undeclared variables in JSX passed +● Disallow unescaped HTML entities from appearing in markup passed +● Disallow usage of deprecated methods passed +● Disallow usage of findDOMNode passed +● Disallow usage of isMounted passed +● Disallow usage of the return value of ReactDOM.render passed +● Disallow usage of unknown DOM property passed +● Disallow use of optional chaining in contexts where the passed + `undefined` value is not allowed +● Disallow using Object.assign with an object literal as the first passed + argument and prefer the use of object spread instead +● Disallow using string references passed +● Disallow variables used in JSX to be incorrectly marked as unused passed +● Disallow when a DOM element is using both children and passed + dangerouslySetInnerHTML +● Enforce a maximum number of lines per file passed +● Enforce camelcase naming convention passed +● Enforce comparing `typeof` expressions against valid strings passed +● Enforce consistent brace style for all control statements passed +● Enforce ES5 or ES6 class for returning value in render function passed +● enforces the Rules of Hooks passed +● Require `let` or `const` instead of `var` passed +● Require calls to `isNaN()` when checking for `NaN` passed +● Require or disallow "Yoda" conditions passed +● Require using arrow functions for callbacks passed Lighthouse audits -● Minimize third-party usage Third-party code - blocked the main - thread for 6,850 ms -● First Contentful Paint 1.2 s -● Largest Contentful Paint 1.5 s -● Speed Index 1.2 s -● Cumulative Layout Shift 0 -● Total Blocking Time 0 ms +● Minimize third-party usage Third-party code blocked the main thread for + 6,850 ms +● First Contentful Paint 1.2 s +● Largest Contentful Paint 1.5 s +● Speed Index 1.2 s +● Cumulative Layout Shift 0 +● Total Blocking Time 0 ms Categories @@ -98,3 +84,4 @@ Categories └─────────────────────────────────────────────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev + diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt index c7ebf3e24..df0e5b95e 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt @@ -3,34 +3,31 @@ Code PushUp Report - @code-pushup/core@0.0.1 ESLint audits -● Disallow missing props validation in a React component 6 warnings - definition -● Disallow variable declarations from shadowing variables 3 warnings - declared in the outer scope -● Require or disallow method and property shorthand 3 warnings - syntax for object literals -● verifies the list of dependencies for Hooks like 2 warnings - useEffect and similar -● Disallow missing `key` props in iterators/collection 1 warning - literals -● Disallow unused variables 1 warning -● Enforce a maximum number of lines of code in a function 1 warning -● Require `const` declarations for variables that are 1 warning - never reassigned after declared -● Require braces around arrow function bodies 1 warning -● Require the use of `===` and `!==` 1 warning -● ... 37 audits with perfect scores omitted for brevity ... +● Disallow missing props validation in a React component definition 6 warnings +● Disallow variable declarations from shadowing variables declared 3 warnings + in the outer scope +● Require or disallow method and property shorthand syntax for 3 warnings + object literals +● verifies the list of dependencies for Hooks like useEffect and 2 warnings + similar +● Disallow missing `key` props in iterators/collection literals 1 warning +● Disallow unused variables 1 warning +● Enforce a maximum number of lines of code in a function 1 warning +● Require `const` declarations for variables that are never 1 warning + reassigned after declared +● Require braces around arrow function bodies 1 warning +● Require the use of `===` and `!==` 1 warning +● ... + 37 audits with perfect scores ... Lighthouse audits -● Minimize third-party usage Third-party code - blocked the main - thread for 6,850 ms -● First Contentful Paint 1.2 s -● Largest Contentful Paint 1.5 s -● Speed Index 1.2 s -● ... 2 audits with perfect scores omitted for brevity ... +● Minimize third-party usage Third-party code blocked the main + thread for 6,850 ms +● First Contentful Paint 1.2 s +● Largest Contentful Paint 1.5 s +● Speed Index 1.2 s +● ... + 2 audits with perfect scores ... Categories @@ -45,3 +42,4 @@ Categories └─────────────────────────────────────────────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev + diff --git a/packages/utils/src/lib/reports/log-stdout-summary.ts b/packages/utils/src/lib/reports/log-stdout-summary.ts index 38a0ba4ed..74b3f2a34 100644 --- a/packages/utils/src/lib/reports/log-stdout-summary.ts +++ b/packages/utils/src/lib/reports/log-stdout-summary.ts @@ -1,8 +1,8 @@ -import cliui from '@isaacs/cliui'; import ansis from 'ansis'; import type { AuditReport } from '@code-pushup/models'; import { logger } from '../logger.js'; import { ui } from '../logging.js'; +import { formatAsciiTable } from '../text-formats/ascii/table.js'; import { TERMINAL_WIDTH } from '../text-formats/constants.js'; import { CODE_PUSHUP_DOMAIN, @@ -45,53 +45,47 @@ export function logPlugins(plugins: ScoredReport['plugins']): void { : audits.filter(({ score }) => score !== 1); const diff = audits.length - filteredAudits.length; - logAudits(title, filteredAudits); - - if (diff > 0) { - const notice = - filteredAudits.length === 0 + const footer = + diff > 0 + ? filteredAudits.length === 0 ? `... All ${diff} audits have perfect scores ...` - : `... ${diff} audits with perfect scores omitted for brevity ...`; - logRow(1, notice); - } - logger.newline(); + : `... + ${diff} audits with perfect scores ...` + : null; + + logAudits(title, filteredAudits, footer); }); } -function logAudits(pluginTitle: string, audits: AuditReport[]): void { - logger.newline(); - logger.info(ansis.bold.magentaBright(`${pluginTitle} audits`)); +function logAudits( + pluginTitle: string, + audits: AuditReport[], + footer: string | null, +): void { + const marker = '●'; + logger.newline(); - audits.forEach(({ score, title, displayValue, value }) => { - logRow(score, title, displayValue || `${value}`); - }); -} -function logRow(score: number, title: string, value?: string): void { - const ui = cliui({ width: TERMINAL_WIDTH }); - ui.div( - { - text: applyScoreColor({ score, text: '●' }), - width: 2, - padding: [0, 1, 0, 0], - }, - { - text: title, - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - padding: [0, 3, 0, 0], - }, - ...(value - ? [ - { - text: ansis.cyanBright(value), - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - width: 20, - padding: [0, 0, 0, 0], - }, - ] - : []), + logger.info( + formatAsciiTable( + { + title: ansis.bold.magentaBright(`${pluginTitle} audits`), + columns: ['center', 'left', 'right'], + rows: [ + ...audits.map(({ score, title, displayValue, value }) => [ + applyScoreColor({ score, text: marker }), + title, + ansis.cyanBright(displayValue || value.toString()), + ]), + ...(footer + ? [[applyScoreColor({ score: 1, text: marker }), footer]] + : []), + ], + }, + { borderless: true }, + ), ); - logger.info(ui.toString()); + + logger.newline(); } export function logCategories({ diff --git a/packages/utils/src/lib/reports/log-stdout-summary.unit.test.ts b/packages/utils/src/lib/reports/log-stdout-summary.unit.test.ts index 58681e545..6e5d1ebd7 100644 --- a/packages/utils/src/lib/reports/log-stdout-summary.unit.test.ts +++ b/packages/utils/src/lib/reports/log-stdout-summary.unit.test.ts @@ -191,7 +191,7 @@ describe('logPlugins', () => { expect(stdout).toContain('Audit 1'); expect(stdout).not.toContain('Audit 2'); - expect(stdout).toContain('audits with perfect scores omitted for brevity'); + expect(stdout).toContain('audits with perfect scores'); }); it('should log all audits when verbose is true', () => { From bc130a9a0c6581bff6c9fb5bd7e3d7d7aa3097b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 14 Nov 2025 14:24:26 +0100 Subject: [PATCH 6/9] refactor(utils): move and rename ansis link utility --- packages/cli/src/lib/collect/collect-command.ts | 6 +++--- packages/cli/src/lib/implementation/logging.ts | 12 ++++++------ packages/plugin-lighthouse/src/lib/runner/runner.ts | 5 ++--- packages/utils/src/index.ts | 5 ++++- packages/utils/src/lib/logging.ts | 5 ----- packages/utils/src/lib/text-formats/ascii/link.ts | 5 +++++ 6 files changed, 20 insertions(+), 18 deletions(-) create mode 100644 packages/utils/src/lib/text-formats/ascii/link.ts diff --git a/packages/cli/src/lib/collect/collect-command.ts b/packages/cli/src/lib/collect/collect-command.ts index 3baf3df9d..54ff5c30c 100644 --- a/packages/cli/src/lib/collect/collect-command.ts +++ b/packages/cli/src/lib/collect/collect-command.ts @@ -4,7 +4,7 @@ import { type CollectAndPersistReportsOptions, collectAndPersistReports, } from '@code-pushup/core'; -import { link, logger, ui } from '@code-pushup/utils'; +import { formatAsciiLink, logger, ui } from '@code-pushup/utils'; import { CLI_NAME } from '../constants.js'; import { collectSuccessfulLog, @@ -51,7 +51,7 @@ export function renderUploadAutorunHint(): void { )}`, ) .add( - ` ${link( + ` ${formatAsciiLink( 'https://github.com/code-pushup/cli/tree/main/packages/cli#upload-command', )}`, ) @@ -59,7 +59,7 @@ export function renderUploadAutorunHint(): void { `${ansis.gray('❯')} npx code-pushup autorun - ${ansis.gray('Run collect & upload')}`, ) .add( - ` ${link( + ` ${formatAsciiLink( 'https://github.com/code-pushup/cli/tree/main/packages/cli#autorun-command', )}`, ) diff --git a/packages/cli/src/lib/implementation/logging.ts b/packages/cli/src/lib/implementation/logging.ts index 2191cbe51..4b730e772 100644 --- a/packages/cli/src/lib/implementation/logging.ts +++ b/packages/cli/src/lib/implementation/logging.ts @@ -1,9 +1,9 @@ import ansis from 'ansis'; -import { link, logger, ui } from '@code-pushup/utils'; +import { formatAsciiLink, logger, ui } from '@code-pushup/utils'; export function renderConfigureCategoriesHint(): void { logger.debug( - `💡 Configure categories to see the scores in an overview table. See: ${link( + `💡 Configure categories to see the scores in an overview table. See: ${formatAsciiLink( 'https://github.com/code-pushup/cli/blob/main/packages/cli/README.md', )}`, { force: true }, @@ -11,7 +11,7 @@ export function renderConfigureCategoriesHint(): void { } export function uploadSuccessfulLog(url: string): void { logger.info(ansis.green('Upload successful!')); - logger.info(link(url)); + logger.info(formatAsciiLink(url)); } export function collectSuccessfulLog(): void { @@ -30,17 +30,17 @@ export function renderIntegratePortalHint(): void { )}`, ) .add( - ` ${link( + ` ${formatAsciiLink( 'https://github.com/code-pushup/cli/tree/main/packages/cli#upload-command', )}`, ) .add( - `${ansis.gray('❯')} ${ansis.gray('Portal Integration')} - ${link( + `${ansis.gray('❯')} ${ansis.gray('Portal Integration')} - ${formatAsciiLink( 'https://github.com/code-pushup/cli/blob/main/packages/cli/README.md#portal-integration', )}`, ) .add( - `${ansis.gray('❯')} ${ansis.gray('Upload Command')} - ${link( + `${ansis.gray('❯')} ${ansis.gray('Upload Command')} - ${formatAsciiLink( 'https://github.com/code-pushup/cli/blob/main/packages/cli/README.md#portal-integration', )}`, ) diff --git a/packages/plugin-lighthouse/src/lib/runner/runner.ts b/packages/plugin-lighthouse/src/lib/runner/runner.ts index 177298c93..fdb8e0ac5 100644 --- a/packages/plugin-lighthouse/src/lib/runner/runner.ts +++ b/packages/plugin-lighthouse/src/lib/runner/runner.ts @@ -5,11 +5,10 @@ import type { AuditOutputs, RunnerFunction } from '@code-pushup/models'; import { addIndex, ensureDirectoryExists, - link, + formatAsciiLink, logger, shouldExpandForUrls, stringifyError, - ui, } from '@code-pushup/utils'; import type { LighthouseOptions } from '../types.js'; import { DEFAULT_CLI_FLAGS } from './constants.js'; @@ -82,7 +81,7 @@ async function runLighthouseForUrl( if (runnerResult == null) { throw new Error( - `Lighthouse did not produce a result for URL: ${link(url)}`, + `Lighthouse did not produce a result for URL: ${formatAsciiLink(url)}`, ); } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index db0742a40..937a7b2fc 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -88,7 +88,7 @@ export { export { interpolate } from './lib/interpolate.js'; export { logMultipleResults } from './lib/log-results.js'; export { Logger, logger } from './lib/logger.js'; -export { link, ui, type CliUi } from './lib/logging.js'; +export { ui, type CliUi } from './lib/logging.js'; export { mergeConfigs } from './lib/merge-configs.js'; export { addIndex, @@ -144,6 +144,9 @@ export { formatReportScore, } from './lib/reports/utils.js'; export { isSemver, normalizeSemver, sortSemvers } from './lib/semver.js'; +export { formatAsciiLink } from './lib/text-formats/ascii/link.js'; +export { formatAsciiTable } from './lib/text-formats/ascii/table.js'; +export { formatAsciiTree } from './lib/text-formats/ascii/tree.js'; export * from './lib/text-formats/index.js'; export { countOccurrences, diff --git a/packages/utils/src/lib/logging.ts b/packages/utils/src/lib/logging.ts index d2e7535cb..aff424ec1 100644 --- a/packages/utils/src/lib/logging.ts +++ b/packages/utils/src/lib/logging.ts @@ -1,5 +1,4 @@ import { cliui } from '@poppinss/cliui'; -import ansis from 'ansis'; // TODO: remove once logger is used everywhere @@ -12,7 +11,3 @@ export function ui(): CliUi { cliUISingleton ??= cliui(); return cliUISingleton; } - -export function link(text: string) { - return ansis.underline.blueBright(text); -} diff --git a/packages/utils/src/lib/text-formats/ascii/link.ts b/packages/utils/src/lib/text-formats/ascii/link.ts new file mode 100644 index 000000000..1525fa5e2 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/link.ts @@ -0,0 +1,5 @@ +import ansis from 'ansis'; + +export function formatAsciiLink(url: string): string { + return ansis.underline.blueBright(url); +} From 7fc117b7ca0116409b8c0049db81eacfa69763b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 14 Nov 2025 15:29:09 +0100 Subject: [PATCH 7/9] feat(utils): replace @poppinss/cliui table for printing categories --- .../report-stdout-all-perfect-scores.txt | 16 ++-- .../__snapshots__/report-stdout-verbose.txt | 16 ++-- .../reports/__snapshots__/report-stdout.txt | 16 ++-- packages/utils/src/lib/reports/constants.ts | 6 -- .../reports/log-stdout-summary.int.test.ts | 11 --- .../src/lib/reports/log-stdout-summary.ts | 45 ++++------- .../reports/log-stdout-summary.unit.test.ts | 81 ++++++++++--------- 7 files changed, 81 insertions(+), 110 deletions(-) diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt index 23a981c37..42c7ae5e9 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout-all-perfect-scores.txt @@ -17,15 +17,13 @@ Lighthouse audits Categories -┌─────────────────────────────────────────────────────────┬─────────┬──────────┐ -│ Category │ Score │ Audits │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Performance │ 92 │ 8 │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Bug prevention │ 100 │ 16 │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Code style │ 100 │ 13 │ -└─────────────────────────────────────────────────────────┴─────────┴──────────┘ +┌──────────────────┬─────────┬──────────┐ +│ Category │ Score │ Audits │ +├──────────────────┼─────────┼──────────┤ +│ Performance │ 92 │ 8 │ +│ Bug prevention │ 100 │ 16 │ +│ Code style │ 100 │ 13 │ +└──────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt index bfb4f30bb..00d567cb6 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout-verbose.txt @@ -73,15 +73,13 @@ Lighthouse audits Categories -┌─────────────────────────────────────────────────────────┬─────────┬──────────┐ -│ Category │ Score │ Audits │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Performance │ 92 │ 8 │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Bug prevention │ 68 │ 16 │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Code style │ 54 │ 13 │ -└─────────────────────────────────────────────────────────┴─────────┴──────────┘ +┌──────────────────┬─────────┬──────────┐ +│ Category │ Score │ Audits │ +├──────────────────┼─────────┼──────────┤ +│ Performance │ 92 │ 8 │ +│ Bug prevention │ 68 │ 16 │ +│ Code style │ 54 │ 13 │ +└──────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev diff --git a/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt b/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt index df0e5b95e..48c06632e 100644 --- a/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt +++ b/packages/utils/src/lib/reports/__snapshots__/report-stdout.txt @@ -31,15 +31,13 @@ Lighthouse audits Categories -┌─────────────────────────────────────────────────────────┬─────────┬──────────┐ -│ Category │ Score │ Audits │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Performance │ 92 │ 8 │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Bug prevention │ 68 │ 16 │ -├─────────────────────────────────────────────────────────┼─────────┼──────────┤ -│ Code style │ 54 │ 13 │ -└─────────────────────────────────────────────────────────┴─────────┴──────────┘ +┌──────────────────┬─────────┬──────────┐ +│ Category │ Score │ Audits │ +├──────────────────┼─────────┼──────────┤ +│ Performance │ 92 │ 8 │ +│ Bug prevention │ 68 │ 16 │ +│ Code style │ 54 │ 13 │ +└──────────────────┴─────────┴──────────┘ Made with ❤ by code-pushup.dev diff --git a/packages/utils/src/lib/reports/constants.ts b/packages/utils/src/lib/reports/constants.ts index e2f6f9c0a..e81c6eaa1 100644 --- a/packages/utils/src/lib/reports/constants.ts +++ b/packages/utils/src/lib/reports/constants.ts @@ -14,10 +14,4 @@ export const REPORT_HEADLINE_TEXT = 'Code PushUp Report'; export const CODE_PUSHUP_UNICODE_LOGO = '<✓>'; -export const REPORT_RAW_OVERVIEW_TABLE_HEADERS = [ - 'Category', - 'Score', - 'Audits', -]; - export const AUDIT_DETAILS_HEADING_LEVEL = HIERARCHY.level_4; diff --git a/packages/utils/src/lib/reports/log-stdout-summary.int.test.ts b/packages/utils/src/lib/reports/log-stdout-summary.int.test.ts index 754000739..cf2267ff1 100644 --- a/packages/utils/src/lib/reports/log-stdout-summary.int.test.ts +++ b/packages/utils/src/lib/reports/log-stdout-summary.int.test.ts @@ -1,7 +1,6 @@ import { beforeAll, describe, expect, vi } from 'vitest'; import { removeColorCodes, reportMock } from '@code-pushup/test-utils'; import { logger } from '../logger.js'; -import { ui } from '../logging.js'; import { logStdoutSummary } from './log-stdout-summary.js'; import { scoreReport } from './scoring.js'; import { sortReport } from './sorting.js'; @@ -16,12 +15,6 @@ describe('logStdoutSummary', () => { vi.mocked(logger.newline).mockImplementation(() => { stdout += '\n'; }); - // console.log is used inside the @poppinss/cliui logger when in "normal" mode - vi.spyOn(console, 'log').mockImplementation(message => { - stdout += `${message}\n`; - }); - // we want to see table and sticker logs in the final style ("raw" don't show borders etc so we use `console.log` here) - ui().switchMode('normal'); }); beforeEach(() => { @@ -29,10 +22,6 @@ describe('logStdoutSummary', () => { logger.setVerbose(false); }); - afterAll(() => { - ui().switchMode('raw'); - }); - it('should contain all sections when using the fixture report', async () => { logStdoutSummary(sortReport(scoreReport(reportMock()))); diff --git a/packages/utils/src/lib/reports/log-stdout-summary.ts b/packages/utils/src/lib/reports/log-stdout-summary.ts index 74b3f2a34..2763cd6ef 100644 --- a/packages/utils/src/lib/reports/log-stdout-summary.ts +++ b/packages/utils/src/lib/reports/log-stdout-summary.ts @@ -1,14 +1,11 @@ import ansis from 'ansis'; import type { AuditReport } from '@code-pushup/models'; import { logger } from '../logger.js'; -import { ui } from '../logging.js'; import { formatAsciiTable } from '../text-formats/ascii/table.js'; -import { TERMINAL_WIDTH } from '../text-formats/constants.js'; import { CODE_PUSHUP_DOMAIN, FOOTER_PREFIX, REPORT_HEADLINE_TEXT, - REPORT_RAW_OVERVIEW_TABLE_HEADERS, } from './constants.js'; import type { ScoredReport } from './types.js'; import { @@ -92,35 +89,25 @@ export function logCategories({ plugins, categories, }: Required>): void { - const hAlign = (idx: number) => (idx === 0 ? 'left' : 'right'); - - const rows = categories.map(({ title, score, scoreTarget, refs }) => [ - title, - `${binaryIconPrefix(score, scoreTarget)}${applyScoreColor({ score })}`, - countCategoryAudits(refs, plugins), - ]); - // TODO: replace @poppinss/cliui - const table = ui().table(); - // eslint-disable-next-line @typescript-eslint/no-magic-numbers - table.columnWidths([TERMINAL_WIDTH - 9 - 10 - 4, 9, 10]); - table.head( - REPORT_RAW_OVERVIEW_TABLE_HEADERS.map((heading, idx) => ({ - content: ansis.cyan(heading), - hAlign: hAlign(idx), - })), - ); - rows.forEach(row => - table.row( - row.map((content, idx) => ({ - content: content.toString(), - hAlign: hAlign(idx), - })), + logger.info( + formatAsciiTable( + { + title: ansis.bold.magentaBright('Categories'), + columns: [ + { key: 'title', label: ansis.cyan('Category'), align: 'left' }, + { key: 'score', label: ansis.cyan('Score'), align: 'right' }, + { key: 'audits', label: ansis.cyan('Audits'), align: 'right' }, + ], + rows: categories.map(({ title, score, scoreTarget, refs }) => ({ + title, + score: `${binaryIconPrefix(score, scoreTarget)}${applyScoreColor({ score })}`, + audits: countCategoryAudits(refs, plugins), + })), + }, + { padding: 2 }, ), ); - logger.info(ansis.bold.magentaBright('Categories')); - logger.newline(); - table.render(); logger.newline(); } diff --git a/packages/utils/src/lib/reports/log-stdout-summary.unit.test.ts b/packages/utils/src/lib/reports/log-stdout-summary.unit.test.ts index 6e5d1ebd7..8b4a656d9 100644 --- a/packages/utils/src/lib/reports/log-stdout-summary.unit.test.ts +++ b/packages/utils/src/lib/reports/log-stdout-summary.unit.test.ts @@ -1,7 +1,7 @@ +import ansis from 'ansis'; import { beforeAll, describe, expect, vi } from 'vitest'; import { removeColorCodes } from '@code-pushup/test-utils'; import { logger } from '../logger.js'; -import { ui } from '../logging.js'; import { binaryIconPrefix, logCategories, @@ -10,24 +10,16 @@ import { import type { ScoredReport } from './types.js'; describe('logCategories', () => { - let logs: string[]; - - beforeAll(() => { - logs = []; - // console.log is used inside the logger when in "normal" mode - vi.spyOn(console, 'log').mockImplementation(msg => { - logs = [...logs, msg]; - }); - // we want to see table and sticker logs in the final style ("raw" don't show borders etc so we use `console.log` here) - ui().switchMode('normal'); - }); + let stdout: string; - afterEach(() => { - logs = []; + beforeEach(() => { + stdout = ''; }); - afterAll(() => { - ui().switchMode('raw'); + beforeAll(() => { + vi.mocked(logger.info).mockImplementation(message => { + stdout += `${message}\n`; + }); }); it('should list categories', () => { @@ -64,13 +56,18 @@ describe('logCategories', () => { logCategories({ plugins, categories }); - const output = logs.join('\n'); - - expect(output).not.toContain('✅'); - expect(output).not.toContain('❌'); - expect(output).toContain('Performance'); - expect(output).toContain('42'); - expect(output).toContain('1'); + expect(logger.info).toHaveBeenCalledTimes(1); + expect(ansis.strip(stdout)).toBe( + ` +Categories + +┌───────────────┬─────────┬──────────┐ +│ Category │ Score │ Audits │ +├───────────────┼─────────┼──────────┤ +│ Performance │ 42 │ 1 │ +└───────────────┴─────────┴──────────┘ +`.trimStart(), + ); }); it('should list categories with score < scoreTarget', () => { @@ -108,13 +105,18 @@ describe('logCategories', () => { logCategories({ plugins, categories }); - const output = logs.join('\n'); - - expect(output).not.toContain('✓'); - expect(output).toContain('✗'); - expect(output).toContain('Performance'); - expect(output).toContain('42'); - expect(output).toContain('1'); + expect(logger.info).toHaveBeenCalledTimes(1); + expect(ansis.strip(stdout)).toBe( + ` +Categories + +┌───────────────┬─────────┬──────────┐ +│ Category │ Score │ Audits │ +├───────────────┼─────────┼──────────┤ +│ Performance │ ✗ 42 │ 1 │ +└───────────────┴─────────┴──────────┘ +`.trimStart(), + ); }); it('should list categories with score >= scoreTarget', () => { @@ -152,13 +154,18 @@ describe('logCategories', () => { logCategories({ plugins, categories }); - const output = logs.join('\n'); - - expect(output).toContain('✓'); - expect(output).not.toContain('✗'); - expect(output).toContain('Performance'); - expect(output).toContain('100'); - expect(output).toContain('1'); + expect(logger.info).toHaveBeenCalledTimes(1); + expect(ansis.strip(stdout)).toBe( + ` +Categories + +┌───────────────┬─────────┬──────────┐ +│ Category │ Score │ Audits │ +├───────────────┼─────────┼──────────┤ +│ Performance │ ✓ 100 │ 1 │ +└───────────────┴─────────┴──────────┘ +`.trimStart(), + ); }); }); From 44a49f46d41ba8fd85747bf3702dd09a62a2f59b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 14 Nov 2025 16:24:46 +0100 Subject: [PATCH 8/9] feat(cli): replace @poppinss/cliui sticker for upload hints --- .../cli/src/lib/collect/collect-command.ts | 26 ++++++------- .../cli/src/lib/implementation/logging.ts | 26 ++++++------- packages/utils/src/index.ts | 1 + .../src/lib/text-formats/ascii/sticker.ts | 8 ++++ .../text-formats/ascii/sticker.unit.test.ts | 38 +++++++++++++++++++ .../utils/src/lib/text-formats/ascii/table.ts | 10 +++-- 6 files changed, 76 insertions(+), 33 deletions(-) create mode 100644 packages/utils/src/lib/text-formats/ascii/sticker.ts create mode 100644 packages/utils/src/lib/text-formats/ascii/sticker.unit.test.ts diff --git a/packages/cli/src/lib/collect/collect-command.ts b/packages/cli/src/lib/collect/collect-command.ts index 54ff5c30c..84dc6f4c1 100644 --- a/packages/cli/src/lib/collect/collect-command.ts +++ b/packages/cli/src/lib/collect/collect-command.ts @@ -4,7 +4,11 @@ import { type CollectAndPersistReportsOptions, collectAndPersistReports, } from '@code-pushup/core'; -import { formatAsciiLink, logger, ui } from '@code-pushup/utils'; +import { + formatAsciiLink, + formatAsciiSticker, + logger, +} from '@code-pushup/utils'; import { CLI_NAME } from '../constants.js'; import { collectSuccessfulLog, @@ -40,28 +44,20 @@ export function yargsCollectCommandObject(): CommandModule { } export function renderUploadAutorunHint(): void { - // TODO: replace @poppinss/cliui - ui() - .sticker() - .add(ansis.bold.gray('💡 Visualize your reports')) - .add('') - .add( + logger.info( + formatAsciiSticker([ + ansis.bold.gray('💡 Visualize your reports'), + '', `${ansis.gray('❯')} npx code-pushup upload - ${ansis.gray( 'Run upload to upload the created report to the server', )}`, - ) - .add( ` ${formatAsciiLink( 'https://github.com/code-pushup/cli/tree/main/packages/cli#upload-command', )}`, - ) - .add( `${ansis.gray('❯')} npx code-pushup autorun - ${ansis.gray('Run collect & upload')}`, - ) - .add( ` ${formatAsciiLink( 'https://github.com/code-pushup/cli/tree/main/packages/cli#autorun-command', )}`, - ) - .render(); + ]), + ); } diff --git a/packages/cli/src/lib/implementation/logging.ts b/packages/cli/src/lib/implementation/logging.ts index 4b730e772..3add53b11 100644 --- a/packages/cli/src/lib/implementation/logging.ts +++ b/packages/cli/src/lib/implementation/logging.ts @@ -1,5 +1,9 @@ import ansis from 'ansis'; -import { formatAsciiLink, logger, ui } from '@code-pushup/utils'; +import { + formatAsciiLink, + formatAsciiSticker, + logger, +} from '@code-pushup/utils'; export function renderConfigureCategoriesHint(): void { logger.debug( @@ -19,30 +23,22 @@ export function collectSuccessfulLog(): void { } export function renderIntegratePortalHint(): void { - // TODO: replace @poppinss/cliui - ui() - .sticker() - .add(ansis.bold.gray('💡 Integrate the portal')) - .add('') - .add( + logger.info( + formatAsciiSticker([ + ansis.bold.gray('💡 Integrate the portal'), + '', `${ansis.gray('❯')} Upload a report to the server - ${ansis.gray( 'npx code-pushup upload', )}`, - ) - .add( ` ${formatAsciiLink( 'https://github.com/code-pushup/cli/tree/main/packages/cli#upload-command', )}`, - ) - .add( `${ansis.gray('❯')} ${ansis.gray('Portal Integration')} - ${formatAsciiLink( 'https://github.com/code-pushup/cli/blob/main/packages/cli/README.md#portal-integration', )}`, - ) - .add( `${ansis.gray('❯')} ${ansis.gray('Upload Command')} - ${formatAsciiLink( 'https://github.com/code-pushup/cli/blob/main/packages/cli/README.md#portal-integration', )}`, - ) - .render(); + ]), + ); } diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 937a7b2fc..5b0ce6927 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -145,6 +145,7 @@ export { } from './lib/reports/utils.js'; export { isSemver, normalizeSemver, sortSemvers } from './lib/semver.js'; export { formatAsciiLink } from './lib/text-formats/ascii/link.js'; +export { formatAsciiSticker } from './lib/text-formats/ascii/sticker.js'; export { formatAsciiTable } from './lib/text-formats/ascii/table.js'; export { formatAsciiTree } from './lib/text-formats/ascii/tree.js'; export * from './lib/text-formats/index.js'; diff --git a/packages/utils/src/lib/text-formats/ascii/sticker.ts b/packages/utils/src/lib/text-formats/ascii/sticker.ts new file mode 100644 index 000000000..fe619f23c --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/sticker.ts @@ -0,0 +1,8 @@ +import { formatAsciiTable } from './table.js'; + +export function formatAsciiSticker(lines: string[]): string { + return formatAsciiTable( + { rows: ['', ...lines, ''].map(line => [line]) }, + { padding: 4 }, + ); +} diff --git a/packages/utils/src/lib/text-formats/ascii/sticker.unit.test.ts b/packages/utils/src/lib/text-formats/ascii/sticker.unit.test.ts new file mode 100644 index 000000000..60f283199 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/sticker.unit.test.ts @@ -0,0 +1,38 @@ +import ansis from 'ansis'; +import { formatAsciiSticker } from './sticker'; + +describe('formatAsciiSticker', () => { + it('should frame lines with border and padding', () => { + const output = formatAsciiSticker(['Hello,', 'How are you today?']); + expect(ansis.strip(output)).toBe( + ` +┌──────────────────────────┐ +│ │ +│ Hello, │ +│ How are you today? │ +│ │ +└──────────────────────────┘ + `.trim(), + ); + }); + + it('should align emojis, color codes and line breaks correctly', () => { + const output = formatAsciiSticker([ + `✅ ${ansis.bold('ESLint')} ${ansis.gray('(1.2 s)')}`, + `✅ ${ansis.bold('Code Coverage')} ${ansis.gray('(680 ms)')}`, + `❌ ${ansis.bold('Lighthouse')}\n - ${ansis.red('Unable to connect to Chrome')}`, + ]); + expect(ansis.strip(output)).toBe( + ` +┌────────────────────────────────────────┐ +│ │ +│ ✅ ESLint (1.2 s) │ +│ ✅ Code Coverage (680 ms) │ +│ ❌ Lighthouse │ +│ - Unable to connect to Chrome │ +│ │ +└────────────────────────────────────────┘ + `.trim(), + ); + }); +}); diff --git a/packages/utils/src/lib/text-formats/ascii/table.ts b/packages/utils/src/lib/text-formats/ascii/table.ts index 207d36287..3d366631d 100644 --- a/packages/utils/src/lib/text-formats/ascii/table.ts +++ b/packages/utils/src/lib/text-formats/ascii/table.ts @@ -142,7 +142,7 @@ function wrapRow(cells: TableCell[], columnWidths: number[]): TableCell[][] { const wrapped: string = wrapText(cell.text, columnWidths[colIndex]); const lines = wrapped.split('\n').filter(Boolean); - const rowCount = Math.max(acc.length, lines.length); + const rowCount = Math.max(acc.length, lines.length, 1); return Array.from({ length: rowCount }).map((_, rowIndex) => { const prevCols = @@ -154,7 +154,7 @@ function wrapRow(cells: TableCell[], columnWidths: number[]): TableCell[][] { } function wrapText(text: string, width: number | undefined): string { - if (!width || stringWidth(text) <= width) { + if (!width || getTextWidth(text) <= width) { return text; } const words = extractWords(text); @@ -173,6 +173,10 @@ function wrapText(text: string, width: number | undefined): string { return wrapAnsi(textWithSplitLongWords, width); } +function getTextWidth(text: string): number { + return Math.max(...text.split('\n').map(line => stringWidth(line))); +} + function extractWords(text: string): string[] { return ansis .strip(text) @@ -234,7 +238,7 @@ function getColumnTexts(table: NormalizedTable): string[][] { function aggregateColumnsStats(columnTexts: string[][]): ColumnStats[] { return columnTexts.map(texts => { - const widths = texts.map(text => stringWidth(text)); + const widths = texts.map(getTextWidth); const longestWords = texts .flatMap(extractWords) .toSorted((a, b) => b.length - a.length); From d2091ed186c488b95637b2701fda7695fb1d6dab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= Date: Fri, 14 Nov 2025 16:41:06 +0100 Subject: [PATCH 9/9] feat(utils): remove @poppinss/cliui dependency --- package-lock.json | 321 +----------------- package.json | 1 - .../runner/details/item-value.unit.test.ts | 12 +- .../src/lib/runner/utils.unit.test.ts | 4 +- packages/utils/package.json | 7 +- packages/utils/src/index.ts | 1 - packages/utils/src/lib/logging.ts | 13 - .../lib/vitest-config-factory.unit.test.ts | 9 - .../src/lib/vitest-setup-files.ts | 2 - .../src/lib/vitest-setup-files.unit.test.ts | 206 ++--------- testing/test-setup-config/tsconfig.test.json | 3 +- testing/test-setup/src/lib/cliui.mock.ts | 15 - .../src/lib/extend/ui-logger.matcher.ts | 98 ------ .../src/lib/extend/ui-logger.matcher.utils.ts | 83 ----- .../ui-logger.matcher.utils.unit.test.ts | 82 ----- testing/test-setup/src/vitest.d.ts | 2 - 16 files changed, 46 insertions(+), 813 deletions(-) delete mode 100644 packages/utils/src/lib/logging.ts delete mode 100644 testing/test-setup/src/lib/cliui.mock.ts delete mode 100644 testing/test-setup/src/lib/extend/ui-logger.matcher.ts delete mode 100644 testing/test-setup/src/lib/extend/ui-logger.matcher.utils.ts delete mode 100644 testing/test-setup/src/lib/extend/ui-logger.matcher.utils.unit.test.ts diff --git a/package-lock.json b/package-lock.json index b2beeeeda..cbb2f418e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,6 @@ "@axe-core/playwright": "^4.11.0", "@code-pushup/portal-client": "^0.16.0", "@nx/devkit": "21.4.1", - "@poppinss/cliui": "6.4.1", "@swc/helpers": "0.5.13", "ansis": "^3.3.2", "build-md": "^0.4.2", @@ -2426,15 +2425,6 @@ "vscode-material-icons": "^0.1.0" } }, - "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "optional": true, - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/@commitlint/cli": { "version": "19.6.0", "resolved": "https://registry.npmjs.org/@commitlint/cli/-/cli-19.6.0.tgz", @@ -6841,58 +6831,6 @@ "integrity": "sha512-j7P6Rgr3mmtdkeDGTe0E/aYyWEWVtc5yFXtHCRHs28/jptDEWfaVOc5T7cblqy1XKPPfCxJc/8DwQ5YgLOZOVQ==", "dev": true }, - "node_modules/@poppinss/cliui": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/@poppinss/cliui/-/cliui-6.4.1.tgz", - "integrity": "sha512-tdV3QpAfrPFRLPOh98F8QxWBvwYF3ziWGGtpVqfZtFNTFkC7nQnVQlUW55UtQ7rkeMmFohxfDI+2JNWScGJ1jQ==", - "dependencies": { - "@poppinss/colors": "^4.1.3", - "cli-boxes": "^3.0.0", - "cli-table3": "^0.6.4", - "cli-truncate": "^4.0.0", - "log-update": "^6.0.0", - "pretty-hrtime": "^1.0.3", - "string-width": "^7.1.0", - "supports-color": "^9.4.0", - "terminal-size": "^4.0.0", - "wordwrap": "^1.0.0" - }, - "engines": { - "node": ">=18.16.0" - } - }, - "node_modules/@poppinss/cliui/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==" - }, - "node_modules/@poppinss/cliui/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@poppinss/colors": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.3.tgz", - "integrity": "sha512-A0FjJ6x14donWDN3bHAFFjJaPWTwM2PgWT834+bPKVK6Xukf25CscoRqCPYI939a8yuJFX9PYWWnVbUVI0E2Cg==", - "dependencies": { - "kleur": "^4.1.5" - }, - "engines": { - "node": ">=18.16.0" - } - }, "node_modules/@puppeteer/browsers": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-2.3.0.tgz", @@ -13080,17 +13018,6 @@ "node": ">=6" } }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -13116,93 +13043,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/cli-table3": { - "version": "0.6.5", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz", - "integrity": "sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ==", - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cli-table3/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" - }, - "node_modules/cli-table3/node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-table3/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-table3/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-truncate": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-4.0.0.tgz", - "integrity": "sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==", - "dependencies": { - "slice-ansi": "^5.0.0", - "string-width": "^7.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-truncate/node_modules/emoji-regex": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==" - }, - "node_modules/cli-truncate/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/cli-width": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", @@ -15313,17 +15153,6 @@ "node": ">=4" } }, - "node_modules/environment": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", - "integrity": "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -19492,17 +19321,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-fullwidth-code-point": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz", - "integrity": "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-generator-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", @@ -22253,14 +22071,6 @@ "node": ">=0.10.0" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "engines": { - "node": ">=6" - } - }, "node_modules/knip": { "version": "5.37.2", "resolved": "https://registry.npmjs.org/knip/-/knip-5.37.2.tgz", @@ -22880,78 +22690,6 @@ "node": ">=8" } }, - "node_modules/log-update": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", - "integrity": "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w==", - "dependencies": { - "ansi-escapes": "^7.0.0", - "cli-cursor": "^5.0.0", - "slice-ansi": "^7.1.0", - "strip-ansi": "^7.1.0", - "wrap-ansi": "^9.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-escapes": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.0.0.tgz", - "integrity": "sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==", - "dependencies": { - "environment": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/log-update/node_modules/is-fullwidth-code-point": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz", - "integrity": "sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA==", - "dependencies": { - "get-east-asian-width": "^1.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/log-update/node_modules/slice-ansi": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.0.tgz", - "integrity": "sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg==", - "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, "node_modules/log4js": { "version": "6.9.1", "resolved": "https://registry.npmjs.org/log4js/-/log4js-6.9.1.tgz", @@ -25926,14 +25664,6 @@ "integrity": "sha512-gwBNIP8ZAYev/ORDWW0QvxdwPXwxBtLsdsJgSc7eDIRt8ubP+rxUBzPsrwnu16fgEF8Bx4lh/+mvQvJzcTM6Kw==", "license": "MIT" }, - "node_modules/pretty-hrtime": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", - "integrity": "sha512-66hKPCr+72mlfiSjlEB1+45IjXSqvVAIy6mocupoww4tBFE9R9IhwwUGoI4G++Tc9Aq+2rxOt0RFU6gPcrte0A==", - "engines": { - "node": ">= 0.8" - } - }, "node_modules/pretty-ms": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.1.0.tgz", @@ -27670,32 +27400,6 @@ "node": ">=8" } }, - "node_modules/slice-ansi": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-5.0.0.tgz", - "integrity": "sha512-FC+lgizVPfie0kkhqUScwRu1O/lF6NOgJmlCgK+/LYxDCTk8sGelYaHDhFcDN+Sn3Cv+3VSa4Byeo+IMCzpMgQ==", - "dependencies": { - "ansi-styles": "^6.0.0", - "is-fullwidth-code-point": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" - } - }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", - "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -28436,17 +28140,6 @@ "integrity": "sha512-nMIjMrd5Z2nuB2RZCKJfFMjgS3fygbeyGk9PxPPaJR1RIcyN9yn4A63Isovzm3ZtQuEkLBVgMdPup8UeLH7aQw==", "dev": true }, - "node_modules/supports-color": { - "version": "9.4.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-9.4.0.tgz", - "integrity": "sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/supports-color?sponsor=1" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -28581,17 +28274,6 @@ "node": ">= 6" } }, - "node_modules/terminal-size": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/terminal-size/-/terminal-size-4.0.0.tgz", - "integrity": "sha512-rcdty1xZ2/BkWa4ANjWRp4JGpda2quksXIHgn5TMjNBPZfwzJIgR68DKfSYiTL+CZWowDX/sbOo5ME/FRURvYQ==", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/terser": { "version": "5.37.0", "resolved": "https://registry.npmjs.org/terser/-/terser-5.37.0.tgz", @@ -31689,7 +31371,8 @@ "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==" + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "dev": true }, "node_modules/wrap-ansi": { "version": "9.0.2", diff --git a/package.json b/package.json index 3e717e566..3af1d474c 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "@axe-core/playwright": "^4.11.0", "@code-pushup/portal-client": "^0.16.0", "@nx/devkit": "21.4.1", - "@poppinss/cliui": "6.4.1", "@swc/helpers": "0.5.13", "ansis": "^3.3.2", "build-md": "^0.4.2", diff --git a/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts index 0bb3ad394..41957d3ff 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts @@ -1,7 +1,7 @@ import ansis from 'ansis'; import type Details from 'lighthouse/types/lhr/audit-details'; -import { beforeAll, describe, expect, it } from 'vitest'; -import { logger, ui } from '@code-pushup/utils'; +import { describe, expect, it } from 'vitest'; +import { logger } from '@code-pushup/utils'; import { type SimpleItemValue, formatTableItemPropertyValue, @@ -49,10 +49,6 @@ describe('parseSimpleItemValue', () => { }); describe('parseTableItemPropertyValue', () => { - beforeAll(() => { - ui().switchMode('raw'); - }); - it('should parse undefined', () => { expect(parseTableItemPropertyValue(undefined)).toBe(''); }); @@ -184,10 +180,6 @@ describe('formatTableItemPropertyValue', () => { return result; }; - beforeAll(() => { - ui().switchMode('raw'); - }); - it('should format undefined to empty string', () => { expect(formatTableItemPropertyValue(undefined)).toBe(''); }); diff --git a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts index a3d0df090..42d63f027 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts @@ -13,7 +13,7 @@ import { auditOutputsSchema, } from '@code-pushup/models'; import { MEMFS_VOLUME } from '@code-pushup/test-utils'; -import { logger, ui } from '@code-pushup/utils'; +import { logger } from '@code-pushup/utils'; import { DEFAULT_CLI_FLAGS } from './constants.js'; import { unsupportedDetailTypes } from './details/details.js'; import type { LighthouseCliFlags } from './types.js'; @@ -247,7 +247,7 @@ describe('toAuditOutputs', () => { }) as Result, ), ); - expect(ui()).not.toHaveLogs(); + expect(logger.warn).not.toHaveBeenCalled(); }); it('should inform that for all unsupported details if verbose IS given', () => { diff --git a/packages/utils/package.json b/packages/utils/package.json index 9c5b1922f..ef3579ab4 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -28,18 +28,17 @@ }, "dependencies": { "@code-pushup/models": "0.87.2", - "@poppinss/cliui": "^6.4.0", "ansis": "^3.3.0", "build-md": "^0.4.2", "bundle-require": "^5.1.0", "esbuild": "^0.25.2", "multi-progress-bars": "^5.0.3", + "ora": "^9.0.0", "semver": "^7.6.0", "simple-git": "^3.20.0", "string-width": "^8.1.0", - "ora": "^9.0.0", - "zod": "^4.0.5", - "wrap-ansi": "^9.0.2" + "wrap-ansi": "^9.0.2", + "zod": "^4.0.5" }, "files": [ "src", diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 5b0ce6927..173389de0 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -88,7 +88,6 @@ export { export { interpolate } from './lib/interpolate.js'; export { logMultipleResults } from './lib/log-results.js'; export { Logger, logger } from './lib/logger.js'; -export { ui, type CliUi } from './lib/logging.js'; export { mergeConfigs } from './lib/merge-configs.js'; export { addIndex, diff --git a/packages/utils/src/lib/logging.ts b/packages/utils/src/lib/logging.ts deleted file mode 100644 index aff424ec1..000000000 --- a/packages/utils/src/lib/logging.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { cliui } from '@poppinss/cliui'; - -// TODO: remove once logger is used everywhere - -export type CliUi = ReturnType; - -// eslint-disable-next-line functional/no-let -let cliUISingleton: CliUi | undefined; - -export function ui(): CliUi { - cliUISingleton ??= cliui(); - return cliUISingleton; -} diff --git a/testing/test-setup-config/src/lib/vitest-config-factory.unit.test.ts b/testing/test-setup-config/src/lib/vitest-config-factory.unit.test.ts index efd688d88..59f980078 100644 --- a/testing/test-setup-config/src/lib/vitest-config-factory.unit.test.ts +++ b/testing/test-setup-config/src/lib/vitest-config-factory.unit.test.ts @@ -53,9 +53,6 @@ describe('createVitestConfig', () => { expect(setupFiles).toContain( '../../testing/test-setup/src/lib/reset.mocks.ts', ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/cliui.mock.ts', - ); expect(setupFiles).toContain( '../../testing/test-setup/src/lib/fs.mock.ts', ); @@ -68,9 +65,6 @@ describe('createVitestConfig', () => { expect(setupFiles).toContain( '../../testing/test-setup/src/lib/logger.mock.ts', ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', - ); expect(setupFiles).toContain( '../../testing/test-setup/src/lib/extend/markdown-table.matcher.ts', ); @@ -145,9 +139,6 @@ describe('createVitestConfig', () => { expect(setupFiles).toContain( '../../testing/test-setup/src/lib/extend/path.matcher.ts', ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', - ); }); it('should not enable typecheck for integration tests', () => { diff --git a/testing/test-setup-config/src/lib/vitest-setup-files.ts b/testing/test-setup-config/src/lib/vitest-setup-files.ts index 2f67c4c6d..b08388b78 100644 --- a/testing/test-setup-config/src/lib/vitest-setup-files.ts +++ b/testing/test-setup-config/src/lib/vitest-setup-files.ts @@ -7,7 +7,6 @@ import type { TestKind } from './vitest-config-factory.js'; * which is why they use `../../` to navigate to the workspace root first. */ const CUSTOM_MATCHERS = [ - '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', '../../testing/test-setup/src/lib/extend/markdown-table.matcher.ts', '../../testing/test-setup/src/lib/extend/jest-extended.matcher.ts', '../../testing/test-setup/src/lib/extend/path.matcher.ts', @@ -23,7 +22,6 @@ const UNIT_TEST_SETUP_FILES = [ '../../testing/test-setup/src/lib/fs.mock.ts', '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts', - '../../testing/test-setup/src/lib/cliui.mock.ts', '../../testing/test-setup/src/lib/git.mock.ts', '../../testing/test-setup/src/lib/portal-client.mock.ts', '../../testing/test-setup/src/lib/logger.mock.ts', diff --git a/testing/test-setup-config/src/lib/vitest-setup-files.unit.test.ts b/testing/test-setup-config/src/lib/vitest-setup-files.unit.test.ts index f585bc0b5..dc663a5df 100644 --- a/testing/test-setup-config/src/lib/vitest-setup-files.unit.test.ts +++ b/testing/test-setup-config/src/lib/vitest-setup-files.unit.test.ts @@ -1,185 +1,49 @@ import { describe, expect, it } from 'vitest'; +import type { TestKind } from './vitest-config-factory.js'; import { getSetupFiles } from './vitest-setup-files.js'; -describe('vitest-setup-files', () => { - describe('getSetupFiles', () => { - describe('unit test setup files', () => { - it('should return all required setup files for unit tests', () => { - const setupFiles = getSetupFiles('unit'); - - expect(setupFiles).toHaveLength(11); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/console.mock.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/reset.mocks.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/cliui.mock.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/fs.mock.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/git.mock.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/portal-client.mock.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/logger.mock.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/markdown-table.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/jest-extended.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/path.matcher.ts', - ); - }); - }); - - describe('integration test setup files', () => { - it('should return exactly 8 setup files with essential mocks and custom matchers', () => { - const setupFiles = getSetupFiles('int'); - - expect(setupFiles).toHaveLength(8); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/console.mock.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/reset.mocks.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/chrome-path.mock.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/logger.mock.ts', - ); - }); - - it('should include custom matchers for integration tests', () => { - const setupFiles = getSetupFiles('int'); - - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/markdown-table.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/jest-extended.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/path.matcher.ts', - ); - }); - - it('should NOT include fs, cliui, git, and portal-client mocks for integration tests', () => { - const setupFiles = getSetupFiles('int'); - - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/fs.mock.ts', - ); - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/cliui.mock.ts', - ); - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/git.mock.ts', - ); - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/portal-client.mock.ts', - ); - }); - }); - - describe('e2e test setup files', () => { - it('should return exactly 5 setup files with minimal mocks', () => { - const setupFiles = getSetupFiles('e2e'); - - expect(setupFiles).toHaveLength(5); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/reset.mocks.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/markdown-table.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/jest-extended.matcher.ts', - ); - expect(setupFiles).toContain( - '../../testing/test-setup/src/lib/extend/path.matcher.ts', - ); - }); - - it('should NOT include any other mocks for e2e tests', () => { - const setupFiles = getSetupFiles('e2e'); - - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/console.mock.ts', - ); - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/fs.mock.ts', - ); - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/git.mock.ts', - ); - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/cliui.mock.ts', - ); - expect(setupFiles).not.toContain( - '../../testing/test-setup/src/lib/portal-client.mock.ts', - ); - }); - }); +describe('getSetupFiles', () => { + describe('relative paths', () => { + it.each(['unit', 'int', 'e2e'])( + 'should return paths for %s-test relative to config file location', + kind => { + const setupFiles = getSetupFiles(kind); + expect(setupFiles).toSatisfyAll(path => + /^\.\.\/\.\.\//.test(path), + ); + }, + ); + }); - describe('relative paths', () => { - it('should return paths relative to config file location', () => { - const unitFiles = getSetupFiles('unit'); - const intFiles = getSetupFiles('int'); - const e2eFiles = getSetupFiles('e2e'); + describe('return type', () => { + it('should return an array of strings', () => { + const setupFiles = getSetupFiles('unit'); - [...unitFiles, ...intFiles, ...e2eFiles].forEach(path => { - expect(path).toMatch(/^\.\.\/\.\.\//); - }); - }); + expect(Array.isArray(setupFiles)).toBe(true); + expect(setupFiles).toSatisfyAll( + item => typeof item === 'string', + ); }); + }); - describe('return type', () => { - it('should return a readonly array', () => { - const setupFiles = getSetupFiles('unit'); + describe('test kind differences', () => { + it('should return different setup files for different test kinds', () => { + const unitFiles = getSetupFiles('unit'); + const intFiles = getSetupFiles('int'); + const e2eFiles = getSetupFiles('e2e'); - expect(Array.isArray(setupFiles)).toBe(true); - }); + expect(unitFiles.length).not.toBe(intFiles.length); + expect(intFiles.length).not.toBe(e2eFiles.length); + expect(unitFiles.length).not.toBe(e2eFiles.length); }); - describe('test kind differences', () => { - it('should return different setup files for different test kinds', () => { - const unitFiles = getSetupFiles('unit'); - const intFiles = getSetupFiles('int'); - const e2eFiles = getSetupFiles('e2e'); - - expect(unitFiles.length).not.toBe(intFiles.length); - expect(intFiles.length).not.toBe(e2eFiles.length); - expect(unitFiles.length).not.toBe(e2eFiles.length); - }); - - it('should show hierarchy: unit has most, e2e has least', () => { - const unitFiles = getSetupFiles('unit'); - const intFiles = getSetupFiles('int'); - const e2eFiles = getSetupFiles('e2e'); + it('should show hierarchy: unit has most, e2e has least', () => { + const unitFiles = getSetupFiles('unit'); + const intFiles = getSetupFiles('int'); + const e2eFiles = getSetupFiles('e2e'); - expect(unitFiles.length).toBeGreaterThan(intFiles.length); - expect(intFiles.length).toBeGreaterThan(e2eFiles.length); - }); + expect(unitFiles.length).toBeGreaterThan(intFiles.length); + expect(intFiles.length).toBeGreaterThan(e2eFiles.length); }); }); }); diff --git a/testing/test-setup-config/tsconfig.test.json b/testing/test-setup-config/tsconfig.test.json index 5fddc20ae..375452b59 100644 --- a/testing/test-setup-config/tsconfig.test.json +++ b/testing/test-setup-config/tsconfig.test.json @@ -9,6 +9,7 @@ "src/vitest.d.ts", "src/**/*.unit.test.ts", "src/**/*.d.ts", - "src/**/*.int.test.ts" + "src/**/*.int.test.ts", + "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/testing/test-setup/src/lib/cliui.mock.ts b/testing/test-setup/src/lib/cliui.mock.ts deleted file mode 100644 index 4e55c5d8b..000000000 --- a/testing/test-setup/src/lib/cliui.mock.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { beforeAll, beforeEach, vi } from 'vitest'; - -// TODO: remove once logger is used everywhere - -beforeAll(async () => { - const utils: typeof import('@code-pushup/utils') = - await vi.importActual('@code-pushup/utils'); - utils.ui().switchMode('raw'); -}); - -beforeEach(async () => { - const { ui }: typeof import('@code-pushup/utils') = - await vi.importActual('@code-pushup/utils'); - ui().logger.flushLogs(); -}); diff --git a/testing/test-setup/src/lib/extend/ui-logger.matcher.ts b/testing/test-setup/src/lib/extend/ui-logger.matcher.ts deleted file mode 100644 index 083d58743..000000000 --- a/testing/test-setup/src/lib/extend/ui-logger.matcher.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { cliui } from '@poppinss/cliui'; -import type { SyncExpectationResult } from '@vitest/expect'; -import { expect } from 'vitest'; -import { - type ExpectedMessage, - type LogLevel, - extractLogDetails, - hasExpectedMessage, -} from './ui-logger.matcher.utils'; - -// TODO: remove once logger is used everywhere - -type CliUi = ReturnType; - -export type CustomUiLoggerMatchers = { - toHaveLogged: (level: LogLevel, message: ExpectedMessage) => void; - toHaveNthLogged: ( - nth: number, - level: LogLevel, - message: ExpectedMessage, - ) => void; - toHaveLoggedTimes: (times: number) => void; - toHaveLogs: () => void; -}; - -expect.extend({ - toHaveLogged: assertLogged, - // @ts-expect-error Custom matcher works despite TypeScript signature mismatch - toHaveNthLogged: assertNthLogged, - toHaveLoggedTimes: assertLogCount, - toHaveLogs: assertLogs, -}); - -function assertLogged( - actual: CliUi, - level: LogLevel, - message: ExpectedMessage, -): SyncExpectationResult { - const logs = extractLogDetails(actual.logger); - - const pass = logs.some( - log => log.level === level && hasExpectedMessage(message, log.message), - ); - return { - pass, - message: () => - pass - ? `Expected not to find a log with level "${level}" and message matching: ${message}` - : `Expected a log with level "${level}" and message matching: ${message}`, - }; -} - -function assertNthLogged( - actual: CliUi, - nth: number, - level: LogLevel, - message: ExpectedMessage, -): SyncExpectationResult { - const log = extractLogDetails(actual.logger)[nth - 1]; - - const pass = log?.level === level && hasExpectedMessage(message, log.message); - return { - pass, - message: () => - pass - ? `Expected not to find a log at position ${nth} with level "${level}" and message matching: ${message}` - : `Expected a log at position ${nth} with level "${level}" and message matching: ${message}`, - }; -} - -function assertLogs(actual: CliUi): SyncExpectationResult { - const logs = actual.logger.getRenderer().getLogs(); - - const pass = logs.length > 0; - return { - pass, - message: () => - pass - ? `Expected no logs, but found ${logs.length}` - : `Expected some logs, but no logs were produced`, - }; -} - -function assertLogCount( - actual: CliUi, - expected: number, -): SyncExpectationResult { - const logs = actual.logger.getRenderer().getLogs(); - - const pass = logs.length === expected; - return { - pass, - message: () => - pass - ? `Expected not to find exactly ${expected} logs, but found ${logs.length}` - : `Expected exactly ${expected} logs, but found ${logs.length}`, - }; -} diff --git a/testing/test-setup/src/lib/extend/ui-logger.matcher.utils.ts b/testing/test-setup/src/lib/extend/ui-logger.matcher.utils.ts deleted file mode 100644 index 4c7f8b789..000000000 --- a/testing/test-setup/src/lib/extend/ui-logger.matcher.utils.ts +++ /dev/null @@ -1,83 +0,0 @@ -import type { Logger } from '@poppinss/cliui'; -import type { LoggingTypes } from '@poppinss/cliui/build/src/types'; -import { removeColorCodes } from '@code-pushup/test-utils'; - -export type LogLevel = Exclude | 'warn' | 'log'; - -export type ExpectedMessage = - | string - | { asymmetricMatch: (value: string) => boolean }; - -type ExtractedMessage = { - styledMessage: string; - unstyledMessage: string; -}; - -type LogDetails = { - level: LogLevel; - message: ExtractedMessage; -}; - -const LOG_LEVELS = new Set([ - 'success', - 'error', - 'fatal', - 'info', - 'debug', - 'await', - 'warn', - 'log', -]); - -export function extractLogDetails(logger: Logger): LogDetails[] { - return logger - .getRenderer() - .getLogs() - .map( - ({ message }): LogDetails => ({ - level: extractLevel(message), - message: extractMessage(message), - }), - ); -} - -export function extractLevel(log: string): LogLevel { - const match = removeColorCodes(log).match(/^\[\s*\w+\((?\w+)\)\s*]/); - const level = match?.groups?.['level'] as LogLevel | undefined; - return level && LOG_LEVELS.has(level) ? level : 'log'; -} - -export function extractMessage(log: string): ExtractedMessage { - const match = log.match( - /^\[\s*\w+\((?\w+)\)\s*]\s*(?.+?(\.\s*)?)$/, - ); - const styledMessage = match?.groups?.['message'] ?? log; - const unstyledMessage = removeColorCodes(styledMessage); - return { styledMessage, unstyledMessage }; -} - -export function hasExpectedMessage( - expected: ExpectedMessage, - message: ExtractedMessage | undefined, -): boolean { - if (!message) { - return false; - } - if (isAsymmetricMatcher(expected)) { - return ( - expected.asymmetricMatch(message.styledMessage) || - expected.asymmetricMatch(message.unstyledMessage) - ); - } - return ( - message.styledMessage === expected || message.unstyledMessage === expected - ); -} - -function isAsymmetricMatcher( - value: unknown, -): value is { asymmetricMatch: (input: string) => boolean } { - return ( - typeof value === 'object' && value != null && 'asymmetricMatch' in value - ); -} diff --git a/testing/test-setup/src/lib/extend/ui-logger.matcher.utils.unit.test.ts b/testing/test-setup/src/lib/extend/ui-logger.matcher.utils.unit.test.ts deleted file mode 100644 index df91006c3..000000000 --- a/testing/test-setup/src/lib/extend/ui-logger.matcher.utils.unit.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { - extractLevel, - extractMessage, - hasExpectedMessage, -} from './ui-logger.matcher.utils'; - -describe('extractLevel', () => { - it('should extract level from an info log', () => { - expect(extractLevel('[ blue(info) ] Info message')).toBe('info'); - }); - - it('should extract level from a warning log', () => { - expect(extractLevel('[ yellow(warn) ] Warning message')).toBe('warn'); - }); - - it('should fall back to a default log level for a log without a level', () => { - expect(extractLevel('Message without level')).toBe('log'); - }); - - it('should fall back to a default log level for an invalid log level', () => { - expect(extractLevel('[ unknown ] Message with invalid level')).toBe('log'); - }); -}); - -describe('extractMessage', () => { - it('should extract styled and unstyled messages from a log', () => { - const { styledMessage, unstyledMessage } = extractMessage( - '[ blue(info) ] \u001B[90mRun merge-diffs...\u001B[39m', - ); - expect(styledMessage).toBe('\u001B[90mRun merge-diffs...\u001B[39m'); - expect(unstyledMessage).toBe('Run merge-diffs...'); - }); - - it('should handle logs without styling', () => { - const { styledMessage, unstyledMessage } = extractMessage( - 'Warning message without styles.', - ); - expect(styledMessage).toBe('Warning message without styles.'); - expect(unstyledMessage).toBe('Warning message without styles.'); - }); -}); - -describe('hasExpectedMessage', () => { - it('should return true for a matching styled message', () => { - const result = hasExpectedMessage('Styled message', { - styledMessage: 'Styled message', - unstyledMessage: 'Plain message', - }); - expect(result).toBe(true); - }); - - it('should return true for a matching unstyled message', () => { - const result = hasExpectedMessage('Plain message', { - styledMessage: 'Styled message', - unstyledMessage: 'Plain message', - }); - expect(result).toBe(true); - }); - - it('should return false for a non-matching message', () => { - const result = hasExpectedMessage('Non-matching message', { - styledMessage: 'Styled message', - unstyledMessage: 'Plain message', - }); - expect(result).toBe(false); - }); - - it('should return false for undefined message', () => { - const result = hasExpectedMessage('Expected message', undefined); - expect(result).toBe(false); - }); - - it('should handle asymmetric matchers', () => { - const asymmetricMatcher = expect.stringContaining('Styled'); - const result = hasExpectedMessage(asymmetricMatcher, { - styledMessage: 'Styled message', - unstyledMessage: 'Plain message', - }); - expect(result).toBe(true); - }); -}); diff --git a/testing/test-setup/src/vitest.d.ts b/testing/test-setup/src/vitest.d.ts index d9929e7a2..c5ccf01b1 100644 --- a/testing/test-setup/src/vitest.d.ts +++ b/testing/test-setup/src/vitest.d.ts @@ -4,12 +4,10 @@ import type { CustomAsymmetricPathMatchers, CustomPathMatchers, } from './lib/extend/path.matcher.js'; -import type { CustomUiLoggerMatchers } from './lib/extend/ui-logger.matcher.js'; declare module 'vitest' { interface Assertion extends CustomPathMatchers, - CustomUiLoggerMatchers, CustomMarkdownTableMatchers, JestExtendedMatchers {}