diff --git a/packages/cli/src/lib/compare/compare-command.unit.test.ts b/packages/cli/src/lib/compare/compare-command.unit.test.ts index d38e69517..571d5b1e2 100644 --- a/packages/cli/src/lib/compare/compare-command.unit.test.ts +++ b/packages/cli/src/lib/compare/compare-command.unit.test.ts @@ -5,7 +5,6 @@ import { DEFAULT_PERSIST_FORMAT, DEFAULT_PERSIST_OUTPUT_DIR, } from '@code-pushup/models'; -import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import { DEFAULT_CLI_CONFIGURATION } from '../../../mocks/constants.js'; import { yargsCli } from '../yargs-cli.js'; @@ -75,7 +74,8 @@ describe('compare-command', () => { { ...DEFAULT_CLI_CONFIGURATION, commands: [yargsCompareCommandObject()] }, ).parseAsync(); - expect(getLogMessages(ui().logger).at(-1)).toContain( + expect(ui()).toHaveLogged( + 'info', `Reports diff written to ${bold( '.code-pushup/report-diff.json', )} and ${bold('.code-pushup/report-diff.md')}`, diff --git a/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts b/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts index ecc5de17f..d3088f52d 100644 --- a/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts +++ b/packages/cli/src/lib/implementation/filter.middleware.unit.test.ts @@ -291,8 +291,6 @@ describe('filterMiddleware', () => { ); it('should trigger verbose logging when skipPlugins or onlyPlugins removes categories', () => { - const loggerSpy = vi.spyOn(ui().logger, 'info'); - filterMiddleware({ onlyPlugins: ['p1'], skipPlugins: ['p2'], @@ -316,8 +314,15 @@ describe('filterMiddleware', () => { verbose: true, }); - expect(loggerSpy).toHaveBeenCalledWith( - expect.stringContaining('removed the following categories'), + expect(ui()).toHaveNthLogged( + 1, + 'info', + 'The --skipPlugins argument removed the following categories: c1, c2.', + ); + expect(ui()).toHaveNthLogged( + 2, + 'info', + 'The --onlyPlugins argument removed the following categories: c1, c2.', ); }); diff --git a/packages/cli/src/lib/implementation/global.utils.unit.test.ts b/packages/cli/src/lib/implementation/global.utils.unit.test.ts index 07627f060..e24e97cf6 100644 --- a/packages/cli/src/lib/implementation/global.utils.unit.test.ts +++ b/packages/cli/src/lib/implementation/global.utils.unit.test.ts @@ -55,10 +55,6 @@ describe('logErrorBeforeThrow', () => { }); it('should log a custom error when OptionValidationError is thrown', async () => { - const loggerSpy = vi.spyOn(ui().logger, 'error').mockImplementation(() => { - /* empty */ - }); - const errorFn = vi .fn() .mockRejectedValue(new OptionValidationError('Option validation failed')); @@ -68,8 +64,7 @@ describe('logErrorBeforeThrow', () => { } catch { /* suppress */ } - - expect(loggerSpy).toHaveBeenCalledWith('Option validation failed'); + expect(ui()).toHaveLogged('error', 'Option validation failed'); }); it('should rethrow errors other than OptionValidationError', async () => { diff --git a/packages/cli/src/lib/implementation/validate-filter-options.utils.unit.test.ts b/packages/cli/src/lib/implementation/validate-filter-options.utils.unit.test.ts index 4bee2b3c2..58a72247e 100644 --- a/packages/cli/src/lib/implementation/validate-filter-options.utils.unit.test.ts +++ b/packages/cli/src/lib/implementation/validate-filter-options.utils.unit.test.ts @@ -1,6 +1,5 @@ import { describe, expect } from 'vitest'; import type { CategoryConfig, PluginConfig } from '@code-pushup/models'; -import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import type { FilterOptionType, Filterables } from './filter.model.js'; import { @@ -19,22 +18,22 @@ describe('validateFilterOption', () => { [ 'onlyPlugins', ['p1', 'p3', 'p4'], - 'The --onlyPlugins argument references plugins that do not exist: p3, p4.', + 'The --onlyPlugins argument references plugins that do not exist: p3, p4. The only valid plugin is p1.', ], [ 'onlyPlugins', ['p1', 'p3'], - 'The --onlyPlugins argument references a plugin that does not exist: p3.', + 'The --onlyPlugins argument references a plugin that does not exist: p3. The only valid plugin is p1.', ], [ 'onlyCategories', ['c1', 'c3', 'c4'], - 'The --onlyCategories argument references categories that do not exist: c3, c4.', + 'The --onlyCategories argument references categories that do not exist: c3, c4. The only valid category is c1.', ], [ 'onlyCategories', ['c1', 'c3'], - 'The --onlyCategories argument references a category that does not exist: c3.', + 'The --onlyCategories argument references a category that does not exist: c3. The only valid category is c1.', ], ])( 'should log a warning if the only argument %s references nonexistent slugs %o along with valid ones', @@ -51,8 +50,7 @@ describe('validateFilterOption', () => { }, { itemsToFilter, skippedItems: [], verbose: false }, ); - const logs = getLogMessages(ui().logger); - expect(logs[0]).toContain(expected); + expect(ui()).toHaveLogged('warn', expected); }, ); @@ -60,22 +58,22 @@ describe('validateFilterOption', () => { [ 'skipPlugins', ['p3', 'p4'], - 'The --skipPlugins argument references plugins that do not exist: p3, p4.', + 'The --skipPlugins argument references plugins that do not exist: p3, p4. The only valid plugin is p1.', ], [ 'skipPlugins', ['p3'], - 'The --skipPlugins argument references a plugin that does not exist: p3.', + 'The --skipPlugins argument references a plugin that does not exist: p3. The only valid plugin is p1.', ], [ 'skipCategories', ['c3', 'c4'], - 'The --skipCategories argument references categories that do not exist: c3, c4.', + 'The --skipCategories argument references categories that do not exist: c3, c4. The only valid category is c1.', ], [ 'skipCategories', ['c3'], - 'The --skipCategories argument references a category that does not exist: c3.', + 'The --skipCategories argument references a category that does not exist: c3. The only valid category is c1.', ], ])( 'should log a warning if the skip argument %s references nonexistent slugs %o', @@ -95,8 +93,7 @@ describe('validateFilterOption', () => { }, { itemsToFilter, skippedItems: [], verbose: false }, ); - const logs = getLogMessages(ui().logger); - expect(logs[0]).toContain(expected); + expect(ui()).toHaveLogged('warn', expected); }, ); @@ -111,7 +108,7 @@ describe('validateFilterOption', () => { }, { itemsToFilter: ['p1'], skippedItems: [], verbose: false }, ); - expect(getLogMessages(ui().logger)).toHaveLength(0); + expect(ui()).not.toHaveLogs(); }); it('should log a category ignored as a result of plugin filtering', () => { @@ -130,9 +127,10 @@ describe('validateFilterOption', () => { }, { itemsToFilter: ['p1'], skippedItems: [], verbose: true }, ); - expect(getLogMessages(ui().logger)).toHaveLength(1); - expect(getLogMessages(ui().logger)[0]).toContain( - 'The --onlyPlugins argument removed the following categories: c1, c3', + expect(ui()).toHaveLoggedTimes(1); + expect(ui()).toHaveLogged( + 'info', + 'The --onlyPlugins argument removed the following categories: c1, c3.', ); }); @@ -221,10 +219,16 @@ describe('validateFilterOption', () => { { plugins, categories }, { itemsToFilter: ['p1'], skippedItems: ['p1'], verbose: true }, ); - const logs = getLogMessages(ui().logger); - expect(logs[0]).toContain( + expect(ui()).toHaveNthLogged( + 1, + 'warn', 'The --skipPlugins argument references a skipped plugin: p1.', ); + expect(ui()).toHaveNthLogged( + 2, + 'info', + 'The --skipPlugins argument removed the following categories: c1.', + ); }); }); @@ -446,7 +450,6 @@ describe('validateSkippedCategories', () => { ] as NonNullable; it('should log info when categories are removed', () => { - const loggerSpy = vi.spyOn(ui().logger, 'info'); validateSkippedCategories( categories, [ @@ -457,7 +460,8 @@ describe('validateSkippedCategories', () => { ] as NonNullable, true, ); - expect(loggerSpy).toHaveBeenCalledWith( + expect(ui()).toHaveLogged( + 'info', 'Category c1 was removed because all its refs were skipped. Affected refs: g1 (group)', ); }); diff --git a/packages/cli/src/lib/merge-diffs/merge-diffs-command.unit.test.ts b/packages/cli/src/lib/merge-diffs/merge-diffs-command.unit.test.ts index 6e95f5a90..aca98fb91 100644 --- a/packages/cli/src/lib/merge-diffs/merge-diffs-command.unit.test.ts +++ b/packages/cli/src/lib/merge-diffs/merge-diffs-command.unit.test.ts @@ -5,7 +5,6 @@ import { DEFAULT_PERSIST_FORMAT, DEFAULT_PERSIST_OUTPUT_DIR, } from '@code-pushup/models'; -import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import { DEFAULT_CLI_CONFIGURATION } from '../../../mocks/constants.js'; import { yargsCli } from '../yargs-cli.js'; @@ -65,7 +64,8 @@ describe('merge-diffs-command', () => { }, ).parseAsync(); - expect(getLogMessages(ui().logger).at(-1)).toContain( + expect(ui()).toHaveLogged( + 'info', `Reports diff written to ${bold('.code-pushup/report-diff.md')}`, ); }); diff --git a/packages/cli/src/lib/print-config/print-config-command.unit.test.ts b/packages/cli/src/lib/print-config/print-config-command.unit.test.ts index 8e3c8cae4..23e09893b 100644 --- a/packages/cli/src/lib/print-config/print-config-command.unit.test.ts +++ b/packages/cli/src/lib/print-config/print-config-command.unit.test.ts @@ -1,5 +1,4 @@ import { describe, expect, vi } from 'vitest'; -import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import { DEFAULT_CLI_CONFIGURATION } from '../../../mocks/constants.js'; import { yargsCli } from '../yargs-cli.js'; @@ -27,11 +26,16 @@ describe('print-config-command', () => { { ...DEFAULT_CLI_CONFIGURATION, commands: [yargsConfigCommandObject()] }, ).parseAsync(); - const log = getLogMessages(ui().logger)[0]; - expect(log).not.toContain('"$0":'); - expect(log).not.toContain('"_":'); + expect(ui()).not.toHaveLogged('log', expect.stringContaining('"$0":')); + expect(ui()).not.toHaveLogged('log', expect.stringContaining('"_":')); - expect(log).toContain('"outputDir": "destinationDir"'); - expect(log).not.toContain('"output-dir":'); + expect(ui()).toHaveLogged( + 'log', + expect.stringContaining('"outputDir": "destinationDir"'), + ); + expect(ui()).not.toHaveLogged( + 'log', + expect.stringContaining('"output-dir":'), + ); }); }); diff --git a/packages/cli/tsconfig.test.json b/packages/cli/tsconfig.test.json index bb1ab5e0c..4de6650fc 100644 --- a/packages/cli/tsconfig.test.json +++ b/packages/cli/tsconfig.test.json @@ -12,6 +12,7 @@ "src/**/*.test.tsx", "src/**/*.test.js", "src/**/*.test.jsx", - "src/**/*.d.ts" + "src/**/*.d.ts", + "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/packages/cli/vite.config.unit.ts b/packages/cli/vite.config.unit.ts index 8ce1638f4..cb4a01fb0 100644 --- a/packages/cli/vite.config.unit.ts +++ b/packages/cli/vite.config.unit.ts @@ -28,6 +28,7 @@ export default defineConfig({ '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/portal-client.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts', + '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', ], }, }); diff --git a/packages/core/src/lib/collect-and-persist.unit.test.ts b/packages/core/src/lib/collect-and-persist.unit.test.ts index f9b6f8d22..1256b9e41 100644 --- a/packages/core/src/lib/collect-and-persist.unit.test.ts +++ b/packages/core/src/lib/collect-and-persist.unit.test.ts @@ -3,7 +3,6 @@ import { ISO_STRING_REGEXP, MINIMAL_CONFIG_MOCK, MINIMAL_REPORT_MOCK, - getLogMessages, } from '@code-pushup/test-utils'; import { type ScoredReport, @@ -115,7 +114,6 @@ describe('collectAndPersistReports', () => { await collectAndPersistReports( MINIMAL_CONFIG_MOCK as CollectAndPersistReportsOptions, ); - const logs = getLogMessages(ui().logger); - expect(logs.at(-2)).toContain('Made with ❤ by code-pushup.dev'); + expect(ui()).toHaveLogged('log', 'Made with ❤ by code-pushup.dev'); }); }); diff --git a/packages/core/src/lib/implementation/persist.unit.test.ts b/packages/core/src/lib/implementation/persist.unit.test.ts index d512a355a..e62ae19ae 100644 --- a/packages/core/src/lib/implementation/persist.unit.test.ts +++ b/packages/core/src/lib/implementation/persist.unit.test.ts @@ -7,7 +7,6 @@ import { MEMFS_VOLUME, MINIMAL_REPORT_MOCK, REPORT_MOCK, - getLogMessages, } from '@code-pushup/test-utils'; import { scoreReport, sortReport, ui } from '@code-pushup/utils'; import { logPersistedResults, persistReport } from './persist.js'; @@ -91,17 +90,27 @@ describe('persistReport', () => { describe('logPersistedResults', () => { it('should log report sizes correctly`', () => { logPersistedResults([{ status: 'fulfilled', value: ['out.json', 10_000] }]); - const logs = getLogMessages(ui().logger); - expect(logs[0]).toBe('[ green(success) ] Generated reports successfully: '); - expect(logs[1]).toContain('9.77 kB'); - expect(logs[1]).toContain('out.json'); + expect(ui()).toHaveNthLogged( + 1, + 'success', + expect.stringContaining('Generated reports successfully: '), + ); + expect(ui()).toHaveNthLogged( + 2, + 'success', + expect.stringContaining('9.77 kB'), + ); + expect(ui()).toHaveNthLogged( + 2, + 'success', + expect.stringContaining('out.json'), + ); }); it('should log fails correctly`', () => { logPersistedResults([{ status: 'rejected', reason: 'fail' }]); - const logs = getLogMessages(ui().logger); - expect(logs[0]).toBe('[ yellow(warn) ] Generated reports failed: '); - expect(logs[1]).toContain('fail'); + expect(ui()).toHaveNthLogged(1, 'warn', 'Generated reports failed: '); + expect(ui()).toHaveNthLogged(2, 'warn', expect.stringContaining('fail')); }); it('should log report sizes and fails correctly`', () => { @@ -109,12 +118,26 @@ describe('logPersistedResults', () => { { status: 'fulfilled', value: ['out.json', 10_000] }, { status: 'rejected', reason: 'fail' }, ]); - const logs = getLogMessages(ui().logger); - expect(logs[0]).toBe('[ green(success) ] Generated reports successfully: '); - expect(logs[1]).toContain('out.json'); - expect(logs[1]).toContain('9.77 kB'); - - expect(logs[2]).toContain('Generated reports failed: '); - expect(logs[2]).toContain('fail'); + expect(ui()).toHaveNthLogged( + 1, + 'success', + 'Generated reports successfully: ', + ); + expect(ui()).toHaveNthLogged( + 2, + 'success', + expect.stringContaining('out.json'), + ); + expect(ui()).toHaveNthLogged( + 2, + 'success', + expect.stringContaining('9.77 kB'), + ); + expect(ui()).toHaveNthLogged( + 3, + 'warn', + expect.stringContaining('Generated reports failed: '), + ); + expect(ui()).toHaveNthLogged(3, 'warn', expect.stringContaining('fail')); }); }); diff --git a/packages/core/src/lib/merge-diffs.unit.test.ts b/packages/core/src/lib/merge-diffs.unit.test.ts index 483f720c7..97b8cb163 100644 --- a/packages/core/src/lib/merge-diffs.unit.test.ts +++ b/packages/core/src/lib/merge-diffs.unit.test.ts @@ -4,7 +4,6 @@ import path from 'node:path'; import type { PersistConfig } from '@code-pushup/models'; import { MEMFS_VOLUME, - getLogMessages, reportsDiffAddedPluginMock, reportsDiffAltMock, reportsDiffMock, @@ -64,13 +63,19 @@ describe('mergeDiffs', () => { ), ).resolves.toBe(path.join(MEMFS_VOLUME, 'report-diff.md')); - expect(getLogMessages(ui().logger)).toEqual([ + expect(ui()).toHaveNthLogged( + 1, + 'warn', expect.stringContaining( 'Skipped invalid report diff - Failed to read JSON file missing-report-diff.json', ), + ); + expect(ui()).toHaveNthLogged( + 2, + 'warn', expect.stringContaining( 'Skipped invalid report diff - Invalid reports diff in invalid-report-diff.json', ), - ]); + ); }); }); diff --git a/packages/core/tsconfig.test.json b/packages/core/tsconfig.test.json index d8dfd40c8..6d65ad4d6 100644 --- a/packages/core/tsconfig.test.json +++ b/packages/core/tsconfig.test.json @@ -12,6 +12,7 @@ "src/**/*.test.js", "src/**/*.test.jsx", "src/**/*.d.ts", - "mocks/**/*.ts" + "mocks/**/*.ts", + "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/packages/core/vite.config.unit.ts b/packages/core/vite.config.unit.ts index ce56fb16c..729480d93 100644 --- a/packages/core/vite.config.unit.ts +++ b/packages/core/vite.config.unit.ts @@ -28,6 +28,7 @@ export default defineConfig({ '../../testing/test-setup/src/lib/console.mock.ts', '../../testing/test-setup/src/lib/reset.mocks.ts', '../../testing/test-setup/src/lib/portal-client.mock.ts', + '../../testing/test-setup/src/lib/extend/ui-logger.matcher.ts', ], }, }); diff --git a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts index 2ab6bcd2f..63be5307d 100644 --- a/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts +++ b/packages/plugin-coverage/src/lib/runner/lcov/lcov-runner.unit.test.ts @@ -1,7 +1,6 @@ import { vol } from 'memfs'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import { parseLcovFiles } from './lcov-runner.js'; @@ -99,11 +98,12 @@ end_of_record path.join('coverage', 'lcov.info'), ]); - expect(getLogMessages(ui().logger)[0]).toContain( + expect(ui()).toHaveLogged( + 'warn', `Coverage plugin: Empty lcov report file detected at ${path.join( 'coverage', 'lcov.info', - )}`, + )}.`, ); }); }); diff --git a/packages/plugin-coverage/tsconfig.test.json b/packages/plugin-coverage/tsconfig.test.json index 9f29d6bb0..211f44ad2 100644 --- a/packages/plugin-coverage/tsconfig.test.json +++ b/packages/plugin-coverage/tsconfig.test.json @@ -8,6 +8,7 @@ "vite.config.unit.ts", "vite.config.integration.ts", "mocks/**/*.ts", - "src/**/*.test.ts" + "src/**/*.test.ts", + "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/packages/plugin-coverage/vite.config.unit.ts b/packages/plugin-coverage/vite.config.unit.ts index e359bb1bb..e86d3f2e5 100644 --- a/packages/plugin-coverage/vite.config.unit.ts +++ b/packages/plugin-coverage/vite.config.unit.ts @@ -26,6 +26,7 @@ export default defineConfig({ '../../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/extend/ui-logger.matcher.ts', ], }, }); diff --git a/packages/plugin-lighthouse/src/lib/normalize-flags.unit.test.ts b/packages/plugin-lighthouse/src/lib/normalize-flags.unit.test.ts index 31c817d02..39e711976 100644 --- a/packages/plugin-lighthouse/src/lib/normalize-flags.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/normalize-flags.unit.test.ts @@ -1,7 +1,6 @@ import { bold, yellow } from 'ansis'; import path from 'node:path'; import { describe, expect, it } from 'vitest'; -import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import { DEFAULT_CHROME_FLAGS, LIGHTHOUSE_OUTPUT_PATH } from './constants.js'; import { logUnsupportedFlagsInUse, normalizeFlags } from './normalize-flags.js'; @@ -11,9 +10,10 @@ import type { LighthouseOptions } from './types.js'; describe('logUnsupportedFlagsInUse', () => { it('should log unsupported entries', () => { logUnsupportedFlagsInUse({ 'list-all-audits': true } as LighthouseOptions); - expect(getLogMessages(ui().logger)).toHaveLength(1); - expect(getLogMessages(ui().logger).at(0)).toBe( - `[ cyan(debug) ] ${yellow('⚠')} Plugin ${bold( + expect(ui()).toHaveLoggedTimes(1); + expect(ui()).toHaveLogged( + 'debug', + `${yellow('⚠')} Plugin ${bold( 'lighthouse', )} used unsupported flags: ${bold('list-all-audits')}`, ); @@ -32,9 +32,10 @@ describe('logUnsupportedFlagsInUse', () => { // unsupported ...unsupportedFlags, } as unknown as LighthouseOptions); - expect(getLogMessages(ui().logger)).toHaveLength(1); - expect(getLogMessages(ui().logger).at(0)).toBe( - `[ cyan(debug) ] ${yellow('⚠')} Plugin ${bold( + expect(ui()).toHaveLoggedTimes(1); + expect(ui()).toHaveLogged( + 'debug', + `${yellow('⚠')} Plugin ${bold( 'lighthouse', )} used unsupported flags: ${bold( 'list-all-audits, list-locales, list-trace-categories', @@ -118,7 +119,7 @@ describe('normalizeFlags', () => { ...supportedFlags, } as unknown as LighthouseOptions), ).toEqual(expect.not.objectContaining({ 'list-all-audits': true })); - expect(getLogMessages(ui().logger)).toHaveLength(1); + expect(ui()).toHaveLoggedTimes(1); }); it('should remove any flag with an empty array as a value', () => { 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 index 276a839e1..79be8d2db 100644 --- a/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/details/details.unit.test.ts @@ -3,7 +3,6 @@ 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.js'; @@ -12,9 +11,10 @@ describe('logUnsupportedDetails', () => { logUnsupportedDetails([ { details: { type: 'screenshot' } }, ] as unknown as Result[]); - expect(getLogMessages(ui().logger)).toHaveLength(1); - expect(getLogMessages(ui().logger).at(0)).toBe( - `[ cyan(debug) ] ${yellow('⚠')} Plugin ${bold( + expect(ui()).toHaveLoggedTimes(1); + expect(ui()).toHaveLogged( + 'debug', + `${yellow('⚠')} Plugin ${bold( 'lighthouse', )} skipped parsing of unsupported audit details: ${bold('screenshot')}`, ); @@ -30,9 +30,10 @@ describe('logUnsupportedDetails', () => { { 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) ] ${yellow('⚠')} Plugin ${bold( + expect(ui()).toHaveLoggedTimes(1); + expect(ui()).toHaveLogged( + 'debug', + `${yellow('⚠')} Plugin ${bold( 'lighthouse', )} skipped parsing of unsupported audit details: ${bold( 'filmstrip, screenshot, debugdata', 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 2e344b56f..5c6a0617f 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,6 @@ import { bold } from 'ansis'; import type 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 { type SimpleItemValue, @@ -147,15 +146,17 @@ describe('parseTableItemPropertyValue', () => { }), ).toBe(''); - expect(getLogMessages(ui().logger).at(0)).toBe( - `[ blue(info) ] Value type ${bold('subitems')} is not implemented`, + expect(ui()).toHaveLogged( + 'info', + `Value type ${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 ${bold('debugdata')} is not implemented`, + expect(ui()).toHaveLogged( + 'info', + `Value type ${bold('debugdata')} is not implemented`, ); }); @@ -362,8 +363,9 @@ describe('formatTableItemPropertyValue', () => { ), ).toBe(''); - expect(getLogMessages(ui().logger).at(0)).toBe( - `[ blue(info) ] Format type ${bold('multi')} is not implemented`, + expect(ui()).toHaveLogged( + 'info', + `Format type ${bold('multi')} is not implemented`, ); }); @@ -374,8 +376,9 @@ describe('formatTableItemPropertyValue', () => { 'thumbnail', ), ).toBe(''); - expect(getLogMessages(ui().logger).at(0)).toBe( - `[ blue(info) ] Format type ${bold('thumbnail')} is not implemented`, + expect(ui()).toHaveLogged( + 'info', + `Format type ${bold('thumbnail')} is not implemented`, ); }); 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 8f2aff376..2743e665e 100644 --- a/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/runner/utils.unit.test.ts @@ -11,7 +11,7 @@ import { type CoreConfig, auditOutputsSchema, } from '@code-pushup/models'; -import { MEMFS_VOLUME, getLogMessages } from '@code-pushup/test-utils'; +import { MEMFS_VOLUME } from '@code-pushup/test-utils'; import { ui } from '@code-pushup/utils'; import { unsupportedDetailTypes } from './details/details.js'; import { @@ -242,7 +242,7 @@ describe('toAuditOutputs', () => { }) as Result, ), ); - expect(getLogMessages(ui().logger)).toHaveLength(0); + expect(ui()).not.toHaveLogs(); }); it('should inform that for all unsupported details if verbose IS given', () => { @@ -260,7 +260,7 @@ describe('toAuditOutputs', () => { ), { verbose: true }, ); - expect(getLogMessages(ui().logger)).toHaveLength(1); + expect(ui()).toHaveLoggedTimes(1); }); it('should not parse empty audit details', () => { @@ -343,9 +343,7 @@ describe('getConfig', () => { await expect( getConfig({ preset: 'wrong' as 'desktop' }), ).resolves.toBeUndefined(); - expect(getLogMessages(ui().logger).at(0)).toMatch( - 'Preset "wrong" is not supported', - ); + expect(ui()).toHaveLogged('info', 'Preset "wrong" is not supported'); }); it('should load config from json file if configPath is specified', async () => { @@ -378,9 +376,7 @@ describe('getConfig', () => { await expect( getConfig({ configPath: path.join('wrong.not') }), ).resolves.toBeUndefined(); - expect(getLogMessages(ui().logger).at(0)).toMatch( - 'Format of file wrong.not not supported', - ); + expect(ui()).toHaveLogged('info', 'Format of file wrong.not not supported'); }); }); diff --git a/packages/plugin-lighthouse/tsconfig.test.json b/packages/plugin-lighthouse/tsconfig.test.json index bb1ab5e0c..4de6650fc 100644 --- a/packages/plugin-lighthouse/tsconfig.test.json +++ b/packages/plugin-lighthouse/tsconfig.test.json @@ -12,6 +12,7 @@ "src/**/*.test.tsx", "src/**/*.test.js", "src/**/*.test.jsx", - "src/**/*.d.ts" + "src/**/*.d.ts", + "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/packages/plugin-lighthouse/vite.config.unit.ts b/packages/plugin-lighthouse/vite.config.unit.ts index 932dbde85..6b797a223 100644 --- a/packages/plugin-lighthouse/vite.config.unit.ts +++ b/packages/plugin-lighthouse/vite.config.unit.ts @@ -26,6 +26,7 @@ export default defineConfig({ '../../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/extend/ui-logger.matcher.ts', ], }, }); diff --git a/packages/utils/src/lib/log-results.unit.test.ts b/packages/utils/src/lib/log-results.unit.test.ts index b0549733b..e9203fbae 100644 --- a/packages/utils/src/lib/log-results.unit.test.ts +++ b/packages/utils/src/lib/log-results.unit.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest'; -import { getLogMessages } from '@code-pushup/test-utils'; import type { FileResult } from './file-system.js'; import { logMultipleResults, logPromiseResults } from './log-results.js'; import { ui } from './logging.js'; @@ -68,9 +67,12 @@ describe('logPromiseResults', () => { 'Uploaded reports successfully:', (result): string => result.value.toString(), ); - const logs = getLogMessages(ui().logger); - expect(logs[0]).toBe('[ green(success) ] Uploaded reports successfully:'); - expect(logs[1]).toBe('[ green(success) ] out.json'); + expect(ui()).toHaveNthLogged( + 1, + 'success', + 'Uploaded reports successfully:', + ); + expect(ui()).toHaveNthLogged(2, 'success', 'out.json'); }); it('should log on fail', () => { @@ -79,8 +81,7 @@ describe('logPromiseResults', () => { 'Generated reports failed:', (result: { reason: string }) => result.reason, ); - const logs = getLogMessages(ui().logger); - expect(logs[0]).toBe('[ yellow(warn) ] Generated reports failed:'); - expect(logs[1]).toBe('[ yellow(warn) ] fail'); + expect(ui()).toHaveNthLogged(1, 'warn', 'Generated reports failed:'); + expect(ui()).toHaveNthLogged(2, 'warn', 'fail'); }); }); diff --git a/packages/utils/src/lib/verbose-utils.unit.test.ts b/packages/utils/src/lib/verbose-utils.unit.test.ts index 339131cf7..1403d44f7 100644 --- a/packages/utils/src/lib/verbose-utils.unit.test.ts +++ b/packages/utils/src/lib/verbose-utils.unit.test.ts @@ -1,5 +1,4 @@ import { describe, expect, it, vi } from 'vitest'; -import { getLogMessages } from '@code-pushup/test-utils'; import { ui } from './logging.js'; import { verboseUtils } from './verbose-utils.js'; @@ -24,16 +23,16 @@ describe('verbose-utils', () => { it('logs should be off by default', () => { verboseUtils(false).log('42'); - expect(getLogMessages(ui().logger)).toHaveLength(0); + expect(ui()).not.toHaveLogs(); }); it('should not print any logs when verbose is off', () => { verboseUtils(false).log('42'); - expect(getLogMessages(ui().logger)).toHaveLength(0); + expect(ui()).not.toHaveLogs(); }); it('should log when verbose is on', () => { verboseUtils(true).log('42'); - expect(getLogMessages(ui().logger)[0]).toContain('42'); + expect(ui()).toHaveLogged('info', '42'); }); }); diff --git a/packages/utils/tsconfig.test.json b/packages/utils/tsconfig.test.json index bb1ab5e0c..4de6650fc 100644 --- a/packages/utils/tsconfig.test.json +++ b/packages/utils/tsconfig.test.json @@ -12,6 +12,7 @@ "src/**/*.test.tsx", "src/**/*.test.js", "src/**/*.test.jsx", - "src/**/*.d.ts" + "src/**/*.d.ts", + "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/packages/utils/vite.config.unit.ts b/packages/utils/vite.config.unit.ts index 66d9e7717..79a1203e3 100644 --- a/packages/utils/vite.config.unit.ts +++ b/packages/utils/vite.config.unit.ts @@ -26,6 +26,7 @@ export default defineConfig({ '../../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/extend/ui-logger.matcher.ts', ], }, }); diff --git a/testing/test-setup/src/lib/extend/ui-logger.matcher.ts b/testing/test-setup/src/lib/extend/ui-logger.matcher.ts new file mode 100644 index 000000000..e8b6e4a25 --- /dev/null +++ b/testing/test-setup/src/lib/extend/ui-logger.matcher.ts @@ -0,0 +1,96 @@ +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'; + +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 new file mode 100644 index 000000000..4c7f8b789 --- /dev/null +++ b/testing/test-setup/src/lib/extend/ui-logger.matcher.utils.ts @@ -0,0 +1,83 @@ +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 new file mode 100644 index 000000000..df91006c3 --- /dev/null +++ b/testing/test-setup/src/lib/extend/ui-logger.matcher.utils.unit.test.ts @@ -0,0 +1,82 @@ +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 801a7667b..2ee2fb9f5 100644 --- a/testing/test-setup/src/vitest.d.ts +++ b/testing/test-setup/src/vitest.d.ts @@ -3,10 +3,10 @@ import type { CustomAsymmetricPathMatchers, CustomPathMatchers, } from './lib/extend/path.matcher.js'; +import type { CustomUiLoggerMatchers } from './lib/extend/ui-logger.matcher.js'; declare module 'vitest' { - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - interface Assertion extends CustomPathMatchers {} + interface Assertion extends CustomPathMatchers, CustomUiLoggerMatchers {} // eslint-disable-next-line @typescript-eslint/no-empty-object-type interface AsymmetricMatchersContaining extends CustomAsymmetricPathMatchers {} } diff --git a/testing/test-utils/src/index.ts b/testing/test-utils/src/index.ts index 2adbfb7b9..25a36b96c 100644 --- a/testing/test-utils/src/index.ts +++ b/testing/test-utils/src/index.ts @@ -1,7 +1,6 @@ export * from './lib/constants.js'; export * from './lib/utils/execute-process-helper.mock.js'; export * from './lib/utils/os-agnostic-paths.js'; -export * from './lib/utils/logging.js'; export * from './lib/utils/env.js'; export * from './lib/utils/git.js'; export * from './lib/utils/string.js'; diff --git a/testing/test-utils/src/lib/utils/logging.ts b/testing/test-utils/src/lib/utils/logging.ts deleted file mode 100644 index 43ab1d6c3..000000000 --- a/testing/test-utils/src/lib/utils/logging.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Logger } from '@poppinss/cliui'; - -export function getLogMessages(logger: Logger): string[] { - return logger - .getRenderer() - .getLogs() - .map(({ message }) => message); -}