From 7d03bc042d4ffb85e25646a7e8ea2bc52cb8b042 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Tue, 24 Mar 2026 22:00:21 -0400 Subject: [PATCH 1/3] feat(plugin-lighthouse): add setup wizard binding --- packages/create-cli/README.md | 7 + packages/create-cli/package.json | 1 + packages/create-cli/src/index.ts | 4 +- .../src/lib/setup/codegen-categories.ts | 99 +++++++++++++ packages/create-cli/src/lib/setup/codegen.ts | 60 +++----- .../src/lib/setup/codegen.unit.test.ts | 124 ++++++++++++++++ packages/create-cli/src/lib/setup/types.ts | 1 + packages/models/src/index.ts | 2 + packages/models/src/lib/plugin-setup.ts | 20 ++- packages/plugin-lighthouse/src/index.ts | 1 + packages/plugin-lighthouse/src/lib/binding.ts | 135 ++++++++++++++++++ .../src/lib/binding.unit.test.ts | 122 ++++++++++++++++ packages/utils/src/index.ts | 7 +- packages/utils/src/lib/merge-configs.ts | 2 +- packages/utils/src/lib/plugin-answers.ts | 10 ++ 15 files changed, 547 insertions(+), 48 deletions(-) create mode 100644 packages/create-cli/src/lib/setup/codegen-categories.ts create mode 100644 packages/plugin-lighthouse/src/lib/binding.ts create mode 100644 packages/plugin-lighthouse/src/lib/binding.unit.test.ts diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index bbef5ef02b..46db6e5649 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -65,6 +65,13 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen | **`--typescript.tsconfig`** | `string` | auto-detected | TypeScript config file | | **`--typescript.categories`** | `boolean` | `true` | Add TypeScript categories | +#### Lighthouse + +| Option | Type | Default | Description | +| ----------------------------- | ---------------------------------------------------------------- | ----------------------- | ------------------------------- | +| **`--lighthouse.urls`** | `string` | `http://localhost:4200` | Target URL(s) (comma-separated) | +| **`--lighthouse.categories`** | `('performance'` \| `'a11y'` \| `'best-practices'` \| `'seo')[]` | all | Lighthouse categories | + ### Examples Run interactively (default): diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index eb56af636e..6951c21ac3 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -29,6 +29,7 @@ "@code-pushup/coverage-plugin": "0.123.0", "@code-pushup/eslint-plugin": "0.123.0", "@code-pushup/js-packages-plugin": "0.123.0", + "@code-pushup/lighthouse-plugin": "0.123.0", "@code-pushup/models": "0.123.0", "@code-pushup/typescript-plugin": "0.123.0", "@code-pushup/utils": "0.123.0", diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index b0184249da..b25fd014a8 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -4,6 +4,7 @@ import { hideBin } from 'yargs/helpers'; import { coverageSetupBinding } from '@code-pushup/coverage-plugin'; import { eslintSetupBinding } from '@code-pushup/eslint-plugin'; import { jsPackagesSetupBinding } from '@code-pushup/js-packages-plugin'; +import { lighthouseSetupBinding } from '@code-pushup/lighthouse-plugin'; import { typescriptSetupBinding } from '@code-pushup/typescript-plugin'; import { parsePluginSlugs, validatePluginSlugs } from './lib/setup/plugins.js'; import { @@ -14,12 +15,13 @@ import { } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; -// TODO: create, import and pass remaining plugin bindings (lighthouse, jsdocs, axe) +// TODO: create, import and pass remaining plugin bindings (jsdocs, axe) const bindings: PluginSetupBinding[] = [ eslintSetupBinding, coverageSetupBinding, jsPackagesSetupBinding, typescriptSetupBinding, + lighthouseSetupBinding, ]; const argv = await yargs(hideBin(process.argv)) diff --git a/packages/create-cli/src/lib/setup/codegen-categories.ts b/packages/create-cli/src/lib/setup/codegen-categories.ts new file mode 100644 index 0000000000..ff067d6ac4 --- /dev/null +++ b/packages/create-cli/src/lib/setup/codegen-categories.ts @@ -0,0 +1,99 @@ +import type { CategoryRef } from '@code-pushup/models'; +import { mergeDescriptions, singleQuote } from '@code-pushup/utils'; +import type { CodeBuilder } from './codegen.js'; +import type { CategoryCodegenConfig, PluginCodegenResult } from './types.js'; + +type MergedCategory = { + slug: string; + title: string; + description?: string; + docsUrl?: string; + refs: CategoryRef[]; + refsExpressions: string[]; +}; + +export function addCategories( + builder: CodeBuilder, + plugins: PluginCodegenResult[], + depth = 1, +): void { + const categories = mergeCategoriesBySlug( + plugins.flatMap(p => p.categories ?? []).map(toMergedCategory), + ); + if (categories.length === 0) { + return; + } + builder.addLine('categories: [', depth); + categories.forEach( + ({ slug, title, description, docsUrl, refs, refsExpressions }) => { + builder.addLine('{', depth + 1); + builder.addLine(`slug: '${slug}',`, depth + 2); + builder.addLine(`title: ${singleQuote(title)},`, depth + 2); + if (description) { + builder.addLine(`description: ${singleQuote(description)},`, depth + 2); + } + if (docsUrl) { + builder.addLine(`docsUrl: ${singleQuote(docsUrl)},`, depth + 2); + } + addCategoryRefs(builder, refs, refsExpressions, depth + 2); + builder.addLine('},', depth + 1); + }, + ); + builder.addLine('],', depth); +} + +function toMergedCategory(category: CategoryCodegenConfig): MergedCategory { + return { + slug: category.slug, + title: category.title, + description: category.description, + docsUrl: category.docsUrl, + refs: 'refs' in category ? category.refs : [], + refsExpressions: + 'refsExpression' in category ? [category.refsExpression] : [], + }; +} + +function mergeCategoriesBySlug(categories: MergedCategory[]): MergedCategory[] { + const map = categories.reduce((acc, category) => { + const existing = acc.get(category.slug); + acc.set( + category.slug, + existing ? mergeCategory(existing, category) : category, + ); + return acc; + }, new Map()); + return [...map.values()]; +} + +function mergeCategory( + existing: MergedCategory, + incoming: MergedCategory, +): MergedCategory { + return { + ...existing, + description: mergeDescriptions(existing.description, incoming.description), + docsUrl: existing.docsUrl ?? incoming.docsUrl, + refs: [...existing.refs, ...incoming.refs], + refsExpressions: [...existing.refsExpressions, ...incoming.refsExpressions], + }; +} + +function addCategoryRefs( + builder: CodeBuilder, + refs: MergedCategory['refs'], + refsExpressions: MergedCategory['refsExpressions'], + depth: number, +): void { + builder.addLine('refs: [', depth); + builder.addLines( + refsExpressions.map(expr => `...${expr},`), + depth + 1, + ); + builder.addLines(refs.map(formatCategoryRef), depth + 1); + builder.addLine('],', depth); +} + +function formatCategoryRef(ref: CategoryRef): string { + return `{ type: '${ref.type}', plugin: '${ref.plugin}', slug: '${ref.slug}', weight: ${ref.weight} },`; +} diff --git a/packages/create-cli/src/lib/setup/codegen.ts b/packages/create-cli/src/lib/setup/codegen.ts index 2c3f6e9dcc..62845bedca 100644 --- a/packages/create-cli/src/lib/setup/codegen.ts +++ b/packages/create-cli/src/lib/setup/codegen.ts @@ -1,10 +1,6 @@ import path from 'node:path'; -import type { CategoryRef } from '@code-pushup/models'; -import { - mergeCategoriesBySlug, - singleQuote, - toUnixPath, -} from '@code-pushup/utils'; +import { exists, toUnixPath } from '@code-pushup/utils'; +import { addCategories } from './codegen-categories.js'; import type { ConfigFileFormat, ImportDeclarationStructure, @@ -17,7 +13,7 @@ const CORE_CONFIG_IMPORT: ImportDeclarationStructure = { isTypeOnly: true, }; -class CodeBuilder { +export class CodeBuilder { private lines: string[] = []; addLine(text: string, depth = 0): void { @@ -45,6 +41,7 @@ export function generateConfigSource( ): string { const builder = new CodeBuilder(); addImports(builder, collectImports(plugins, format)); + addPluginDeclarations(builder, plugins); if (format === 'ts') { builder.addLine('export default {'); addPlugins(builder, plugins); @@ -66,6 +63,7 @@ export function generatePresetSource( ): string { const builder = new CodeBuilder(); addImports(builder, collectImports(plugins, format)); + addPluginDeclarations(builder, plugins); addPresetExport(builder, plugins, format); return builder.toString(); } @@ -137,6 +135,20 @@ function addImports( } } +function addPluginDeclarations( + builder: CodeBuilder, + plugins: PluginCodegenResult[], +): void { + const declarations = plugins + .map(({ pluginDeclaration }) => pluginDeclaration) + .filter(exists) + .map(d => `const ${d.identifier} = ${d.expression};`); + if (declarations.length > 0) { + builder.addLines(declarations); + builder.addEmptyLine(); + } +} + function addPlugins( builder: CodeBuilder, plugins: PluginCodegenResult[], @@ -183,37 +195,3 @@ function addPresetExport( builder.addLine('};', 1); builder.addLine('}'); } - -function addCategories( - builder: CodeBuilder, - plugins: PluginCodegenResult[], - depth = 1, -): void { - const categories = mergeCategoriesBySlug( - plugins.flatMap(p => p.categories ?? []), - ); - if (categories.length === 0) { - return; - } - builder.addLine('categories: [', depth); - categories.forEach(({ slug, title, description, docsUrl, refs }) => { - builder.addLine('{', depth + 1); - builder.addLine(`slug: '${slug}',`, depth + 2); - builder.addLine(`title: ${singleQuote(title)},`, depth + 2); - if (description) { - builder.addLine(`description: ${singleQuote(description)},`, depth + 2); - } - if (docsUrl) { - builder.addLine(`docsUrl: ${singleQuote(docsUrl)},`, depth + 2); - } - builder.addLine('refs: [', depth + 2); - builder.addLines(refs.map(formatCategoryRef), depth + 3); - builder.addLine('],', depth + 2); - builder.addLine('},', depth + 1); - }); - builder.addLine('],', depth); -} - -function formatCategoryRef(ref: CategoryRef): string { - return `{ type: '${ref.type}', plugin: '${ref.plugin}', slug: '${ref.slug}', weight: ${ref.weight} },`; -} diff --git a/packages/create-cli/src/lib/setup/codegen.unit.test.ts b/packages/create-cli/src/lib/setup/codegen.unit.test.ts index 7fa514bdf2..d7993af876 100644 --- a/packages/create-cli/src/lib/setup/codegen.unit.test.ts +++ b/packages/create-cli/src/lib/setup/codegen.unit.test.ts @@ -376,6 +376,130 @@ describe('generateConfigSource', () => { expect(source).toContain("plugin: 'ts'"); }); }); + + describe('pluginDeclaration', () => { + it('should emit variable declaration between imports and config export', () => { + const plugin: PluginCodegenResult = { + imports: [ + { + moduleSpecifier: '@code-pushup/lighthouse-plugin', + defaultImport: 'lighthousePlugin', + }, + ], + pluginDeclaration: { + identifier: 'lhPlugin', + expression: "lighthousePlugin('http://localhost:4200')", + }, + pluginInit: ['lhPlugin,'], + }; + expect(generateConfigSource([plugin], 'ts')).toMatchInlineSnapshot(` + "import lighthousePlugin from '@code-pushup/lighthouse-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + const lhPlugin = lighthousePlugin('http://localhost:4200'); + + export default { + plugins: [ + lhPlugin, + ], + } satisfies CoreConfig; + " + `); + }); + }); + + describe('expression refs', () => { + it('should generate config with expression refs and merged categories', () => { + expect( + generateConfigSource( + [ + { + imports: [ + { + moduleSpecifier: '@code-pushup/lighthouse-plugin', + defaultImport: 'lighthousePlugin', + namedImports: ['lighthouseGroupRefs'], + }, + ], + pluginDeclaration: { + identifier: 'lhPlugin', + expression: "lighthousePlugin('http://localhost:4200')", + }, + pluginInit: ['lhPlugin,'], + categories: [ + { + slug: 'a11y', + title: 'Accessibility', + refsExpression: + "lighthouseGroupRefs(lhPlugin, 'accessibility')", + }, + { + slug: 'performance', + title: 'Performance', + refsExpression: + "lighthouseGroupRefs(lhPlugin, 'performance')", + }, + ], + }, + { + imports: [ + { + moduleSpecifier: '@code-pushup/axe-plugin', + defaultImport: 'axePlugin', + namedImports: ['axeGroupRefs'], + }, + ], + pluginDeclaration: { + identifier: 'axe', + expression: "axePlugin('http://localhost:4200')", + }, + pluginInit: ['axe,'], + categories: [ + { + slug: 'a11y', + title: 'Accessibility', + refsExpression: 'axeGroupRefs(axe)', + }, + ], + }, + ], + 'ts', + ), + ).toMatchInlineSnapshot(` + "import axePlugin, { axeGroupRefs } from '@code-pushup/axe-plugin'; + import lighthousePlugin, { lighthouseGroupRefs } from '@code-pushup/lighthouse-plugin'; + import type { CoreConfig } from '@code-pushup/models'; + + const lhPlugin = lighthousePlugin('http://localhost:4200'); + const axe = axePlugin('http://localhost:4200'); + + export default { + plugins: [ + lhPlugin, + axe, + ], + categories: [ + { + slug: 'a11y', + title: 'Accessibility', + refs: [ + ...lighthouseGroupRefs(lhPlugin, 'accessibility'), + ...axeGroupRefs(axe), + ], + }, + { + slug: 'performance', + title: 'Performance', + refs: [ + ...lighthouseGroupRefs(lhPlugin, 'performance'), + ], + }, + ], + } satisfies CoreConfig; + " + `); + }); + }); }); describe('generatePresetSource', () => { diff --git a/packages/create-cli/src/lib/setup/types.ts b/packages/create-cli/src/lib/setup/types.ts index 5891f163e2..5948d4a157 100644 --- a/packages/create-cli/src/lib/setup/types.ts +++ b/packages/create-cli/src/lib/setup/types.ts @@ -2,6 +2,7 @@ import type { PluginCodegenResult } from '@code-pushup/models'; import type { MonorepoTool } from '@code-pushup/utils'; export type { + CategoryCodegenConfig, ImportDeclarationStructure, PluginAnswer, PluginCodegenResult, diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 76aa96092e..680db2a374 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -113,9 +113,11 @@ export { type PluginUrls, } from './lib/plugin-config.js'; export type { + CategoryCodegenConfig, ImportDeclarationStructure, PluginAnswer, PluginCodegenResult, + PluginDeclarationStructure, PluginPromptDescriptor, PluginSetupBinding, PluginSetupTree, diff --git a/packages/models/src/lib/plugin-setup.ts b/packages/models/src/lib/plugin-setup.ts index bc335a46ac..f10bbd94c7 100644 --- a/packages/models/src/lib/plugin-setup.ts +++ b/packages/models/src/lib/plugin-setup.ts @@ -37,6 +37,9 @@ export type PluginPromptDescriptor = | CheckboxPrompt | ConfirmPrompt; +/** A single value in the answers record produced by plugin prompts. */ +export type PluginAnswer = string | string[] | boolean; + export type ImportDeclarationStructure = { moduleSpecifier: string; defaultImport?: string; @@ -44,14 +47,23 @@ export type ImportDeclarationStructure = { isTypeOnly?: boolean; }; -/** A single value in the answers record produced by plugin prompts. */ -export type PluginAnswer = string | string[] | boolean; +export type PluginDeclarationStructure = { + identifier: string; + expression: string; +}; + +type CategoryCodegenRefs = + | { refs: CategoryConfig['refs'] } + | { refsExpression: string }; + +export type CategoryCodegenConfig = CategoryCodegenRefs & + Pick; -/** Code a plugin binding contributes to the generated config. */ export type PluginCodegenResult = { imports: ImportDeclarationStructure[]; + pluginDeclaration?: PluginDeclarationStructure; pluginInit: string[]; - categories?: CategoryConfig[]; + categories?: CategoryCodegenConfig[]; }; /** Minimal file system abstraction passed to plugin bindings. */ diff --git a/packages/plugin-lighthouse/src/index.ts b/packages/plugin-lighthouse/src/index.ts index 4357d4fb50..d9f6c048c3 100644 --- a/packages/plugin-lighthouse/src/index.ts +++ b/packages/plugin-lighthouse/src/index.ts @@ -19,3 +19,4 @@ export { lighthouseCategories, mergeLighthouseCategories, } from './lib/categories.js'; +export { lighthouseSetupBinding } from './lib/binding.js'; diff --git a/packages/plugin-lighthouse/src/lib/binding.ts b/packages/plugin-lighthouse/src/lib/binding.ts new file mode 100644 index 0000000000..84bd5e5a3a --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/binding.ts @@ -0,0 +1,135 @@ +import { createRequire } from 'node:module'; +import type { + CategoryCodegenConfig, + PluginAnswer, + PluginSetupBinding, +} from '@code-pushup/models'; +import { + answerArray, + answerNonEmptyArray, + singleQuote, +} from '@code-pushup/utils'; +import { + LIGHTHOUSE_PLUGIN_SLUG, + LIGHTHOUSE_PLUGIN_TITLE, +} from './constants.js'; + +const { name: PACKAGE_NAME } = createRequire(import.meta.url)( + '../../package.json', +) as typeof import('../../package.json'); + +const DEFAULT_URL = 'http://localhost:4200'; +const PLUGIN_VAR = 'lhPlugin'; + +const CATEGORIES: CategoryCodegenConfig[] = [ + { + slug: 'performance', + title: 'Performance', + description: + 'Measure performance and find opportunities to speed up page loads.', + refsExpression: `lighthouseGroupRefs(${PLUGIN_VAR}, 'performance')`, + }, + { + slug: 'a11y', + title: 'Accessibility', + description: + 'Determine if all users access content and navigate your site effectively.', + refsExpression: `lighthouseGroupRefs(${PLUGIN_VAR}, 'accessibility')`, + }, + { + slug: 'best-practices', + title: 'Best Practices', + description: + 'Improve code health of your web page following these best practices.', + refsExpression: `lighthouseGroupRefs(${PLUGIN_VAR}, 'best-practices')`, + }, + { + slug: 'seo', + title: 'SEO', + description: + 'Ensure that your page is optimized for search engine results ranking.', + refsExpression: `lighthouseGroupRefs(${PLUGIN_VAR}, 'seo')`, + }, +]; + +const CATEGORY_CHOICES = CATEGORIES.map(({ slug, title }) => ({ + name: title, + value: slug, +})); + +const DEFAULT_CATEGORIES = CATEGORIES.map(({ slug }) => slug); + +type LighthouseOptions = { + urls: [string, ...string[]]; + categories: string[]; +}; + +export const lighthouseSetupBinding = { + slug: LIGHTHOUSE_PLUGIN_SLUG, + title: LIGHTHOUSE_PLUGIN_TITLE, + packageName: PACKAGE_NAME, + prompts: async (_targetDir: string) => [ + { + key: 'lighthouse.urls', + message: 'Target URL(s) (comma-separated)', + type: 'input', + default: DEFAULT_URL, + }, + { + key: 'lighthouse.categories', + message: 'Lighthouse categories', + type: 'checkbox', + choices: [...CATEGORY_CHOICES], + default: [...DEFAULT_CATEGORIES], + }, + ], + generateConfig: (answers: Record) => { + const options = parseAnswers(answers); + const hasCategories = options.categories.length > 0; + const formattedUrls = formatUrls(options.urls); + const imports = [ + { + moduleSpecifier: PACKAGE_NAME, + defaultImport: 'lighthousePlugin', + ...(hasCategories ? { namedImports: ['lighthouseGroupRefs'] } : {}), + }, + ]; + if (!hasCategories) { + return { + imports, + pluginInit: [`lighthousePlugin(${formattedUrls}),`], + }; + } + return { + imports, + pluginDeclaration: { + identifier: PLUGIN_VAR, + expression: `lighthousePlugin(${formattedUrls})`, + }, + pluginInit: [`${PLUGIN_VAR},`], + categories: createCategories(options), + }; + }, +} satisfies PluginSetupBinding; + +function parseAnswers( + answers: Record, +): LighthouseOptions { + return { + urls: answerNonEmptyArray(answers, 'lighthouse.urls', DEFAULT_URL), + categories: answerArray(answers, 'lighthouse.categories'), + }; +} + +function formatUrls([first, ...rest]: [string, ...string[]]): string { + if (rest.length === 0) { + return singleQuote(first); + } + return `[${[first, ...rest].map(singleQuote).join(', ')}]`; +} + +function createCategories({ + categories, +}: LighthouseOptions): CategoryCodegenConfig[] { + return CATEGORIES.filter(({ slug }) => categories.includes(slug)); +} diff --git a/packages/plugin-lighthouse/src/lib/binding.unit.test.ts b/packages/plugin-lighthouse/src/lib/binding.unit.test.ts new file mode 100644 index 0000000000..5eacaddf86 --- /dev/null +++ b/packages/plugin-lighthouse/src/lib/binding.unit.test.ts @@ -0,0 +1,122 @@ +import type { PluginAnswer } from '@code-pushup/models'; +import { lighthouseSetupBinding as binding } from './binding.js'; + +const defaultAnswers: Record = { + 'lighthouse.urls': 'http://localhost:4200', + 'lighthouse.categories': ['performance', 'a11y', 'best-practices', 'seo'], +}; + +const noCategoryAnswers: Record = { + ...defaultAnswers, + 'lighthouse.categories': [], +}; + +describe('lighthouseSetupBinding', () => { + describe('prompts', () => { + it('should select all categories by default', async () => { + await expect(binding.prompts!('')).resolves.toIncludeAllPartialMembers([ + { + key: 'lighthouse.categories', + type: 'checkbox', + default: ['performance', 'a11y', 'best-practices', 'seo'], + }, + ]); + }); + }); + + describe('generateConfig with categories selected', () => { + it('should declare plugin as a variable for use in category refs', () => { + expect(binding.generateConfig(defaultAnswers).pluginDeclaration).toEqual({ + identifier: 'lhPlugin', + expression: "lighthousePlugin('http://localhost:4200')", + }); + }); + + it('should import lighthouseGroupRefs helper', () => { + expect(binding.generateConfig(defaultAnswers).imports).toEqual([ + expect.objectContaining({ namedImports: ['lighthouseGroupRefs'] }), + ]); + }); + + it('should produce categories with refs expressions for each selected group', () => { + const { categories } = binding.generateConfig(defaultAnswers); + expect(categories).toHaveLength(4); + expect(categories).toEqual([ + expect.objectContaining({ + slug: 'performance', + refsExpression: "lighthouseGroupRefs(lhPlugin, 'performance')", + }), + expect.objectContaining({ + slug: 'a11y', + refsExpression: "lighthouseGroupRefs(lhPlugin, 'accessibility')", + }), + expect.objectContaining({ + slug: 'best-practices', + refsExpression: "lighthouseGroupRefs(lhPlugin, 'best-practices')", + }), + expect.objectContaining({ + slug: 'seo', + refsExpression: "lighthouseGroupRefs(lhPlugin, 'seo')", + }), + ]); + }); + + it('should only include selected categories', () => { + const { categories } = binding.generateConfig({ + ...defaultAnswers, + 'lighthouse.categories': ['performance', 'seo'], + }); + expect(categories).toHaveLength(2); + expect(categories).toEqual([ + expect.objectContaining({ slug: 'performance' }), + expect.objectContaining({ slug: 'seo' }), + ]); + }); + + it('should use custom URL in plugin declaration', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'lighthouse.urls': 'https://example.com', + }).pluginDeclaration, + ).toEqual( + expect.objectContaining({ + expression: "lighthousePlugin('https://example.com')", + }), + ); + }); + + it('should format multiple URLs as an array', () => { + expect( + binding.generateConfig({ + ...defaultAnswers, + 'lighthouse.urls': 'http://localhost:4200, http://localhost:4201', + }).pluginDeclaration, + ).toEqual( + expect.objectContaining({ + expression: + "lighthousePlugin(['http://localhost:4200', 'http://localhost:4201'])", + }), + ); + }); + }); + + describe('generateConfig without categories selected', () => { + it('should not declare plugin as a variable', () => { + expect( + binding.generateConfig(noCategoryAnswers).pluginDeclaration, + ).toBeUndefined(); + }); + + it('should not import lighthouseGroupRefs helper', () => { + const { imports } = binding.generateConfig(noCategoryAnswers); + expect(imports[0]).not.toHaveProperty('namedImports'); + }); + + it('should not produce categories', () => { + expect( + binding.generateConfig(noCategoryAnswers).categories, + ).toBeUndefined(); + }); + }); +}); diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 46218b989b..5cacf5243d 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -94,7 +94,11 @@ export { } from './lib/guards.js'; export { interpolate } from './lib/interpolate.js'; export { Logger, logger } from './lib/logger.js'; -export { mergeCategoriesBySlug, mergeConfigs } from './lib/merge-configs.js'; +export { + mergeCategoriesBySlug, + mergeConfigs, + mergeDescriptions, +} from './lib/merge-configs.js'; export { loadNxProjectGraph } from './lib/nx.js'; export { addIndex, @@ -197,6 +201,7 @@ export { export { answerArray, answerBoolean, + answerNonEmptyArray, answerString, } from './lib/plugin-answers.js'; export { diff --git a/packages/utils/src/lib/merge-configs.ts b/packages/utils/src/lib/merge-configs.ts index a9794bd64e..9aba7d65a4 100644 --- a/packages/utils/src/lib/merge-configs.ts +++ b/packages/utils/src/lib/merge-configs.ts @@ -188,7 +188,7 @@ function toSentence(text: string): string { return `${trimmed}.`; } -function mergeDescriptions( +export function mergeDescriptions( a: string | undefined, b: string | undefined, ): string | undefined { diff --git a/packages/utils/src/lib/plugin-answers.ts b/packages/utils/src/lib/plugin-answers.ts index 056eab612f..224a932a87 100644 --- a/packages/utils/src/lib/plugin-answers.ts +++ b/packages/utils/src/lib/plugin-answers.ts @@ -24,6 +24,16 @@ export function answerArray( .filter(Boolean); } +/** Extracts a non-empty string array from a plugin answer, using a default if empty. */ +export function answerNonEmptyArray( + answers: Record, + key: string, + defaultValue: string, +): [string, ...string[]] { + const [first = defaultValue, ...rest] = answerArray(answers, key); + return [first, ...rest]; +} + /** Extracts a boolean from a plugin answer, defaulting to `true`. */ export function answerBoolean( answers: Record, From 9da4e8a2a66faf7304b344aadcdeeff1c01f680a Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 26 Mar 2026 08:48:07 -0400 Subject: [PATCH 2/3] fix(create-cli): update lighthouse.urls type --- packages/create-cli/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index 46db6e5649..c2cd7a82f4 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -69,7 +69,7 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen | Option | Type | Default | Description | | ----------------------------- | ---------------------------------------------------------------- | ----------------------- | ------------------------------- | -| **`--lighthouse.urls`** | `string` | `http://localhost:4200` | Target URL(s) (comma-separated) | +| **`--lighthouse.urls`** | `string \| string[]` | `http://localhost:4200` | Target URL(s) (comma-separated) | | **`--lighthouse.categories`** | `('performance'` \| `'a11y'` \| `'best-practices'` \| `'seo')[]` | all | Lighthouse categories | ### Examples From 30f3a2ca780a85fc0ff3857b680ce696fadc5366 Mon Sep 17 00:00:00 2001 From: hanna-skryl Date: Thu, 26 Mar 2026 09:28:43 -0400 Subject: [PATCH 3/3] feat(plugin-lighthouse): add onlyGroups support --- packages/plugin-lighthouse/src/lib/binding.ts | 29 +++++++++++++++++-- .../src/lib/binding.unit.test.ts | 15 ++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/plugin-lighthouse/src/lib/binding.ts b/packages/plugin-lighthouse/src/lib/binding.ts index 84bd5e5a3a..3da2bfe6b0 100644 --- a/packages/plugin-lighthouse/src/lib/binding.ts +++ b/packages/plugin-lighthouse/src/lib/binding.ts @@ -10,9 +10,11 @@ import { singleQuote, } from '@code-pushup/utils'; import { + LIGHTHOUSE_GROUP_SLUGS, LIGHTHOUSE_PLUGIN_SLUG, LIGHTHOUSE_PLUGIN_TITLE, } from './constants.js'; +import type { LighthouseGroupSlug } from './types.js'; const { name: PACKAGE_NAME } = createRequire(import.meta.url)( '../../package.json', @@ -21,6 +23,13 @@ const { name: PACKAGE_NAME } = createRequire(import.meta.url)( const DEFAULT_URL = 'http://localhost:4200'; const PLUGIN_VAR = 'lhPlugin'; +const CATEGORY_TO_GROUP: Record = { + performance: 'performance', + a11y: 'accessibility', + 'best-practices': 'best-practices', + seo: 'seo', +}; + const CATEGORIES: CategoryCodegenConfig[] = [ { slug: 'performance', @@ -86,7 +95,6 @@ export const lighthouseSetupBinding = { generateConfig: (answers: Record) => { const options = parseAnswers(answers); const hasCategories = options.categories.length > 0; - const formattedUrls = formatUrls(options.urls); const imports = [ { moduleSpecifier: PACKAGE_NAME, @@ -94,17 +102,19 @@ export const lighthouseSetupBinding = { ...(hasCategories ? { namedImports: ['lighthouseGroupRefs'] } : {}), }, ]; + const pluginCall = formatPluginCall(options); + if (!hasCategories) { return { imports, - pluginInit: [`lighthousePlugin(${formattedUrls}),`], + pluginInit: [`${pluginCall},`], }; } return { imports, pluginDeclaration: { identifier: PLUGIN_VAR, - expression: `lighthousePlugin(${formattedUrls})`, + expression: pluginCall, }, pluginInit: [`${PLUGIN_VAR},`], categories: createCategories(options), @@ -121,6 +131,19 @@ function parseAnswers( }; } +function formatPluginCall({ urls, categories }: LighthouseOptions): string { + const formattedUrls = formatUrls(urls); + const groups = categories.flatMap(slug => { + const group = CATEGORY_TO_GROUP[slug]; + return group ? [group] : []; + }); + if (groups.length === 0 || groups.length === LIGHTHOUSE_GROUP_SLUGS.length) { + return `lighthousePlugin(${formattedUrls})`; + } + const onlyGroups = groups.map(singleQuote).join(', '); + return `lighthousePlugin(${formattedUrls}, { onlyGroups: [${onlyGroups}] })`; +} + function formatUrls([first, ...rest]: [string, ...string[]]): string { if (rest.length === 0) { return singleQuote(first); diff --git a/packages/plugin-lighthouse/src/lib/binding.unit.test.ts b/packages/plugin-lighthouse/src/lib/binding.unit.test.ts index 5eacaddf86..86216aea7e 100644 --- a/packages/plugin-lighthouse/src/lib/binding.unit.test.ts +++ b/packages/plugin-lighthouse/src/lib/binding.unit.test.ts @@ -73,6 +73,21 @@ describe('lighthouseSetupBinding', () => { ]); }); + it('should pass onlyGroups when not all categories are selected', () => { + const { pluginDeclaration } = binding.generateConfig({ + ...defaultAnswers, + 'lighthouse.categories': ['performance', 'seo'], + }); + expect(pluginDeclaration!.expression).toContain( + "onlyGroups: ['performance', 'seo']", + ); + }); + + it('should omit onlyGroups when all categories are selected', () => { + const { pluginDeclaration } = binding.generateConfig(defaultAnswers); + expect(pluginDeclaration!.expression).not.toContain('onlyGroups'); + }); + it('should use custom URL in plugin declaration', () => { expect( binding.generateConfig({