diff --git a/e2e/cli-e2e/tests/__snapshots__/collect.e2e.test.ts.snap b/e2e/cli-e2e/tests/__snapshots__/collect.e2e.test.ts.snap index 0755bd2a6..c2dfcd5a0 100644 --- a/e2e/cli-e2e/tests/__snapshots__/collect.e2e.test.ts.snap +++ b/e2e/cli-e2e/tests/__snapshots__/collect.e2e.test.ts.snap @@ -1798,6 +1798,7 @@ exports[`CLI collect > should run Lighthouse plugin that runs lighthouse CLI and }, ], "icon": "lighthouse", + "packageName": "@code-pushup/lighthouse-plugin", "slug": "lighthouse", "title": "Lighthouse", }, diff --git a/packages/core/src/lib/implementation/report-to-gql.ts b/packages/core/src/lib/implementation/report-to-gql.ts index 083704cbd..1a83fd6e3 100644 --- a/packages/core/src/lib/implementation/report-to-gql.ts +++ b/packages/core/src/lib/implementation/report-to-gql.ts @@ -125,10 +125,10 @@ export function tableToGQL(table: Table): PortalTable { }), rows: table.rows.map((row): PortalTableCell[] => Array.isArray(row) - ? row.map(content => ({ content: content.toString() })) + ? row.map(content => ({ content: content?.toString() ?? '' })) : Object.entries(row).map(([key, content]) => ({ key, - content: content.toString(), + content: content?.toString() ?? '', })), ), }; diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 6b5ed216e..f6775425a 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -1,6 +1,6 @@ export { - PrimitiveValue, - primitiveValueSchema, + TableCellValue, + tableCellValueSchema, } from './lib/implementation/schemas'; export { Audit, auditSchema } from './lib/audit'; diff --git a/packages/models/src/lib/implementation/schemas.ts b/packages/models/src/lib/implementation/schemas.ts index fb8a81a80..30e09375e 100644 --- a/packages/models/src/lib/implementation/schemas.ts +++ b/packages/models/src/lib/implementation/schemas.ts @@ -7,8 +7,10 @@ import { } from './limits'; import { filenameRegex, slugRegex } from './utils'; -export const primitiveValueSchema = z.union([z.string(), z.number()]); -export type PrimitiveValue = z.infer; +export const tableCellValueSchema = z + .union([z.string(), z.number(), z.boolean(), z.null()]) + .default(null); +export type TableCellValue = z.infer; /** * Schema for execution meta date diff --git a/packages/models/src/lib/implementation/schemas.unit.test.ts b/packages/models/src/lib/implementation/schemas.unit.test.ts index c30f1d251..3e74b89b5 100644 --- a/packages/models/src/lib/implementation/schemas.unit.test.ts +++ b/packages/models/src/lib/implementation/schemas.unit.test.ts @@ -1,15 +1,15 @@ import { describe, expect, it } from 'vitest'; -import { PrimitiveValue, primitiveValueSchema, weightSchema } from './schemas'; +import { TableCellValue, tableCellValueSchema, weightSchema } from './schemas'; describe('primitiveValueSchema', () => { it('should accept a valid union', () => { - const value: PrimitiveValue = 'test'; - expect(() => primitiveValueSchema.parse(value)).not.toThrow(); + const value: TableCellValue = 'test'; + expect(() => tableCellValueSchema.parse(value)).not.toThrow(); }); it('should throw for a invalid union', () => { const value = new Date(); - expect(() => primitiveValueSchema.parse(value)).toThrow('invalid_union'); + expect(() => tableCellValueSchema.parse(value)).toThrow('invalid_union'); }); }); diff --git a/packages/models/src/lib/table.ts b/packages/models/src/lib/table.ts index 447d19b00..ff166e2c0 100644 --- a/packages/models/src/lib/table.ts +++ b/packages/models/src/lib/table.ts @@ -1,5 +1,5 @@ import { z } from 'zod'; -import { primitiveValueSchema } from './implementation/schemas'; +import { tableCellValueSchema } from './implementation/schemas'; export const tableAlignmentSchema = z.enum(['left', 'center', 'right'], { description: 'Cell alignment', @@ -16,12 +16,12 @@ export const tableColumnObjectSchema = z.object({ }); export type TableColumnObject = z.infer; -export const tableRowObjectSchema = z.record(primitiveValueSchema, { +export const tableRowObjectSchema = z.record(tableCellValueSchema, { description: 'Object row', }); export type TableRowObject = z.infer; -export const tableRowPrimitiveSchema = z.array(primitiveValueSchema, { +export const tableRowPrimitiveSchema = z.array(tableCellValueSchema, { description: 'Primitive row', }); export type TableRowPrimitive = z.infer; diff --git a/packages/models/src/lib/table.unit.test.ts b/packages/models/src/lib/table.unit.test.ts index b6000949c..1dc803ab3 100644 --- a/packages/models/src/lib/table.unit.test.ts +++ b/packages/models/src/lib/table.unit.test.ts @@ -74,8 +74,15 @@ describe('tableRowObjectSchema', () => { expect(() => tableRowObjectSchema.parse(row)).not.toThrow(); }); - it('should throw for a invalid object', () => { + it('should default undefined object values to null', () => { const row = { prop: undefined }; + expect(tableRowObjectSchema.parse(row)).toStrictEqual({ + prop: null, + }); + }); + + it('should throw for a invalid object', () => { + const row = { prop: [] }; expect(() => tableRowObjectSchema.parse(row)).toThrow('invalid_union'); }); }); diff --git a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts index f33cd20eb..e71f68942 100644 --- a/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts +++ b/packages/plugin-lighthouse/src/lib/lighthouse-plugin.ts @@ -1,4 +1,5 @@ import type { PluginConfig } from '@code-pushup/models'; +import { name, version } from '../../package.json'; import { LIGHTHOUSE_PLUGIN_SLUG } from './constants'; import { normalizeFlags } from './normalize-flags'; import { @@ -28,6 +29,8 @@ export function lighthousePlugin( return { slug: LIGHTHOUSE_PLUGIN_SLUG, + packageName: name, + version, title: 'Lighthouse', icon: 'lighthouse', audits, diff --git a/packages/plugin-lighthouse/src/lib/runner/details/details.ts b/packages/plugin-lighthouse/src/lib/runner/details/details.ts new file mode 100644 index 000000000..eabc3001b --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/runner/details/details.ts @@ -0,0 +1,81 @@ +import chalk from 'chalk'; +import type { FormattedIcu } from 'lighthouse'; +import type Details from 'lighthouse/types/lhr/audit-details'; +import { Result } from 'lighthouse/types/lhr/audit-result'; +import { AuditDetails, Table, tableSchema } from '@code-pushup/models'; +import { ui } from '@code-pushup/utils'; +import { PLUGIN_SLUG } from '../constants'; +import { parseTableToAuditDetailsTable } from './table.type'; + +export function toAuditDetails>( + details: T | undefined, +): AuditDetails | undefined { + if (details == null) { + return undefined; + } + + const { type } = details; + + if (type !== 'table') { + return undefined; + } + + const rawTable: Table | undefined = parseTableToAuditDetailsTable(details); + + if (rawTable != null) { + const result = tableSchema().safeParse(rawTable); + if (result.success) { + return { + table: result.data, + }; + } + + throw new Error( + `Parsing details ${chalk.bold( + type, + )} failed: \nRaw data:\n ${JSON.stringify( + rawTable, + null, + 2, + )}\n${result.error.toString()}`, + ); + } + + return undefined; +} + +// @TODO implement all details +export const unsupportedDetailTypes = new Set([ + 'opportunity', + 'debugdata', + 'treemap-data', + 'screenshot', + 'filmstrip', + 'criticalrequestchain', +]); + +export function logUnsupportedDetails( + lhrAudits: Result[], + { displayCount = 3 }: { displayCount?: number } = {}, +) { + const slugsWithDetailParsingErrors = [ + ...new Set( + lhrAudits + .filter(({ details }) => + unsupportedDetailTypes.has(details?.type as string), + ) + .map(({ details }) => details?.type), + ), + ]; + if (slugsWithDetailParsingErrors.length > 0) { + const postFix = (count: number) => + count > displayCount ? ` and ${count - displayCount} more.` : ''; + ui().logger.debug( + `${chalk.yellow('⚠')} Plugin ${chalk.bold( + PLUGIN_SLUG, + )} skipped parsing of unsupported audit details: ${chalk.bold( + slugsWithDetailParsingErrors.slice(0, displayCount).join(', '), + )}${postFix(slugsWithDetailParsingErrors.length)}`, + ); + } +} diff --git a/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts new file mode 100644 index 000000000..c4e00bc80 --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts @@ -0,0 +1,194 @@ +import chalk from 'chalk'; +import type { FormattedIcu } from 'lighthouse'; +import type Details from 'lighthouse/types/lhr/audit-details'; +import type { Result } from 'lighthouse/types/lhr/audit-result'; +import { describe, expect, it } from 'vitest'; +import { getLogMessages } from '@code-pushup/test-utils'; +import { ui } from '@code-pushup/utils'; +import { logUnsupportedDetails, toAuditDetails } from './details'; + +describe('logUnsupportedDetails', () => { + it('should log unsupported entries', () => { + logUnsupportedDetails([ + { details: { type: 'screenshot' } }, + ] as unknown as Result[]); + expect(getLogMessages(ui().logger)).toHaveLength(1); + expect(getLogMessages(ui().logger).at(0)).toBe( + `[ cyan(debug) ] ${chalk.yellow('⚠')} Plugin ${chalk.bold( + 'lighthouse', + )} skipped parsing of unsupported audit details: ${chalk.bold( + 'screenshot', + )}`, + ); + }); + + it('should log only 3 details of unsupported entries', () => { + logUnsupportedDetails([ + { details: { type: 'table' } }, + { details: { type: 'filmstrip' } }, + { details: { type: 'screenshot' } }, + { details: { type: 'opportunity' } }, + { details: { type: 'debugdata' } }, + { details: { type: 'treemap-data' } }, + { details: { type: 'criticalrequestchain' } }, + ] as unknown as Result[]); + expect(getLogMessages(ui().logger)).toHaveLength(1); + expect(getLogMessages(ui().logger).at(0)).toBe( + `[ cyan(debug) ] ${chalk.yellow('⚠')} Plugin ${chalk.bold( + 'lighthouse', + )} skipped parsing of unsupported audit details: ${chalk.bold( + 'filmstrip, screenshot, opportunity', + )} and 3 more.`, + ); + }); +}); + +describe('toAuditDetails', () => { + it('should return undefined for missing details', () => { + const outputs = toAuditDetails(undefined); + expect(outputs).toBeUndefined(); + }); + + it('should return undefined for unsupported type', () => { + const outputs = toAuditDetails({ type: 'debugdata' }); + expect(outputs).toBeUndefined(); + }); + + it('should return undefined for supported type with empty table', () => { + const outputs = toAuditDetails({ + type: 'table', + items: [], + } as unknown as FormattedIcu
); + expect(outputs).toBeUndefined(); + }); + + it('should render audit details of type table', () => { + const outputs = toAuditDetails({ + type: 'table', + headings: [ + { + key: 'name', + valueType: 'text', + label: 'Name', + }, + { + key: 'duration', + valueType: 'ms', + label: 'Duration', + }, + ], + items: [ + { + name: 'Zone', + duration: 0.634, + }, + { + name: 'Zone:ZoneAwarePromise', + duration: 0.783, + }, + ], + }); + + expect(outputs).toStrictEqual({ + table: { + columns: [ + { + key: 'name', + label: 'Name', + align: 'left', + }, + { + key: 'duration', + label: 'Duration', + align: 'left', + }, + ], + rows: [ + { + name: 'Zone', + duration: '0.634 ms', + }, + { + name: 'Zone:ZoneAwarePromise', + duration: '0.783 ms', + }, + ], + }, + }); + }); + + it('should inform that debugdata detail type is not supported yet', () => { + const outputs = toAuditDetails({ + type: 'debugdata', + items: [ + { + cumulativeLayoutShiftMainFrame: 0.000_350_978_852_728_593_95, + }, + ], + }); + + // @TODO add check that cliui.logger is called. Resolve TODO after PR #487 is merged. + + expect(outputs).toBeUndefined(); + }); + + it('should inform that filmstrip detail type is not supported yet', () => { + const outputs = toAuditDetails({ + type: 'filmstrip', + scale: 3000, + items: [ + { + timing: 375, + timestamp: 106_245_424_545, + data: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAYEBQYFBAYGBQYHBwY...', + }, + ], + }); + + expect(outputs).toBeUndefined(); + }); + + it('should inform that screenshot detail type is not supported yet', () => { + const outputs = toAuditDetails({ + type: 'screenshot', + timing: 541, + timestamp: 106_245_590_644, + data: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD//2Q==', + }); + + expect(outputs).toBeUndefined(); + }); + + it('should inform that treemap-data detail type is not supported yet', () => { + const outputs = toAuditDetails({ + type: 'treemap-data', + nodes: [], + }); + + expect(outputs).toBeUndefined(); + }); + + it('should inform that criticalrequestchain detail type is not supported yet', () => { + const outputs = toAuditDetails({ + type: 'criticalrequestchain', + chains: { + EED301D300C9A7B634A444E0C6019FC1: { + request: { + url: 'https://example.com/', + startTime: 106_245.050_727, + endTime: 106_245.559_225, + responseReceivedTime: 106_245.559_001, + transferSize: 849, + }, + }, + }, + longestChain: { + duration: 508.498_000_010_848_05, + length: 1, + transferSize: 849, + }, + }); + + expect(outputs).toBeUndefined(); + }); +}); diff --git a/packages/plugin-lighthouse/src/lib/runner/details/item-value.ts b/packages/plugin-lighthouse/src/lib/runner/details/item-value.ts new file mode 100644 index 000000000..40d26f024 --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/runner/details/item-value.ts @@ -0,0 +1,168 @@ +import chalk from 'chalk'; +import type { IcuMessage } from 'lighthouse'; +import type Details from 'lighthouse/types/lhr/audit-details'; +import { + formatBytes, + formatDuration, + html, + truncateText, + ui, +} from '@code-pushup/utils'; + +export type PrimitiveItemValue = string | number | boolean; +export type ObjectItemValue = Exclude< + Details.ItemValue, + PrimitiveItemValue | IcuMessage +>; +export type SimpleItemValue = + | Extract< + ObjectItemValue, + Details.NumericValue | Details.CodeValue | Details.UrlValue + > + | PrimitiveItemValue; + +export function trimSlice(item?: PrimitiveItemValue, maxLength = 0) { + const str = String(item).trim(); + return maxLength > 0 ? str.slice(0, maxLength) : str; +} + +export function parseNodeValue(node?: Details.NodeValue): string { + const { selector = '' } = node ?? {}; + return selector; +} + +// eslint-disable-next-line max-lines-per-function +export function formatTableItemPropertyValue( + itemValue?: Details.ItemValue, + itemValueFormat?: Details.ItemValueType, +) { + // null + if (itemValue == null) { + return ''; + } + + // Primitive Values + if (itemValueFormat == null) { + if (typeof itemValue === 'string') { + return trimSlice(itemValue); + } + + if (typeof itemValue === 'number') { + return Number(itemValue); + } + + if (typeof itemValue === 'boolean') { + return itemValue; + } + } + + const parsedItemValue = parseTableItemPropertyValue(itemValue); + + /* eslint-disable no-magic-numbers */ + switch (itemValueFormat) { + case 'bytes': + return formatBytes(Number(parsedItemValue)); + case 'code': + return html.code(trimSlice(parsedItemValue as string)); + case 'link': + const link = parsedItemValue as Details.LinkValue; + return html.link(link.url, link.text); + case 'url': + const url = parsedItemValue as string; + return html.link(url); + case 'timespanMs': + case 'ms': + return formatDuration(Number(parsedItemValue)); + case 'node': + return parseNodeValue(itemValue as Details.NodeValue); + case 'source-location': + return truncateText(String(parsedItemValue), 200); + case 'numeric': + const num = Number(parsedItemValue); + if (num.toFixed(3).toString().endsWith('.000')) { + return String(num); + } + return String(num.toFixed(3)); + case 'text': + return truncateText(String(parsedItemValue), 500); + case 'multi': // @TODO + // @TODO log verbose first, then implement data type + ui().logger.info(`Format type ${chalk.bold('multi')} is not implemented`); + return ''; + case 'thumbnail': // @TODO + // @TODO log verbose first, then implement data type + ui().logger.info( + `Format type ${chalk.bold('thumbnail')} is not implemented`, + ); + return ''; + case null: + return ''; + default: + return itemValue; + } + /* eslint-enable no-magic-numbers */ +} + +export function parseSimpleItemValue( + item: SimpleItemValue, +): PrimitiveItemValue { + if (typeof item === 'object') { + const value = item.value; + if (typeof value === 'object') { + return value.formattedDefault; + } + return value; + } + return item; +} + +// @TODO extract Link type from logic +export function parseTableItemPropertyValue( + itemValue?: Details.ItemValue, +): PrimitiveItemValue | Details.LinkValue { + if (itemValue == null) { + return ''; + } + + // Primitive Values + if ( + typeof itemValue === 'string' || + typeof itemValue === 'number' || + typeof itemValue === 'boolean' + ) { + return parseSimpleItemValue(itemValue); + } + + // Object Values + const objectValue = itemValue as ObjectItemValue; + const { type } = objectValue; + switch (type) { + case 'code': + case 'url': + return String(parseSimpleItemValue(objectValue)); + case 'node': + return parseNodeValue(objectValue); + case 'link': + return objectValue; + case 'numeric': + return Number(parseSimpleItemValue(objectValue)); + case 'source-location': + const { url } = objectValue; + return String(url); + case 'subitems': + // @TODO log verbose first, then implement data type + ui().logger.info( + `Value type ${chalk.bold('subitems')} is not implemented`, + ); + return ''; + case 'debugdata': + // @TODO log verbose first, then implement data type + ui().logger.info( + `Value type ${chalk.bold('debugdata')} is not implemented`, + { silent: true }, + ); + return ''; + } + // IcuMessage + return parseSimpleItemValue(objectValue as SimpleItemValue); +} 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 new file mode 100644 index 000000000..8bdf16b2a --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/runner/details/item-value.unit.test.ts @@ -0,0 +1,383 @@ +import chalk from 'chalk'; +import Details from 'lighthouse/types/lhr/audit-details'; +import { beforeAll, describe, expect, it } from 'vitest'; +import { getLogMessages } from '@code-pushup/test-utils'; +import { ui } from '@code-pushup/utils'; +import { + SimpleItemValue, + formatTableItemPropertyValue, + parseNodeValue, + parseSimpleItemValue, + parseTableItemPropertyValue, +} from './item-value'; + +describe('parseNodeValue', () => { + it('should parse selector of node', () => { + expect(parseNodeValue({ type: 'node', selector: 'h1 > span.icon' })).toBe( + 'h1 > span.icon', + ); + }); + + it('should parse empty node', () => { + expect(parseNodeValue(undefined)).toBe(''); + }); +}); + +describe('parseSimpleItemValue', () => { + it('should parse primitive ItemValue type string', () => { + expect(parseSimpleItemValue('42')).toBe('42'); + }); + + it('should parse primitive ItemValue type number', () => { + expect(parseSimpleItemValue(42)).toBe(42); + }); + + it('should parse primitive ItemValue type boolean', () => { + expect(parseSimpleItemValue(false)).toBe(false); + }); + + it('should parse ObjectItemValue ItemValue type boolean', () => { + expect(parseSimpleItemValue({ type: 'numeric', value: 42 })).toBe(42); + }); + + it('should parse IcuMessage', () => { + expect( + parseSimpleItemValue({ + value: { i18nId: 'i18nId-42', formattedDefault: '42' }, + } as any), + ).toBe('42'); + }); +}); + +describe('parseTableItemPropertyValue', () => { + beforeAll(() => { + ui().switchMode('raw'); + }); + + it('should parse undefined', () => { + expect(parseTableItemPropertyValue(undefined)).toBe(''); + }); + + it('should parse primitive string value', () => { + expect(parseTableItemPropertyValue('42')).toBe('42'); + }); + + it('should parse primitive number float value', () => { + expect(parseTableItemPropertyValue(42.21)).toBe(42.21); + }); + + it('should parse primitive number int value', () => { + expect(parseTableItemPropertyValue(42)).toBe(42); + }); + + it('should parse primitive boolean value', () => { + expect(parseTableItemPropertyValue(false)).toBe(false); + }); + + it('should parse value item code', () => { + expect( + parseTableItemPropertyValue({ type: 'code', value: 'const num = 42;' }), + ).toBe('const num = 42;'); + }); + + it('should parse value item url', () => { + expect( + parseTableItemPropertyValue({ + type: 'url', + value: 'https://www.code-pushup.dev', + }), + ).toBe('https://www.code-pushup.dev'); + }); + + it('should parse value item link', () => { + expect( + parseTableItemPropertyValue({ + type: 'link', + url: 'https://www.code-pushup.dev', + text: 'Code Pushup', + }), + ).toEqual({ + type: 'link', + url: 'https://www.code-pushup.dev', + text: 'Code Pushup', + }); + }); + + it('should parse value item node', () => { + expect( + parseTableItemPropertyValue({ type: 'node', selector: 'h1 > span.icon' }), + ).toBe('h1 > span.icon'); + }); + + it('should parse value item numeric', () => { + expect(parseTableItemPropertyValue({ type: 'numeric', value: 42 })).toBe( + 42, + ); + }); + + it('should parse value item source-location', () => { + expect( + parseTableItemPropertyValue({ + type: 'code', + value: 'Legible text', + }), + ).toBe('Legible text'); + }); + + it('should parse value item subitems to empty string and log implemented', () => { + expect( + parseTableItemPropertyValue({ + type: 'subitems', + items: [ + { + url: 'https://fonts.gstatic.com/s/spacegrotesk/v16/V8mDoQDjQSkFtoMM3T6r8E7mPbF4Cw.woff2', + mainThreadTime: 0, + blockingTime: 0, + transferSize: 22_826, + tbtImpact: 0, + }, + { + url: 'https://fonts.gstatic.com/s/spacegrotesk/v16/V8mDoQDjQSkFtoMM3T6r8E7mPb94C-s0.woff2', + mainThreadTime: 0, + blockingTime: 0, + transferSize: 18_380, + tbtImpact: 0, + }, + ], + }), + ).toBe(''); + + expect(getLogMessages(ui().logger).at(0)).toBe( + `[ blue(info) ] Value type ${chalk.bold('subitems')} is not implemented`, + ); + }); + + it('should parse value item debugdata to empty string and log implemented', () => { + expect(parseTableItemPropertyValue({ type: 'debugdata' })).toBe(''); + expect(getLogMessages(ui().logger).at(0)).toBe( + `[ blue(info) ] Value type ${chalk.bold('debugdata')} is not implemented`, + ); + }); + + it('should parse value item IcuMessage', () => { + expect( + parseTableItemPropertyValue({ + value: { i18nId: 'i18nId-42', formattedDefault: 'IcuMessageValue' }, + } as SimpleItemValue), + ).toBe('IcuMessageValue'); + }); +}); + +describe('formatTableItemPropertyValue', () => { + const alphabet = 'abcdefghijklmnopqrstuvwxyz'; + const stringOfLength = (length: number, postfix = ''): string => { + const maxLength = length - postfix.length; + let result = ''; + // eslint-disable-next-line functional/no-loop-statements + for (let i = 0; i < maxLength; i++) { + result += alphabet.at((alphabet.length - 1) % i); + } + + if (postfix) { + result += postfix; + } + + return result; + }; + + beforeAll(() => { + ui().switchMode('raw'); + }); + + it('should format undefined to empty string', () => { + expect(formatTableItemPropertyValue(undefined)).toBe(''); + }); + + it('should format primitive string value without extra type format', () => { + expect(formatTableItemPropertyValue('42 ')).toBe('42'); + }); + + it('should format primitive number', () => { + expect(formatTableItemPropertyValue(42.213_123_123)).toBe(42.213_123_123); + }); + + it('should format primitive number value have no floating numbers if all are zeros', () => { + expect(formatTableItemPropertyValue(42)).toBe(42); + }); + + it('should format primitive boolean value', () => { + expect(formatTableItemPropertyValue(false)).toBe(false); + }); + + it('should forward non primitive value directly if no format is provided', () => { + expect( + formatTableItemPropertyValue( + { type: 'debugdata', value: '42' }, + undefined, + ), + ).toStrictEqual({ type: 'debugdata', value: '42' }); + }); + + it('should format value based on itemValueFormat "bytes"', () => { + expect(formatTableItemPropertyValue('100000', 'numeric')).toBe('100000'); + }); + + it('should format value based on itemValueFormat "code"', () => { + expect( + formatTableItemPropertyValue( + { type: 'code', value: '

Code Pushup

' }, + 'code', + ), + ).toBe('

Code Pushup

'); + }); + + it('should format value based on itemValueFormat "link"', () => { + expect( + formatTableItemPropertyValue( + { + type: 'link', + url: 'https://code-pushup.dev', + text: 'code-pushup.dev', + }, + 'link', + ), + ).toBe('code-pushup.dev'); + }); + + it('should format value based on itemValueFormat "url"', () => { + expect( + formatTableItemPropertyValue( + { type: 'url', value: 'https://code-pushup.dev' }, + 'url', + ), + ).toBe('https://code-pushup.dev'); + }); + + it('should format value based on itemValueFormat "timespanMs"', () => { + expect( + formatTableItemPropertyValue( + { type: 'numeric', value: 2142 }, + 'timespanMs', + ), + ).toBe('2.14 s'); + }); + + it('should format value based on itemValueFormat "ms"', () => { + expect( + formatTableItemPropertyValue({ type: 'numeric', value: 2142 }, 'ms'), + ).toBe('2.14 s'); + }); + + it('should format value based on itemValueFormat "node"', () => { + expect( + formatTableItemPropertyValue( + { type: 'node', selector: 'h1 > span' }, + 'node', + ), + ).toBe('h1 > span'); + }); + + it('should format value based on itemValueFormat "source-location"', () => { + expect( + formatTableItemPropertyValue( + { + type: 'source-location', + url: 'https://code-pushup.dev', + } as Details.ItemValue, + 'source-location', + ), + ).toBe('https://code-pushup.dev'); + }); + + it('should format value based on itemValueFormat "source-location" to a length of 200 and add "..."', () => { + const formattedStr = formatTableItemPropertyValue( + { + type: 'source-location', + url: stringOfLength(210, 'https://code-pushup.dev'), + } as Details.ItemValue, + 'source-location', + ) as string; + + expect(formattedStr.length).toBeLessThanOrEqual(200); + expect(formattedStr.slice(-3)).toBe('...'); + }); + + it('should format value based on itemValueFormat "numeric" as int', () => { + expect( + formatTableItemPropertyValue( + { type: 'numeric', value: 42 } as Details.ItemValue, + 'numeric', + ), + ).toBe('42'); + }); + + it('should format value based on itemValueFormat "numeric" as float', () => { + expect( + formatTableItemPropertyValue( + { type: 'numeric', value: 42.1 } as Details.ItemValue, + 'numeric', + ), + ).toBe('42.100'); + }); + + it('should format value based on itemValueFormat "numeric" as int if float has only 0 post comma', () => { + expect( + formatTableItemPropertyValue( + { type: 'numeric', value: Number('42.0') } as Details.ItemValue, + 'numeric', + ), + ).toBe('42'); + }); + + it('should format value based on itemValueFormat "text"', () => { + expect( + formatTableItemPropertyValue( + { + type: 'url', + value: 'https://github.com/code-pushup/cli/blob/main/README.md', + } as Details.ItemValue, + 'text', + ), + ).toBe('https://github.com/code-pushup/cli/blob/main/README.md'); + }); + + it('should format value based on itemValueFormat "text" to a length of 500 and add "..."', () => { + const formattedStr = formatTableItemPropertyValue( + { + type: 'url', + value: stringOfLength(510, 'https://github.com/'), + } as Details.ItemValue, + 'text', + ) as string; + + expect(formattedStr.length).toBeLessThanOrEqual(500); + expect(formattedStr.slice(-3)).toBe('...'); + }); + + it('should format value based on itemValueFormat "multi"', () => { + expect( + formatTableItemPropertyValue( + { type: 'numeric', value: 42 } as Details.ItemValue, + 'multi', + ), + ).toBe(''); + + expect(getLogMessages(ui().logger).at(0)).toBe( + `[ blue(info) ] Format type ${chalk.bold('multi')} is not implemented`, + ); + }); + + it('should format value based on itemValueFormat "thumbnail"', () => { + expect( + formatTableItemPropertyValue( + { type: 'numeric', value: 42 } as Details.ItemValue, + 'thumbnail', + ), + ).toBe(''); + expect(getLogMessages(ui().logger).at(0)).toBe( + `[ blue(info) ] Format type ${chalk.bold( + 'thumbnail', + )} is not implemented`, + ); + }); +}); diff --git a/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts b/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts new file mode 100644 index 000000000..7937e50bb --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/runner/details/table.type.ts @@ -0,0 +1,58 @@ +import type Details from 'lighthouse/types/lhr/audit-details'; +import { Table, TableColumnObject, TableRowObject } from '@code-pushup/models'; +import { formatTableItemPropertyValue } from './item-value'; + +export function parseTableToAuditDetailsTable( + details: Details.Table, +): Table | undefined { + const { headings: rawHeadings, items } = details; + + if (items.length === 0) { + return undefined; + } + + return { + columns: parseTableColumns(rawHeadings), + rows: items.map(row => parseTableRow(row, rawHeadings)), + }; +} + +export function parseTableColumns( + rawHeadings: Details.TableColumnHeading[], +): TableColumnObject[] { + return rawHeadings.map(({ key, label }) => ({ + key: key ?? '', + label: typeof label === 'string' ? label : undefined, + align: 'left', + })); +} + +export function parseTableRow( + tableItem: Details.TableItem, + headings: Details.TableColumnHeading[], +): TableRowObject { + const keys = new Set(headings.map(({ key }) => key)); + const valueTypesByKey = new Map( + headings.map(({ key, valueType }) => [key, valueType]), + ); + + return Object.fromEntries( + Object.entries(tableItem) + .filter(([key]) => keys.has(key)) + .map(([key, value]) => { + const valueType = valueTypesByKey.get(key); + return parseTableEntry([key, value], valueType); + }), + ) as TableRowObject; +} + +export function parseTableEntry( + [key, value]: [keyof T, T[keyof T]], + valueType?: Details.ItemValueType, +): [keyof T, Details.ItemValue | undefined] { + if (value == null) { + return [key, value]; + } + + return [key, formatTableItemPropertyValue(value, valueType)]; +} diff --git a/packages/plugin-lighthouse/src/lib/runner/details/table.type.unit.test.ts b/packages/plugin-lighthouse/src/lib/runner/details/table.type.unit.test.ts new file mode 100644 index 000000000..6daabe70f --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/runner/details/table.type.unit.test.ts @@ -0,0 +1,378 @@ +import type Details from 'lighthouse/types/lhr/audit-details'; +import { describe, expect, it } from 'vitest'; +import { Table } from '@code-pushup/models'; +import { + parseTableColumns, + parseTableEntry, + parseTableRow, + parseTableToAuditDetailsTable, +} from './table.type'; + +describe('parseTableToAuditDetails', () => { + it('should render complete details of type table', () => { + const outputs = parseTableToAuditDetailsTable({ + type: 'table', + headings: [ + { + key: 'statistic', + valueType: 'text', + label: 'Statistic', + }, + { + key: 'node', + valueType: 'node', + label: 'Element', + }, + { + key: 'value', + valueType: 'numeric', + label: 'Value', + }, + ], + items: [ + { + statistic: 'Total DOM Elements', + value: { + type: 'numeric', + granularity: 1, + value: 138, + }, + }, + { + node: { + type: 'node', + lhId: '1-5-IMG', + path: '1,HTML,1,BODY,1,CP-ROOT,0,HEADER,0,DIV,1,NAV,0,UL,0,LI,0,A,0,IMG', + selector: 'ul.navigation > li > a.nav-item > img.icon', + boundingRect: { + top: 12, + bottom: 44, + left: 293, + right: 325, + width: 32, + height: 32, + }, + snippet: + '', + nodeLabel: 'ul.navigation > li > a.nav-item > img.icon', + }, + statistic: 'Maximum DOM Depth', + value: { + type: 'numeric', + granularity: 1, + value: 9, + }, + }, + { + node: { + type: 'node', + lhId: '1-6-P', + path: '1,HTML,1,BODY,1,CP-ROOT,4,FOOTER,0,DIV,0,P', + selector: 'cp-root > footer.footer > div.footer-content > p.legal', + boundingRect: { + top: 5705, + bottom: 5914, + left: 10, + right: 265, + width: 254, + height: 209, + }, + snippet: '