From 6ff5c69f2220da5f3db984e855e95c95d30d2a80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20Chalk?= <matej.chalk@flowup.cz> Date: Wed, 7 May 2025 17:13:08 +0200 Subject: [PATCH] feat(utils): generate ascii tree in full markdown report's audit details --- packages/utils/src/lib/reports/constants.ts | 4 + packages/utils/src/lib/reports/formatting.ts | 30 +- .../src/lib/reports/generate-md-report.ts | 57 ++-- .../reports/generate-md-report.unit.test.ts | 50 +++ .../ascii/__snapshots__/basic-tree.txt | 3 + .../__snapshots__/basic-tree.with-nesting.txt | 15 + .../__snapshots__/basic-tree.with-values.txt | 3 + .../ascii/__snapshots__/coverage-tree.txt | 15 + .../coverage-tree.with-missing-lines.txt | 8 + .../coverage-tree.with-missing-named.txt | 7 + .../utils/src/lib/text-formats/ascii/tree.ts | 118 +++++++ .../lib/text-formats/ascii/tree.unit.test.ts | 289 ++++++++++++++++++ 12 files changed, 568 insertions(+), 31 deletions(-) create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.txt create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-nesting.txt create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-values.txt create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.txt create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-lines.txt create mode 100644 packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-named.txt create mode 100644 packages/utils/src/lib/text-formats/ascii/tree.ts create mode 100644 packages/utils/src/lib/text-formats/ascii/tree.unit.test.ts diff --git a/packages/utils/src/lib/reports/constants.ts b/packages/utils/src/lib/reports/constants.ts index 07801403e..a40ac5e98 100644 --- a/packages/utils/src/lib/reports/constants.ts +++ b/packages/utils/src/lib/reports/constants.ts @@ -1,3 +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; @@ -20,3 +22,5 @@ export const REPORT_RAW_OVERVIEW_TABLE_HEADERS = [ 'Score', 'Audits', ]; + +export const AUDIT_DETAILS_HEADING_LEVEL = HIERARCHY.level_4; diff --git a/packages/utils/src/lib/reports/formatting.ts b/packages/utils/src/lib/reports/formatting.ts index 08e90a8b3..832107fd5 100644 --- a/packages/utils/src/lib/reports/formatting.ts +++ b/packages/utils/src/lib/reports/formatting.ts @@ -9,13 +9,15 @@ import type { AuditReport, SourceFileLocation, Table, + Tree, } from '@code-pushup/models'; -import { HIERARCHY } from '../text-formats/index.js'; +import { formatAsciiTree } from '../text-formats/ascii/tree.js'; import { columnsToStringArray, getColumnAlignments, rowToStringArray, } from '../text-formats/table.js'; +import { AUDIT_DETAILS_HEADING_LEVEL } from './constants.js'; import { getEnvironmentType, getGitHubBaseUrl, @@ -24,19 +26,19 @@ import { import type { MdReportOptions } from './types.js'; export function tableSection( - tableData: Table, + table: Table, options?: { level?: HeadingLevel; }, ): MarkdownDocument | null { - if (tableData.rows.length === 0) { + if (table.rows.length === 0) { return null; } - const { level = HIERARCHY.level_4 } = options ?? {}; - const columns = columnsToStringArray(tableData); - const alignments = getColumnAlignments(tableData); - const rows = rowToStringArray(tableData); - return new MarkdownDocument().heading(level, tableData.title).table( + const { level = AUDIT_DETAILS_HEADING_LEVEL } = options ?? {}; + const columns = columnsToStringArray(table); + const alignments = getColumnAlignments(table); + const rows = rowToStringArray(table); + return new MarkdownDocument().heading(level, table.title).table( columns.map((heading, i) => { const alignment = alignments[i]; if (alignment) { @@ -48,6 +50,18 @@ export function tableSection( ); } +export function treeSection( + tree: Tree, + options?: { + level?: HeadingLevel; + }, +): MarkdownDocument { + const { level = AUDIT_DETAILS_HEADING_LEVEL } = options ?? {}; + return new MarkdownDocument() + .heading(level, tree.title) + .code(formatAsciiTree(tree)); +} + // @TODO extract `Pick<AuditReport, 'docsUrl' | 'description'>` to a reusable schema and type export function metaDescription( audit: Pick<AuditReport, 'docsUrl' | 'description'>, diff --git a/packages/utils/src/lib/reports/generate-md-report.ts b/packages/utils/src/lib/reports/generate-md-report.ts index c452b22f6..d5e464b9b 100644 --- a/packages/utils/src/lib/reports/generate-md-report.ts +++ b/packages/utils/src/lib/reports/generate-md-report.ts @@ -3,6 +3,7 @@ import type { AuditReport, Issue, Report } from '@code-pushup/models'; import { formatDate, formatDuration } from '../formatting.js'; import { HIERARCHY } from '../text-formats/index.js'; import { + AUDIT_DETAILS_HEADING_LEVEL, FOOTER_PREFIX, README_LINK, REPORT_HEADLINE_TEXT, @@ -12,6 +13,7 @@ import { linkToLocalSourceForIde, metaDescription, tableSection, + treeSection, } from './formatting.js'; import { categoriesDetailsSection, @@ -73,48 +75,57 @@ export function auditDetailsIssues( if (issues.length === 0) { return null; } - return new MarkdownDocument().heading(HIERARCHY.level_4, 'Issues').table( - [ - { heading: 'Severity', alignment: 'center' }, - { heading: 'Message', alignment: 'left' }, - { heading: 'Source file', alignment: 'left' }, - { heading: 'Line(s)', alignment: 'center' }, - ], - issues.map(({ severity: level, message, source }: Issue) => { - const severity = md`${severityMarker(level)} ${md.italic(level)}`; + return new MarkdownDocument() + .heading(AUDIT_DETAILS_HEADING_LEVEL, 'Issues') + .table( + [ + { heading: 'Severity', alignment: 'center' }, + { heading: 'Message', alignment: 'left' }, + { heading: 'Source file', alignment: 'left' }, + { heading: 'Line(s)', alignment: 'center' }, + ], + issues.map(({ severity: level, message, source }: Issue) => { + const severity = md`${severityMarker(level)} ${md.italic(level)}`; - if (!source) { - return [severity, message]; - } - const file = linkToLocalSourceForIde(source, options); - if (!source.position) { - return [severity, message, file]; - } - const line = formatSourceLine(source.position); - return [severity, message, file, line]; - }), - ); + if (!source) { + return [severity, message]; + } + const file = linkToLocalSourceForIde(source, options); + if (!source.position) { + return [severity, message, file]; + } + const line = formatSourceLine(source.position); + return [severity, message, file, line]; + }), + ); } export function auditDetails( audit: AuditReport, options?: MdReportOptions, ): MarkdownDocument { - const { table, issues = [] } = audit.details ?? {}; + const { table, issues = [], trees = [] } = audit.details ?? {}; const detailsValue = auditDetailsAuditValue(audit); // undefined details OR empty details (undefined issues OR empty issues AND empty table) - if (issues.length === 0 && !table?.rows.length) { + if (issues.length === 0 && !table?.rows.length && trees.length === 0) { return new MarkdownDocument().paragraph(detailsValue); } const tableSectionContent = table && tableSection(table); const issuesSectionContent = issues.length > 0 && auditDetailsIssues(issues, options); + const treesSectionContent = + trees.length > 0 && + new MarkdownDocument().$concat(...trees.map(tree => treeSection(tree))); return new MarkdownDocument().details( detailsValue, - new MarkdownDocument().$concat(tableSectionContent, issuesSectionContent), + new MarkdownDocument().$concat( + tableSectionContent, + treesSectionContent, + issuesSectionContent, + ), ); } diff --git a/packages/utils/src/lib/reports/generate-md-report.unit.test.ts b/packages/utils/src/lib/reports/generate-md-report.unit.test.ts index 671f6b4b8..daff690c7 100644 --- a/packages/utils/src/lib/reports/generate-md-report.unit.test.ts +++ b/packages/utils/src/lib/reports/generate-md-report.unit.test.ts @@ -339,6 +339,56 @@ describe('auditDetails', () => { expect(md).not.toMatch('#### Issues'); }); + it('should display tree section if trees are present', () => { + const md = auditDetails({ + slug: 'line-coverage', + title: 'Line coverage', + score: 0.7, + value: 70, + displayValue: '70 %', + details: { + trees: [ + { + type: 'coverage', + title: 'Line coverage', + root: { + name: '.', + values: { coverage: 0.7 }, + children: [ + { + name: 'src', + values: { coverage: 0.7 }, + children: [ + { + name: 'App.tsx', + values: { + coverage: 0.8, + missing: [{ startLine: 42, endLine: 50 }], + }, + }, + { + name: 'index.ts', + values: { + coverage: 0, + missing: [{ startLine: 1, endLine: 10 }], + }, + }, + ], + }, + ], + }, + }, + ], + }, + } as AuditReport).toString(); + expect(md).toMatch('<details>'); + expect(md).toMatch('#### Line coverage'); + expect(md).toContain('```'); + expect(md).toContain('└── src'); + expect(md).toContain('├── App.tsx'); + expect(md).not.toMatch('#### Issues'); + }); + it('should render complete details section', () => { expect( auditDetails({ diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.txt new file mode 100644 index 000000000..ab06b1990 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.txt @@ -0,0 +1,3 @@ +https://example.com +├── https://example.com/styles/base.css +└── https://example.com/styles/theme.css diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-nesting.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-nesting.txt new file mode 100644 index 000000000..b306ab491 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-nesting.txt @@ -0,0 +1,15 @@ +. +└── src + ├── app + │ ├── components + │ │ ├── login + │ │ │ └── login.component.ts + │ │ └── platform + │ │ └── platform.component.ts + │ ├── services + │ │ ├── api-client.service.ts + │ │ └── auth.service.ts + │ ├── app.component.ts + │ ├── app.config.ts + │ └── app.routes.ts + └── main.ts diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-values.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-values.txt new file mode 100644 index 000000000..78f4d1c12 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/basic-tree.with-values.txt @@ -0,0 +1,3 @@ +https://example.com +├── https://example.com/styles/base.css 2 kB 20 +└── https://example.com/styles/theme.css 10 kB 100 diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.txt new file mode 100644 index 000000000..1e20c853d --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.txt @@ -0,0 +1,15 @@ +. 72.00 % +└── src 72.00 % + ├── app 88.00 % + │ ├── components 68.00 % + │ │ ├── login 48.00 % + │ │ │ └── login.component.ts 48.00 % + │ │ └── platform 74.00 % + │ │ └── platform.component.ts 74.00 % + │ ├── services 97.00 % + │ │ ├── api-client.service.ts 99.00 % + │ │ └── auth.service.ts 94.00 % + │ ├── app.component.ts 92.00 % + │ ├── app.config.ts 100.00 % + │ └── app.routes.ts 100.00 % + └── main.ts 0.00 % diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-lines.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-lines.txt new file mode 100644 index 000000000..18b4a9044 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-lines.txt @@ -0,0 +1,8 @@ +. 70.00 % +└── src 45.39 % + ├── components 97.36 % + │ ├── CreateTodo.jsx 100.00 % + │ ├── TodoFilter.jsx 90.90 % 18 + │ └── TodoList.jsx 100.00 % + └── hooks 0.00 % + └── useTodos.js 0.00 % 1-73 diff --git a/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-named.txt b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-named.txt new file mode 100644 index 000000000..7a8e20d66 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/__snapshots__/coverage-tree.with-missing-named.txt @@ -0,0 +1,7 @@ +. 70.00 % +└── src 70.00 % + ├── components 80.00 % + │ ├── App.tsx 75.00 % login (42-50), logout (52-55) + │ └── Layout.tsx 100.00 % + ├── index.ts 100.00 % + └── utils.ts 0.00 % ErrorBoundary (1-10) diff --git a/packages/utils/src/lib/text-formats/ascii/tree.ts b/packages/utils/src/lib/text-formats/ascii/tree.ts new file mode 100644 index 000000000..0ab2fefa8 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/tree.ts @@ -0,0 +1,118 @@ +import type { + BasicTreeNode, + CoverageTreeNode, + Tree, +} from '@code-pushup/models'; + +type TreeNode = BasicTreeNode | CoverageTreeNode; + +const INDENT_CHARS = 4; +const COL_GAP = 2; + +export function formatAsciiTree(tree: Tree): string { + const nodes = flatten(tree.root); + const maxWidth = Math.max( + ...nodes.map(({ node, level }) => level * INDENT_CHARS + node.name.length), + ); + const keysMaxWidths = + tree.type === 'coverage' + ? {} + : nodes.reduce<Record<string, number>>( + (acc, { node }) => ({ + ...acc, + ...Object.fromEntries( + Object.entries(node.values ?? {}).map(([key, value]) => [ + key, + Math.max(acc[key] ?? 0, `${value}`.length), + ]), + ), + }), + {}, + ); + + return formatTreeNode(tree.root, '', maxWidth, keysMaxWidths) + .map(line => `${line}\n`) + .join(''); +} + +function formatTreeNode( + node: TreeNode, + prefix: string, + maxWidth: number, + keysMaxWidths: Record<string, number>, +): string[] { + const childPrefix = prefix.replace(/[└─]/g, ' ').replace(/├/g, '│'); + + const prefixedName = `${prefix}${node.name}`; + const padding = ' '.repeat(maxWidth + COL_GAP * 2 - prefixedName.length); + const values = formatNodeValues(node.values, keysMaxWidths); + const offsetValues = values ? `${padding}${values}` : ''; + const formattedNode = `${prefixedName}${offsetValues}`; + + return [ + formattedNode, + ...(node.children?.flatMap((child, i, arr) => + formatTreeNode( + child, + i === arr.length - 1 ? `${childPrefix}└── ` : `${childPrefix}├── `, + maxWidth, + keysMaxWidths, + ), + ) ?? []), + ]; +} + +function formatNodeValues( + values: TreeNode['values'], + keysMaxWidths: Record<string, number>, +): string { + if (!values) { + return ''; + } + + if ('coverage' in values && typeof values.coverage === 'number') { + const percentage = coveragePercentage(values.coverage); + const maxLength = coveragePercentage(1).length; + const formattedCoverage = `${percentage.padStart(maxLength, ' ')} %`; + if (!Array.isArray(values.missing) || values.missing.length === 0) { + return formattedCoverage; + } + const formattedMissing = values.missing + .map(({ name, startLine, endLine }): string => { + const range = + startLine === endLine + ? startLine.toString() + : `${startLine}-${endLine}`; + return name ? `${name} (${range})` : range; + }) + .join(', '); + return `${formattedCoverage}${' '.repeat(COL_GAP)}${formattedMissing}`; + } + + const valuesMap = new Map( + Object.entries(values).filter( + (pair): pair is [string, string | number] => + typeof pair[1] === 'string' || typeof pair[1] === 'number', + ), + ); + return Object.entries(keysMaxWidths) + .map(([key, maxWidth]) => { + const value = valuesMap.get(key)?.toString() ?? ''; + return value.padStart(maxWidth, ' '); + }) + .join(' '.repeat(COL_GAP)); +} + +function flatten( + node: TreeNode, + level = 0, +): { node: TreeNode; level: number }[] { + return [ + { node, level }, + ...(node.children?.flatMap(child => flatten(child, level + 1)) ?? []), + ]; +} + +function coveragePercentage(coverage: number): string { + return (coverage * 100).toFixed(2); +} diff --git a/packages/utils/src/lib/text-formats/ascii/tree.unit.test.ts b/packages/utils/src/lib/text-formats/ascii/tree.unit.test.ts new file mode 100644 index 000000000..5d6061ed8 --- /dev/null +++ b/packages/utils/src/lib/text-formats/ascii/tree.unit.test.ts @@ -0,0 +1,289 @@ +import { formatAsciiTree } from './tree.js'; + +describe('formatAsciiTree', () => { + it('should format basic tree', () => { + expect( + formatAsciiTree({ + root: { + name: 'https://example.com', + children: [ + { name: 'https://example.com/styles/base.css' }, + { name: 'https://example.com/styles/theme.css' }, + ], + }, + }), + ).toMatchFileSnapshot('__snapshots__/basic-tree.txt'); + }); + + it('should format basic tree with multi-level nesting', () => { + expect( + formatAsciiTree({ + root: { + name: '.', + children: [ + { + name: 'src', + children: [ + { + name: 'app', + children: [ + { + name: 'components', + children: [ + { + name: 'login', + children: [{ name: 'login.component.ts' }], + }, + { + name: 'platform', + children: [{ name: 'platform.component.ts' }], + }, + ], + }, + { + name: 'services', + children: [ + { name: 'api-client.service.ts' }, + { name: 'auth.service.ts' }, + ], + }, + { name: 'app.component.ts' }, + { name: 'app.config.ts' }, + { name: 'app.routes.ts' }, + ], + }, + { name: 'main.ts' }, + ], + }, + ], + }, + }), + ).toMatchFileSnapshot('__snapshots__/basic-tree.with-nesting.txt'); + }); + + it('should format basic tree with custom node values', () => { + expect( + formatAsciiTree({ + root: { + name: 'https://example.com', + children: [ + { + name: 'https://example.com/styles/base.css', + values: { size: '2 kB', duration: 20 }, + }, + { + name: 'https://example.com/styles/theme.css', + values: { size: '10 kB', duration: 100 }, + }, + ], + }, + }), + ).toMatchFileSnapshot('__snapshots__/basic-tree.with-values.txt'); + }); + + it('should format coverage tree', () => { + expect( + formatAsciiTree({ + type: 'coverage', + root: { + name: '.', + values: { coverage: 0.72 }, + children: [ + { + name: 'src', + values: { coverage: 0.72 }, + children: [ + { + name: 'app', + values: { coverage: 0.88 }, + children: [ + { + name: 'components', + values: { coverage: 0.68 }, + children: [ + { + name: 'login', + values: { coverage: 0.48 }, + children: [ + { + name: 'login.component.ts', + values: { coverage: 0.48 }, + }, + ], + }, + { + name: 'platform', + values: { coverage: 0.74 }, + children: [ + { + name: 'platform.component.ts', + values: { coverage: 0.74 }, + }, + ], + }, + ], + }, + { + name: 'services', + values: { coverage: 0.97 }, + children: [ + { + name: 'api-client.service.ts', + values: { coverage: 0.99 }, + }, + { + name: 'auth.service.ts', + values: { coverage: 0.94 }, + }, + ], + }, + { + name: 'app.component.ts', + values: { coverage: 0.92 }, + }, + { + name: 'app.config.ts', + values: { coverage: 1 }, + }, + { + name: 'app.routes.ts', + values: { coverage: 1 }, + }, + ], + }, + { + name: 'main.ts', + values: { coverage: 0 }, + }, + ], + }, + ], + }, + }), + ).toMatchFileSnapshot('__snapshots__/coverage-tree.txt'); + }); + + it('should format coverage tree with missing lines', () => { + expect( + formatAsciiTree({ + type: 'coverage', + title: 'Line coverage', + root: { + name: '.', + values: { coverage: 0.7 }, + children: [ + { + name: 'src', + values: { coverage: 0.4539 }, + children: [ + { + name: 'components', + values: { coverage: 0.9736 }, + children: [ + { + name: 'CreateTodo.jsx', + values: { coverage: 1 }, + }, + { + name: 'TodoFilter.jsx', + values: { + coverage: 0.909, + missing: [{ startLine: 18, endLine: 18 }], + }, + }, + { + name: 'TodoList.jsx', + values: { coverage: 1 }, + }, + ], + }, + { + name: 'hooks', + values: { coverage: 0 }, + children: [ + { + name: 'useTodos.js', + values: { + coverage: 0, + missing: [{ startLine: 1, endLine: 73 }], + }, + }, + ], + }, + ], + }, + ], + }, + }), + ).toMatchFileSnapshot('__snapshots__/coverage-tree.with-missing-lines.txt'); + }); + + it('should format coverage tree with missing lines referencing entities in code', () => { + expect( + formatAsciiTree({ + type: 'coverage', + title: 'Docs coverage', + root: { + name: '.', + values: { coverage: 0.7 }, + children: [ + { + name: 'src', + values: { coverage: 0.7 }, + children: [ + { + name: 'components', + values: { coverage: 0.8 }, + children: [ + { + name: 'App.tsx', + values: { + coverage: 0.75, + missing: [ + { + startLine: 42, + endLine: 50, + name: 'login', + kind: 'function', + }, + { + startLine: 52, + endLine: 55, + name: 'logout', + kind: 'function', + }, + ], + }, + }, + { + name: 'Layout.tsx', + values: { coverage: 1 }, + }, + ], + }, + { + name: 'index.ts', + values: { coverage: 1 }, + }, + { + name: 'utils.ts', + values: { + coverage: 0, + missing: [ + { + startLine: 1, + endLine: 10, + name: 'ErrorBoundary', + kind: 'class', + }, + ], + }, + }, + ], + }, + ], + }, + }), + ).toMatchFileSnapshot('__snapshots__/coverage-tree.with-missing-named.txt'); + }); +});