diff --git a/packages/create-cli/README.md b/packages/create-cli/README.md index c2cd7a82f..9ed8da78a 100644 --- a/packages/create-cli/README.md +++ b/packages/create-cli/README.md @@ -72,6 +72,15 @@ Each plugin exposes its own configuration keys that can be passed as CLI argumen | **`--lighthouse.urls`** | `string \| string[]` | `http://localhost:4200` | Target URL(s) (comma-separated) | | **`--lighthouse.categories`** | `('performance'` \| `'a11y'` \| `'best-practices'` \| `'seo')[]` | all | Lighthouse categories | +#### Axe + +| Option | Type | Default | Description | +| ----------------------- | ------------------------------------------------------------ | ----------------------- | ------------------------------------------ | +| **`--axe.urls`** | `string \| string[]` | `http://localhost:4200` | Target URL(s) (comma-separated) | +| **`--axe.preset`** | `'wcag21aa'` \| `'wcag22aa'` \| `'best-practice'` \| `'all'` | `wcag21aa` | Accessibility preset | +| **`--axe.setupScript`** | `boolean` | `false` | Create setup script for auth-protected app | +| **`--axe.categories`** | `boolean` | `true` | Add Axe categories | + ### Examples Run interactively (default): diff --git a/packages/create-cli/package.json b/packages/create-cli/package.json index 2677a622f..cce8d076b 100644 --- a/packages/create-cli/package.json +++ b/packages/create-cli/package.json @@ -26,6 +26,7 @@ }, "type": "module", "dependencies": { + "@code-pushup/axe-plugin": "0.124.0", "@code-pushup/coverage-plugin": "0.124.0", "@code-pushup/eslint-plugin": "0.124.0", "@code-pushup/js-packages-plugin": "0.124.0", diff --git a/packages/create-cli/src/index.ts b/packages/create-cli/src/index.ts index b25fd014a..99972c458 100755 --- a/packages/create-cli/src/index.ts +++ b/packages/create-cli/src/index.ts @@ -1,6 +1,7 @@ #! /usr/bin/env node import yargs from 'yargs'; import { hideBin } from 'yargs/helpers'; +import { axeSetupBinding } from '@code-pushup/axe-plugin'; import { coverageSetupBinding } from '@code-pushup/coverage-plugin'; import { eslintSetupBinding } from '@code-pushup/eslint-plugin'; import { jsPackagesSetupBinding } from '@code-pushup/js-packages-plugin'; @@ -15,13 +16,14 @@ import { } from './lib/setup/types.js'; import { runSetupWizard } from './lib/setup/wizard.js'; -// TODO: create, import and pass remaining plugin bindings (jsdocs, axe) +// TODO: create, import and pass remaining plugin bindings (jsdocs) const bindings: PluginSetupBinding[] = [ eslintSetupBinding, coverageSetupBinding, jsPackagesSetupBinding, typescriptSetupBinding, lighthouseSetupBinding, + axeSetupBinding, ]; 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 index ff067d6ac..114a76171 100644 --- a/packages/create-cli/src/lib/setup/codegen-categories.ts +++ b/packages/create-cli/src/lib/setup/codegen-categories.ts @@ -85,6 +85,10 @@ function addCategoryRefs( refsExpressions: MergedCategory['refsExpressions'], depth: number, ): void { + if (refsExpressions.length === 1 && refs.length === 0) { + builder.addLine(`refs: ${refsExpressions[0]},`, depth); + return; + } builder.addLine('refs: [', depth); builder.addLines( refsExpressions.map(expr => `...${expr},`), 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 d7993af87..99075cc60 100644 --- a/packages/create-cli/src/lib/setup/codegen.unit.test.ts +++ b/packages/create-cli/src/lib/setup/codegen.unit.test.ts @@ -490,9 +490,7 @@ describe('generateConfigSource', () => { { slug: 'performance', title: 'Performance', - refs: [ - ...lighthouseGroupRefs(lhPlugin, 'performance'), - ], + refs: lighthouseGroupRefs(lhPlugin, 'performance'), }, ], } satisfies CoreConfig; diff --git a/packages/plugin-axe/src/index.ts b/packages/plugin-axe/src/index.ts index fc6a6c5b6..78e6b1466 100644 --- a/packages/plugin-axe/src/index.ts +++ b/packages/plugin-axe/src/index.ts @@ -12,3 +12,4 @@ export { axeGroupRefs, } from './lib/utils.js'; export { axeCategories } from './lib/categories.js'; +export { axeSetupBinding } from './lib/binding.js'; diff --git a/packages/plugin-axe/src/lib/binding.ts b/packages/plugin-axe/src/lib/binding.ts new file mode 100644 index 000000000..d7af969ea --- /dev/null +++ b/packages/plugin-axe/src/lib/binding.ts @@ -0,0 +1,150 @@ +import { createRequire } from 'node:module'; +import type { + CategoryCodegenConfig, + PluginAnswer, + PluginSetupBinding, + PluginSetupTree, +} from '@code-pushup/models'; +import { + answerBoolean, + answerNonEmptyArray, + answerString, + singleQuote, +} from '@code-pushup/utils'; +import { + AXE_DEFAULT_PRESET, + AXE_PLUGIN_SLUG, + AXE_PLUGIN_TITLE, + AXE_PRESET_NAMES, +} 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 = 'axe'; +const SETUP_SCRIPT_PATH = './axe-setup.ts'; + +const CATEGORIES: CategoryCodegenConfig[] = [ + { + slug: 'a11y', + title: 'Accessibility', + description: 'Tests website **accessibility** in accordance with WCAG', + refsExpression: `axeGroupRefs(${PLUGIN_VAR})`, + }, +]; + +const PRESET_CHOICES = Object.entries(AXE_PRESET_NAMES).map( + ([value, name]) => ({ name, value }), +); + +const SETUP_SCRIPT_CONTENT = `import type { Page } from 'playwright-core'; + +export default async function (page: Page): Promise { + // ... add your custom logic here ... +} +`; + +type AxeOptions = { + urls: [string, ...string[]]; + preset: string; + setupScript: boolean; + categories: boolean; +}; + +export const axeSetupBinding = { + slug: AXE_PLUGIN_SLUG, + title: AXE_PLUGIN_TITLE, + packageName: PACKAGE_NAME, + prompts: async () => [ + { + key: 'axe.urls', + message: 'Target URL(s) (comma-separated)', + type: 'input', + default: DEFAULT_URL, + }, + { + key: 'axe.preset', + message: 'Accessibility preset', + type: 'select', + choices: [...PRESET_CHOICES], + default: AXE_DEFAULT_PRESET, + }, + { + key: 'axe.setupScript', + message: 'Create setup script for auth-protected app?', + type: 'confirm', + default: false, + }, + { + key: 'axe.categories', + message: 'Add Axe categories?', + type: 'confirm', + default: true, + }, + ], + generateConfig: async ( + answers: Record, + tree: PluginSetupTree, + ) => { + const options = parseAnswers(answers); + if (options.setupScript) { + await tree.write(SETUP_SCRIPT_PATH, SETUP_SCRIPT_CONTENT); + } + const hasCategories = options.categories; + const imports = [ + { + moduleSpecifier: PACKAGE_NAME, + defaultImport: 'axePlugin', + ...(hasCategories ? { namedImports: ['axeGroupRefs'] } : {}), + }, + ]; + const pluginCall = formatPluginCall(options); + + if (!hasCategories) { + return { + imports, + pluginInit: [`${pluginCall},`], + }; + } + return { + imports, + pluginDeclaration: { + identifier: PLUGIN_VAR, + expression: pluginCall, + }, + pluginInit: [`${PLUGIN_VAR},`], + categories: CATEGORIES, + }; + }, +} satisfies PluginSetupBinding; + +function parseAnswers(answers: Record): AxeOptions { + return { + urls: answerNonEmptyArray(answers, 'axe.urls', DEFAULT_URL), + preset: answerString(answers, 'axe.preset') || AXE_DEFAULT_PRESET, + setupScript: answerBoolean(answers, 'axe.setupScript'), + categories: answerBoolean(answers, 'axe.categories'), + }; +} + +function formatPluginCall({ urls, preset, setupScript }: AxeOptions): string { + const formattedUrls = formatUrls(urls); + const options = [ + preset !== AXE_DEFAULT_PRESET && `preset: ${singleQuote(preset)}`, + setupScript && `setupScript: ${singleQuote(SETUP_SCRIPT_PATH)}`, + ].filter(Boolean); + + if (options.length === 0) { + return `axePlugin(${formattedUrls})`; + } + return `axePlugin(${formattedUrls}, { ${options.join(', ')} })`; +} + +function formatUrls([first, ...rest]: [string, ...string[]]): string { + if (rest.length === 0) { + return singleQuote(first); + } + return `[${[first, ...rest].map(singleQuote).join(', ')}]`; +} diff --git a/packages/plugin-axe/src/lib/binding.unit.test.ts b/packages/plugin-axe/src/lib/binding.unit.test.ts new file mode 100644 index 000000000..fb2af7ce2 --- /dev/null +++ b/packages/plugin-axe/src/lib/binding.unit.test.ts @@ -0,0 +1,143 @@ +import type { PluginAnswer } from '@code-pushup/models'; +import { createMockTree } from '@code-pushup/test-utils'; +import { axeSetupBinding as binding } from './binding.js'; + +const defaultAnswers: Record = { + 'axe.urls': 'http://localhost:4200', + 'axe.preset': 'wcag21aa', + 'axe.setupScript': false, + 'axe.categories': true, +}; + +const noCategoryAnswers: Record = { + ...defaultAnswers, + 'axe.categories': false, +}; + +describe('axeSetupBinding', () => { + describe('prompts', () => { + it('should offer preset choices with wcag21aa as default', async () => { + await expect(binding.prompts!()).resolves.toIncludeAllPartialMembers([ + { key: 'axe.preset', type: 'select', default: 'wcag21aa' }, + ]); + }); + + it('should default setupScript to false', async () => { + await expect(binding.prompts!()).resolves.toIncludeAllPartialMembers([ + { key: 'axe.setupScript', type: 'confirm', default: false }, + ]); + }); + }); + + describe('generateConfig with categories selected', () => { + it('should declare plugin as a variable for use in category refs', async () => { + const { pluginDeclaration } = await binding.generateConfig( + defaultAnswers, + createMockTree(), + ); + expect(pluginDeclaration).toStrictEqual({ + identifier: 'axe', + expression: "axePlugin('http://localhost:4200')", + }); + }); + + it('should import axeGroupRefs helper', async () => { + const { imports } = await binding.generateConfig( + defaultAnswers, + createMockTree(), + ); + expect(imports).toStrictEqual([ + expect.objectContaining({ namedImports: ['axeGroupRefs'] }), + ]); + }); + + it('should produce accessibility category with refs expression', async () => { + const { categories } = await binding.generateConfig( + defaultAnswers, + createMockTree(), + ); + expect(categories).toStrictEqual([ + expect.objectContaining({ + slug: 'a11y', + refsExpression: 'axeGroupRefs(axe)', + }), + ]); + }); + }); + + describe('generateConfig without categories selected', () => { + it('should not declare plugin as a variable', async () => { + const { pluginDeclaration } = await binding.generateConfig( + noCategoryAnswers, + createMockTree(), + ); + expect(pluginDeclaration).toBeUndefined(); + }); + + it('should not import axeGroupRefs helper', async () => { + const { imports } = await binding.generateConfig( + noCategoryAnswers, + createMockTree(), + ); + expect(imports[0]).not.toHaveProperty('namedImports'); + }); + + it('should not produce categories', async () => { + const { categories } = await binding.generateConfig( + noCategoryAnswers, + createMockTree(), + ); + expect(categories).toBeUndefined(); + }); + }); + + describe('setup script', () => { + it('should write setup script file when confirmed', async () => { + const tree = createMockTree(); + await binding.generateConfig( + { ...defaultAnswers, 'axe.setupScript': true }, + tree, + ); + expect(tree.written.get('./axe-setup.ts')).toContain( + "import type { Page } from 'playwright-core'", + ); + }); + + it('should include setupScript in plugin call when confirmed', async () => { + const { pluginDeclaration } = await binding.generateConfig( + { ...defaultAnswers, 'axe.setupScript': true }, + createMockTree(), + ); + expect(pluginDeclaration!.expression).toContain( + "setupScript: './axe-setup.ts'", + ); + }); + + it('should not write setup script file when declined', async () => { + const tree = createMockTree(); + await binding.generateConfig(defaultAnswers, tree); + expect(tree.written.size).toBe(0); + }); + }); + + it('should include non-default preset in plugin call', async () => { + const { pluginDeclaration } = await binding.generateConfig( + { ...defaultAnswers, 'axe.preset': 'wcag22aa' }, + createMockTree(), + ); + expect(pluginDeclaration!.expression).toContain("preset: 'wcag22aa'"); + }); + + it('should format multiple URLs as array', async () => { + const { pluginDeclaration } = await binding.generateConfig( + { + ...defaultAnswers, + 'axe.urls': 'http://localhost:4200/login, http://localhost:4200/home', + }, + createMockTree(), + ); + expect(pluginDeclaration!.expression).toContain( + "axePlugin(['http://localhost:4200/login', 'http://localhost:4200/home']", + ); + }); +}); diff --git a/packages/plugin-coverage/src/lib/binding.unit.test.ts b/packages/plugin-coverage/src/lib/binding.unit.test.ts index fbb6a183d..eee6b445a 100644 --- a/packages/plugin-coverage/src/lib/binding.unit.test.ts +++ b/packages/plugin-coverage/src/lib/binding.unit.test.ts @@ -1,6 +1,6 @@ import { vol } from 'memfs'; -import type { PluginAnswer, PluginSetupTree } from '@code-pushup/models'; -import { MEMFS_VOLUME } from '@code-pushup/test-utils'; +import type { PluginAnswer } from '@code-pushup/models'; +import { MEMFS_VOLUME, createMockTree } from '@code-pushup/test-utils'; import { readJsonFile } from '@code-pushup/utils'; import { coverageSetupBinding as binding } from './binding.js'; @@ -22,19 +22,6 @@ const defaultAnswers: Record = { 'coverage.categories': true, }; -function createMockTree( - files: Record = {}, -): PluginSetupTree & { written: Map } { - const written = new Map(); - return { - written, - read: async (filePath: string) => files[filePath] ?? null, - write: async (filePath: string, content: string) => { - written.set(filePath, content); - }, - }; -} - describe('coverageSetupBinding', () => { beforeEach(() => { vol.fromJSON({ '.gitkeep': '' }, MEMFS_VOLUME); diff --git a/testing/test-utils/src/index.ts b/testing/test-utils/src/index.ts index 5c4460e85..29e1d6a63 100644 --- a/testing/test-utils/src/index.ts +++ b/testing/test-utils/src/index.ts @@ -12,3 +12,4 @@ export * from './lib/utils/project-graph.js'; export * from './lib/utils/test-folder-setup.js'; export * from './lib/utils/profiler.mock.js'; export * from './lib/utils/omit-trace-json.js'; +export * from './lib/utils/plugin-setup-tree.mock.js'; diff --git a/testing/test-utils/src/lib/utils/plugin-setup-tree.mock.ts b/testing/test-utils/src/lib/utils/plugin-setup-tree.mock.ts new file mode 100644 index 000000000..2361930d9 --- /dev/null +++ b/testing/test-utils/src/lib/utils/plugin-setup-tree.mock.ts @@ -0,0 +1,14 @@ +import type { PluginSetupTree } from '@code-pushup/models'; + +export function createMockTree( + files: Record = {}, +): PluginSetupTree & { written: Map } { + const written = new Map(); + return { + written, + read: async (filePath: string) => files[filePath] ?? null, + write: async (filePath: string, content: string) => { + written.set(filePath, content); + }, + }; +}