diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index a16b1759c..0138ee17f 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -5,7 +5,10 @@ import type { CoreConfig, PluginUrls, } from './packages/models/src/index.js'; -import axePlugin, { axeCategories } from './packages/plugin-axe/src/index.js'; +import axePlugin, { + type AxePluginOptions, + axeCategories, +} from './packages/plugin-axe/src/index.js'; import coveragePlugin, { type CoveragePluginConfig, getNxCoveragePaths, @@ -226,8 +229,11 @@ export async function configureLighthousePlugin( }; } -export function configureAxePlugin(urls: PluginUrls): CoreConfig { - const axe = axePlugin(urls); +export function configureAxePlugin( + urls: PluginUrls, + options?: AxePluginOptions, +): CoreConfig { + const axe = axePlugin(urls, options); return { plugins: [axe], categories: axeCategories(axe), diff --git a/e2e/plugin-axe-e2e/mocks/fixtures/default-setup/code-pushup.config.ts b/e2e/plugin-axe-e2e/mocks/fixtures/code-pushup.config.ts similarity index 100% rename from e2e/plugin-axe-e2e/mocks/fixtures/default-setup/code-pushup.config.ts rename to e2e/plugin-axe-e2e/mocks/fixtures/code-pushup.config.ts diff --git a/e2e/plugin-axe-e2e/mocks/fixtures/default-setup/index.html b/e2e/plugin-axe-e2e/mocks/fixtures/index.html similarity index 100% rename from e2e/plugin-axe-e2e/mocks/fixtures/default-setup/index.html rename to e2e/plugin-axe-e2e/mocks/fixtures/index.html diff --git a/e2e/plugin-axe-e2e/tests/collect.e2e.test.ts b/e2e/plugin-axe-e2e/tests/collect.e2e.test.ts index 2d663c1d7..27ab72bbf 100644 --- a/e2e/plugin-axe-e2e/tests/collect.e2e.test.ts +++ b/e2e/plugin-axe-e2e/tests/collect.e2e.test.ts @@ -7,6 +7,7 @@ import { E2E_ENVIRONMENTS_DIR, TEST_OUTPUT_DIR, omitVariableReportData, + restoreNxIgnoredFiles, teardownTestFolder, } from '@code-pushup/test-utils'; import { executeProcess, readJsonFile } from '@code-pushup/utils'; @@ -22,17 +23,17 @@ function sanitizeReportPaths(report: Report): Report { } describe('PLUGIN collect report with axe-plugin NPM package', () => { + const fixturesDir = path.join('e2e', nxTargetProject(), 'mocks', 'fixtures'); const testFileDir = path.join( E2E_ENVIRONMENTS_DIR, nxTargetProject(), TEST_OUTPUT_DIR, 'collect', ); - const defaultSetupDir = path.join(testFileDir, 'default-setup'); - const fixturesDir = path.join('e2e', nxTargetProject(), 'mocks', 'fixtures'); beforeAll(async () => { await cp(fixturesDir, testFileDir, { recursive: true }); + await restoreNxIgnoredFiles(testFileDir); }); afterAll(async () => { @@ -43,13 +44,13 @@ describe('PLUGIN collect report with axe-plugin NPM package', () => { const { code } = await executeProcess({ command: 'npx', args: ['@code-pushup/cli', 'collect'], - cwd: defaultSetupDir, + cwd: testFileDir, }); expect(code).toBe(0); - const report: Report = await readJsonFile( - path.join(defaultSetupDir, '.code-pushup', 'report.json'), + const report = await readJsonFile( + path.join(testFileDir, '.code-pushup', 'report.json'), ); expect(() => reportSchema.parse(report)).not.toThrow(); diff --git a/package-lock.json b/package-lock.json index f68382160..bba3aaf68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,7 +35,7 @@ "wrap-ansi": "^9.0.2", "yaml": "^2.5.1", "yargs": "^17.7.2", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "devDependencies": { "@actions/core": "^1.11.1", @@ -32404,9 +32404,9 @@ } }, "node_modules/zod": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.5.tgz", - "integrity": "sha512-/5UuuRPStvHXu7RS+gmvRf4NXrNxpSllGwDnCBcJZtQsKrviYXm54yDGV2KYNLT5kq0lHGcl7lqWJLgSaG+tgA==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", + "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 5d8cd0208..76c8c83b5 100644 --- a/package.json +++ b/package.json @@ -45,7 +45,7 @@ "wrap-ansi": "^9.0.2", "yaml": "^2.5.1", "yargs": "^17.7.2", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "devDependencies": { "@actions/core": "^1.11.1", diff --git a/packages/ci/package.json b/packages/ci/package.json index bbf66b799..6106bd059 100644 --- a/packages/ci/package.json +++ b/packages/ci/package.json @@ -33,7 +33,7 @@ "glob": "^11.0.1", "simple-git": "^3.20.0", "yaml": "^2.5.1", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "files": [ "src", diff --git a/packages/models/docs/models-reference.md b/packages/models/docs/models-reference.md index 8fcc6536b..6ce9fd19b 100644 --- a/packages/models/docs/models-reference.md +++ b/packages/models/docs/models-reference.md @@ -29,9 +29,9 @@ _Object containing the following properties:_ | :----------------------- | :----------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`slug`** (\*) | Unique ID (human-readable, URL-safe) | [Slug](#slug) | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | -| `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Documentation site | `''` _or_ `string` (_url_) (_optional_) | | **`scores`** (\*) | Score comparison | _Object with properties:_ | -| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | +| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | | **`values`** (\*) | Audit `value` comparison | _Object with properties:_ | | **`displayValues`** (\*) | Audit `displayValue` comparison | _Object with properties:_ | @@ -69,7 +69,7 @@ _Object containing the following properties:_ | **`slug`** (\*) | Reference to audit | [Slug](#slug) | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | -| `docsUrl` | Link to documentation (rationale) | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Link to documentation (rationale) | `''` _or_ `string` (_url_) (_optional_) | | `isSkipped` | Indicates whether the audit is skipped | `boolean` | | `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` | | **`value`** (\*) | Raw numeric value | `number` (_≥0_) | @@ -87,8 +87,8 @@ _Object containing the following properties:_ | :---------------- | :--------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`slug`** (\*) | Unique ID (human-readable, URL-safe) | [Slug](#slug) | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | -| `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | -| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | +| `docsUrl` | Documentation site | `''` _or_ `string` (_url_) (_optional_) | +| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | | **`score`** (\*) | Value between 0 and 1 | [Score](#score) | | **`value`** (\*) | Raw numeric value | `number` (_≥0_) | | `displayValue` | Formatted value (e.g. '0.9 s', '2.1 MB') | `string` | @@ -104,7 +104,7 @@ _Object containing the following properties:_ | **`slug`** (\*) | ID (unique within plugin) | [Slug](#slug) | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | -| `docsUrl` | Link to documentation (rationale) | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Link to documentation (rationale) | `''` _or_ `string` (_url_) (_optional_) | | `isSkipped` | Indicates whether the audit is skipped | `boolean` | _(\*) Required._ @@ -175,7 +175,7 @@ _Object containing the following properties:_ | **`refs`** (\*) | | _Array of at least 1 [CategoryRef](#categoryref) items_ | | **`title`** (\*) | Category Title | `string` (_max length: 256_) | | `description` | Category description | `string` (_max length: 65536_) | -| `docsUrl` | Category docs URL | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Category docs URL | `''` _or_ `string` (_url_) (_optional_) | | `isSkipped` | | `boolean` | | `scoreTarget` | Pass/fail score threshold (0-1) | `number` (_≥0, ≤1_) | @@ -189,7 +189,7 @@ _Object containing the following properties:_ | :---------------- | :----------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`slug`** (\*) | Unique ID (human-readable, URL-safe) | [Slug](#slug) | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | -| `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Documentation site | `''` _or_ `string` (_url_) (_optional_) | | **`scores`** (\*) | Score comparison | _Object with properties:_ | _(\*) Required._ @@ -215,7 +215,7 @@ _Object containing the following properties:_ | :--------------- | :----------------------------------- | :-------------------------------------- | | **`slug`** (\*) | Unique ID (human-readable, URL-safe) | [Slug](#slug) | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | -| `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Documentation site | `''` _or_ `string` (_url_) (_optional_) | | **`score`** (\*) | Value between 0 and 1 | [Score](#score) | _(\*) Required._ @@ -320,9 +320,9 @@ _Object containing the following properties:_ | :---------------- | :----------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`slug`** (\*) | Unique ID (human-readable, URL-safe) | [Slug](#slug) | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | -| `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Documentation site | `''` _or_ `string` (_url_) (_optional_) | | **`scores`** (\*) | Score comparison | _Object with properties:_ | -| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | +| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | _(\*) Required._ @@ -347,8 +347,8 @@ _Object containing the following properties:_ | :---------------- | :----------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **`slug`** (\*) | Unique ID (human-readable, URL-safe) | [Slug](#slug) | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | -| `docsUrl` | Documentation site | `string` (_url_) (_optional_) _or_ `''` | -| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | +| `docsUrl` | Documentation site | `''` _or_ `string` (_url_) (_optional_) | +| **`plugin`** (\*) | Plugin which defines it | _Object with properties:_ | | **`score`** (\*) | Value between 0 and 1 | [Score](#score) | _(\*) Required._ @@ -363,7 +363,7 @@ _Object containing the following properties:_ | **`refs`** (\*) | | _Array of at least 1 [GroupRef](#groupref) items_ | | **`title`** (\*) | Descriptive name for the group | `string` (_max length: 256_) | | `description` | Description of the group (markdown) | `string` (_max length: 65536_) | -| `docsUrl` | Group documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Group documentation site | `''` _or_ `string` (_url_) (_optional_) | | `isSkipped` | Indicates whether the group is skipped | `boolean` | _(\*) Required._ @@ -1296,7 +1296,7 @@ _Object containing the following properties:_ | `version` | NPM version of the package | `string` | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | -| `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Plugin documentation site | `''` _or_ `string` (_url_) (_optional_) | | `isSkipped` | | `boolean` | | **`slug`** (\*) | Unique plugin slug within core config | [Slug](#slug) | | **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) | @@ -1328,7 +1328,7 @@ _Object containing the following properties:_ | `version` | NPM version of the package | `string` | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | -| `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Plugin documentation site | `''` _or_ `string` (_url_) (_optional_) | | `isSkipped` | | `boolean` | | **`slug`** (\*) | Unique plugin slug within core config | [Slug](#slug) | | **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) | @@ -1345,7 +1345,7 @@ _Object containing the following properties:_ | `version` | NPM version of the package | `string` | | **`title`** (\*) | Descriptive name | `string` (_max length: 256_) | | `description` | Description (markdown) | `string` (_max length: 65536_) | -| `docsUrl` | Plugin documentation site | `string` (_url_) (_optional_) _or_ `''` | +| `docsUrl` | Plugin documentation site | `''` _or_ `string` (_url_) (_optional_) | | `isSkipped` | | `boolean` | | **`slug`** (\*) | Unique plugin slug within core config | [Slug](#slug) | | **`icon`** (\*) | Icon from VSCode Material Icons extension | [MaterialIcon](#materialicon) | diff --git a/packages/models/package.json b/packages/models/package.json index 7d068159e..9a28c7659 100644 --- a/packages/models/package.json +++ b/packages/models/package.json @@ -29,7 +29,7 @@ "dependencies": { "ansis": "^3.3.2", "vscode-material-icons": "^0.1.0", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "files": [ "src", diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index c1845b5dd..29b5299da 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -164,3 +164,4 @@ export { type Tree, } from './lib/tree.js'; export { uploadConfigSchema, type UploadConfig } from './lib/upload-config.js'; +export { convertAsyncZodFunctionToSchema } from './lib/implementation/function.js'; diff --git a/packages/models/src/lib/implementation/schemas.ts b/packages/models/src/lib/implementation/schemas.ts index cad8cb4a1..f1c30f6ab 100644 --- a/packages/models/src/lib/implementation/schemas.ts +++ b/packages/models/src/lib/implementation/schemas.ts @@ -1,12 +1,5 @@ import { MATERIAL_ICONS } from 'vscode-material-icons'; -import { - ZodError, - type ZodIssue, - type ZodObject, - type ZodOptional, - type ZodString, - z, -} from 'zod'; +import { type ZodObject, type ZodOptional, type ZodString, z } from 'zod'; import { MAX_DESCRIPTION_LENGTH, MAX_SLUG_LENGTH, @@ -65,28 +58,24 @@ export const descriptionSchema = z .optional(); /* Schema for a URL */ -export const urlSchema = z.string().url().meta({ title: 'URL' }); +export const urlSchema = z.url().meta({ title: 'URL' }); /** Schema for a docsUrl */ -export const docsUrlSchema = urlSchema +export const docsUrlSchema = z + .union([ + z.literal(''), // allow empty string (no URL validation) + // eslint-disable-next-line unicorn/prefer-top-level-await, unicorn/catch-error-name + urlSchema.optional().catch(ctx => { + const issue = ctx.issues[0]; + if (issue?.code === 'invalid_format' && issue?.format === 'url') { + console.warn(`Ignoring invalid docsUrl: ${ctx.value}`); + return ''; + } + // re-parse to throw formatted error for non-URL issues + return urlSchema.parse(ctx.value); + }), + ]) .optional() - .or(z.literal('')) // allow empty string (no URL validation) - // eslint-disable-next-line unicorn/prefer-top-level-await, unicorn/catch-error-name - .catch(ctx => { - // if only URL validation fails, supress error since this metadata is optional anyway - if ( - ctx.issues.length === 1 && - (ctx.issues[0]?.errors as ZodIssue[][]) - .flat() - .some( - error => error.code === 'invalid_format' && error.format === 'url', - ) - ) { - console.warn(`Ignoring invalid docsUrl: ${ctx.value}`); - return ''; - } - throw new ZodError(ctx.error.issues); - }) .meta({ title: 'DocsUrl', description: 'Documentation site' }); /** Schema for a title of a plugin, category and audit */ diff --git a/packages/models/src/lib/implementation/schemas.unit.test.ts b/packages/models/src/lib/implementation/schemas.unit.test.ts index fd76746f4..49cfa355d 100644 --- a/packages/models/src/lib/implementation/schemas.unit.test.ts +++ b/packages/models/src/lib/implementation/schemas.unit.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; import { type TableCellValue, docsUrlSchema, diff --git a/packages/plugin-axe/README.md b/packages/plugin-axe/README.md index ccb6800fe..74b4d3093 100644 --- a/packages/plugin-axe/README.md +++ b/packages/plugin-axe/README.md @@ -68,9 +68,10 @@ axePlugin(urls: PluginUrls, options?: AxePluginOptions) | Property | Type | Default | Description | | -------------- | ----------- | ------------ | ----------------------------------------- | | `preset` | `AxePreset` | `'wcag21aa'` | Accessibility ruleset preset | +| `setupScript` | `string` | `undefined` | Path to authentication setup script | | `scoreTargets` | `object` | `undefined` | Pass/fail thresholds for audits or groups | -See [Presets](#presets) for the list of available presets and [Preset details](#preset-details) for what each preset includes. +See [Presets](#presets) and [Authentication](#authentication) sections below. ## Multiple URLs @@ -92,6 +93,56 @@ axePlugin({ URLs with higher weights contribute more to overall scores. For example, a URL with weight 3 has three times the influence of a URL with weight 1. +## Authentication + +To test login-protected pages, provide a `setupScript` that authenticates before analysis: + +```ts +axePlugin('https://example.com/dashboard', { + setupScript: './axe-setup.ts', +}); +``` + +The setup script must export a default async function that receives a Playwright `Page` instance: + +```ts +// axe-setup.ts +import type { Page } from 'playwright-core'; + +export default async function (page: Page): Promise { + await page.goto('https://example.com/login'); + await page.fill('#username', process.env.USERNAME); + await page.fill('#password', process.env.PASSWORD); + await page.click('button[type="submit"]'); + await page.waitForURL('**/dashboard'); +} +``` + +The script runs once before analyzing URLs. Authentication state (cookies, localStorage) is automatically shared across all URL analyses. + +
+Alternative: Cookie-based authentication + +If you have a session token, you can inject it directly via cookies: + +```ts +// axe-setup.ts +import type { Page } from 'playwright-core'; + +export default async function (page: Page): Promise { + await page.context().addCookies([ + { + name: 'session_token', + value: process.env.SESSION_TOKEN, + domain: 'example.com', + path: '/', + }, + ]); +} +``` + +
+ ## Presets Choose which accessibility ruleset to test against using the `preset` option: diff --git a/packages/plugin-axe/mocks/fixtures/invalid-setup-no-default.ts b/packages/plugin-axe/mocks/fixtures/invalid-setup-no-default.ts new file mode 100644 index 000000000..40b9f1dfe --- /dev/null +++ b/packages/plugin-axe/mocks/fixtures/invalid-setup-no-default.ts @@ -0,0 +1,5 @@ +import type { Page } from 'playwright-core'; + +export async function setup(page: Page): Promise { + await page.goto('about:blank'); +} diff --git a/packages/plugin-axe/mocks/fixtures/invalid-setup-wrong-type.ts b/packages/plugin-axe/mocks/fixtures/invalid-setup-wrong-type.ts new file mode 100644 index 000000000..a079f5d1f --- /dev/null +++ b/packages/plugin-axe/mocks/fixtures/invalid-setup-wrong-type.ts @@ -0,0 +1 @@ +export default 'not a function'; diff --git a/packages/plugin-axe/mocks/fixtures/valid-setup.ts b/packages/plugin-axe/mocks/fixtures/valid-setup.ts new file mode 100644 index 000000000..45766ac4d --- /dev/null +++ b/packages/plugin-axe/mocks/fixtures/valid-setup.ts @@ -0,0 +1,5 @@ +import type { Page } from 'playwright-core'; + +export default async function setup(page: Page): Promise { + await page.goto('about:blank'); +} diff --git a/packages/plugin-axe/package.json b/packages/plugin-axe/package.json index 3da84e660..003f76639 100644 --- a/packages/plugin-axe/package.json +++ b/packages/plugin-axe/package.json @@ -46,7 +46,7 @@ "@code-pushup/utils": "0.100.1", "axe-core": "^4.11.0", "playwright-core": "^1.56.1", - "zod": "^4.1.12" + "zod": "^4.2.1" }, "files": [ "src", diff --git a/packages/plugin-axe/src/lib/axe-plugin.ts b/packages/plugin-axe/src/lib/axe-plugin.ts index bc390087e..9d867a6ae 100644 --- a/packages/plugin-axe/src/lib/axe-plugin.ts +++ b/packages/plugin-axe/src/lib/axe-plugin.ts @@ -22,7 +22,7 @@ export function axePlugin( urls: PluginUrls, options: AxePluginOptions = {}, ): PluginConfig { - const { preset, scoreTargets, timeout } = validate( + const { preset, scoreTargets, timeout, setupScript } = validate( axePluginOptionsSchema, options, ); @@ -49,7 +49,7 @@ export function axePlugin( version: packageJson.version, audits, groups, - runner: createRunnerFunction(normalizedUrls, ruleIds, timeout), + runner: createRunnerFunction(normalizedUrls, ruleIds, timeout, setupScript), context, ...(scoreTargets && { scoreTargets }), }; diff --git a/packages/plugin-axe/src/lib/categories.ts b/packages/plugin-axe/src/lib/categories.ts index 1150693ec..ea080c880 100644 --- a/packages/plugin-axe/src/lib/categories.ts +++ b/packages/plugin-axe/src/lib/categories.ts @@ -58,6 +58,7 @@ function expandCategories( ); } +/** Creates an aggregated accessibility category from Axe groups. */ export function createAggregatedCategory( groups: Group[], context: PluginUrlContext, @@ -72,6 +73,7 @@ export function createAggregatedCategory( }; } +/** Expands category refs for multiple URLs. */ export function expandAggregatedCategory( category: CategoryConfig, context: PluginUrlContext, @@ -84,6 +86,7 @@ export function expandAggregatedCategory( }; } +/** Extracts unique group slugs from Axe groups. */ export function extractGroupSlugs(groups: Group[]): AxeCategoryGroupSlug[] { const slugs = groups.map(({ slug }) => removeIndex(slug)); return [...new Set(slugs)].filter(isAxeGroupSlug); diff --git a/packages/plugin-axe/src/lib/config.ts b/packages/plugin-axe/src/lib/config.ts index ac7f63be6..5f2b5b6f2 100644 --- a/packages/plugin-axe/src/lib/config.ts +++ b/packages/plugin-axe/src/lib/config.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { + filePathSchema, pluginScoreTargetsSchema, positiveIntSchema, } from '@code-pushup/models'; @@ -21,6 +22,7 @@ export const axePluginOptionsSchema = z description: 'Accessibility ruleset preset (default: wcag21aa for WCAG 2.1 Level AA compliance)', }), + setupScript: filePathSchema.optional(), scoreTargets: pluginScoreTargetsSchema.optional(), timeout: positiveIntSchema.default(DEFAULT_TIMEOUT_MS).meta({ description: diff --git a/packages/plugin-axe/src/lib/config.unit.test.ts b/packages/plugin-axe/src/lib/config.unit.test.ts index 6eb467e8c..b52481633 100644 --- a/packages/plugin-axe/src/lib/config.unit.test.ts +++ b/packages/plugin-axe/src/lib/config.unit.test.ts @@ -17,6 +17,12 @@ describe('axePluginOptionsSchema', () => { }, ); + it('should accept setupScript as a file path', () => { + expect(() => + axePluginOptionsSchema.parse({ setupScript: 'src/axe-setup.js' }), + ).not.toThrow(); + }); + it('should accept scoreTargets as a number between 0 and 1', () => { expect(() => axePluginOptionsSchema.parse({ scoreTargets: 0.99 }), diff --git a/packages/plugin-axe/src/lib/constants.ts b/packages/plugin-axe/src/lib/constants.ts index e5602e51a..a47e70dd7 100644 --- a/packages/plugin-axe/src/lib/constants.ts +++ b/packages/plugin-axe/src/lib/constants.ts @@ -1,12 +1,18 @@ import type { AxePreset } from './config.js'; +/** Unique identifier for the Axe plugin. */ export const AXE_PLUGIN_SLUG = 'axe'; + +/** Display title for the Axe plugin. */ export const AXE_PLUGIN_TITLE = 'Axe'; +/** Default WCAG preset used when none is specified. */ export const AXE_DEFAULT_PRESET = 'wcag21aa'; +/** Default timeout in milliseconds for page operations. */ export const DEFAULT_TIMEOUT_MS = 30_000; +/** Human-readable names for each Axe preset. */ export const AXE_PRESET_NAMES: Record = { wcag21aa: 'WCAG 2.1 AA', wcag22aa: 'WCAG 2.2 AA', diff --git a/packages/plugin-axe/src/lib/groups.ts b/packages/plugin-axe/src/lib/groups.ts index ccbe59dab..3d63600f4 100644 --- a/packages/plugin-axe/src/lib/groups.ts +++ b/packages/plugin-axe/src/lib/groups.ts @@ -27,6 +27,7 @@ const WCAG_PRESET_TAGS: Record = { wcag22aa: ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa', 'wcag22aa'], }; +/** Returns the WCAG tags for a given preset. */ export function getWcagPresetTags(preset: AxeWcagPreset): AxeWcagTag[] { return WCAG_PRESET_TAGS[preset]; } @@ -54,6 +55,7 @@ export const axeCategoryGroupSlugSchema = z export type AxeCategoryGroupSlug = z.infer; +/** Maps category group slugs to human-readable titles. */ export const CATEGORY_GROUPS: Record = { aria: 'ARIA', color: 'Color & Contrast', @@ -70,6 +72,7 @@ export const CATEGORY_GROUPS: Record = { 'time-and-media': 'Media', }; +/** Type guard for valid Axe group slugs. */ export function isAxeGroupSlug(slug: unknown): slug is AxeCategoryGroupSlug { return axeCategoryGroupSlugSchema.safeParse(slug).success; } diff --git a/packages/plugin-axe/src/lib/meta/format.ts b/packages/plugin-axe/src/lib/meta/format.ts index 6c8f3d3f9..ccd7f4efe 100644 --- a/packages/plugin-axe/src/lib/meta/format.ts +++ b/packages/plugin-axe/src/lib/meta/format.ts @@ -1,4 +1,5 @@ import { pluginMetaLogFormatter } from '@code-pushup/utils'; import { AXE_PLUGIN_TITLE } from '../constants.js'; +/** Formats log messages with the Axe plugin prefix. */ export const formatMetaLog = pluginMetaLogFormatter(AXE_PLUGIN_TITLE); diff --git a/packages/plugin-axe/src/lib/meta/processing.ts b/packages/plugin-axe/src/lib/meta/processing.ts index 7e4ddff53..55d951889 100644 --- a/packages/plugin-axe/src/lib/meta/processing.ts +++ b/packages/plugin-axe/src/lib/meta/processing.ts @@ -16,6 +16,7 @@ import { transformRulesToGroups, } from './transform.js'; +/** Loads and processes Axe rules into audits and groups, expanding for multiple URLs if needed. */ export function processAuditsAndGroups( urls: string[], preset: AxePreset, diff --git a/packages/plugin-axe/src/lib/meta/transform.ts b/packages/plugin-axe/src/lib/meta/transform.ts index 0ba7fa695..f4e8ad03e 100644 --- a/packages/plugin-axe/src/lib/meta/transform.ts +++ b/packages/plugin-axe/src/lib/meta/transform.ts @@ -8,11 +8,13 @@ import { getWcagPresetTags, } from '../groups.js'; +/** Loads Axe rules filtered by the specified preset. */ export function loadAxeRules(preset: AxePreset): axe.RuleMetadata[] { const tags = getPresetTags(preset); return tags.length === 0 ? axe.getRules() : axe.getRules(tags); } +/** Transforms Axe rule metadata into Code PushUp audit definitions. */ export function transformRulesToAudits(rules: axe.RuleMetadata[]): Audit[] { return rules.map(rule => ({ slug: rule.ruleId, @@ -22,6 +24,7 @@ export function transformRulesToAudits(rules: axe.RuleMetadata[]): Audit[] { })); } +/** Transforms Axe rules into Code PushUp groups based on accessibility categories. */ export function transformRulesToGroups(rules: axe.RuleMetadata[]): Group[] { const groups = createCategoryGroups(rules); return groups.filter(({ refs }) => refs.length > 0); diff --git a/packages/plugin-axe/src/lib/runner/run-axe.ts b/packages/plugin-axe/src/lib/runner/run-axe.ts index 3e53fe3e0..ccc6712a9 100644 --- a/packages/plugin-axe/src/lib/runner/run-axe.ts +++ b/packages/plugin-axe/src/lib/runner/run-axe.ts @@ -3,7 +3,12 @@ import ansis from 'ansis'; import type { AxeResults } from 'axe-core'; import { createRequire } from 'node:module'; import path from 'node:path'; -import { type Browser, type Page, chromium } from 'playwright-core'; +import { + type Browser, + type BrowserContextOptions, + type Page, + chromium, +} from 'playwright-core'; import type { AuditOutputs } from '@code-pushup/models'; import { executeProcess, @@ -12,13 +17,9 @@ import { logger, pluralizeToken, } from '@code-pushup/utils'; +import { type SetupFunction, runSetup } from './setup.js'; import { toAuditOutputs } from './transform.js'; -/* eslint-disable functional/no-let */ -let browser: Browser | undefined; -let browserChecked = false; -/* eslint-enable functional/no-let */ - export type AxeUrlArgs = { url: string; urlIndex: number; @@ -33,42 +34,127 @@ export type AxeUrlResult = { auditOutputs: AuditOutputs; }; -export async function runAxeForUrl(args: AxeUrlArgs): Promise { - const { url, urlIndex, urlsCount } = args; +/** + * Manages Playwright browser lifecycle and runs Axe accessibility audits. + * Handles browser installation, authentication state, and URL analysis. + */ +export class AxeRunner { + private browser: Browser | undefined; + private browserInstalled = false; + private storageState: BrowserContextOptions['storageState']; + + /** Analyzes a URL for accessibility issues using Axe. */ + async analyzeUrl(args: AxeUrlArgs): Promise { + const browser = await this.launchBrowser(); + const { url, urlIndex, urlsCount } = args; + const prefix = ansis.gray(`[${urlIndex + 1}/${urlsCount}]`); + + return await logger.task(`${prefix} Analyzing URL ${url}`, async () => { + const context = await browser.newContext({ + ...(this.storageState && { storageState: this.storageState }), + }); - if (!browser) { - await ensureBrowserInstalled(); - browser = await logger.task('Launching Chromium browser', async () => ({ - message: 'Launched Chromium browser', - result: await chromium.launch({ headless: true }), - })); + try { + const page = await context.newPage(); + try { + const axeResults = await analyzePage(page, args); + const auditOutputs = toAuditOutputs(axeResults, url); + return { + message: `${prefix} Analyzed URL ${url}`, + result: { url, axeResults, auditOutputs }, + }; + } finally { + await page.close(); + } + } finally { + await context.close(); + } + }); } - const prefix = ansis.gray(`[${urlIndex + 1}/${urlsCount}]`); - - return await logger.task(`${prefix} Analyzing URL ${url}`, async () => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const context = await browser!.newContext(); + /** Runs setup script and captures authentication state for reuse. */ + async captureAuthState( + setupFn: SetupFunction, + timeout: number, + ): Promise { + const browser = await this.launchBrowser(); + const context = await browser.newContext(); + const page = await context.newPage(); try { - const page = await context.newPage(); - try { - const axeResults = await runAxeForPage(page, args); - const auditOutputs = toAuditOutputs(axeResults, url); - return { - message: `${prefix} Analyzed URL ${url}`, - result: { url, axeResults, auditOutputs }, - }; - } finally { - await page.close(); - } + page.setDefaultTimeout(timeout); + await runSetup(setupFn, page); + this.storageState = await context.storageState(); + logger.debug('Captured authentication state from setup script'); } finally { + await page.close(); await context.close(); + logger.debug('Closed setup context'); } - }); + } + + /** Closes the browser and clears authentication state. */ + async close(): Promise { + this.storageState = undefined; + this.browserInstalled = false; + + if (this.browser) { + await this.browser.close(); + this.browser = undefined; + logger.debug('Closed Chromium browser'); + } + } + + /** + * Ensures Chromium browser binary is installed before running accessibility audits. + * + * Uses Node's module resolution and npm's bin specification to locate playwright-core CLI, + * working reliably with all package managers (npm, pnpm, yarn). + */ + private async installBrowser(): Promise { + if (this.browserInstalled) { + return; + } + + logger.debug('Checking Chromium browser installation ...'); + + const require = createRequire(import.meta.url); + const pkgPath = require.resolve('playwright-core/package.json'); + const pkg = require(pkgPath); + const cliPath = path.join( + path.dirname(pkgPath), + pkg.bin['playwright-core'], + ); + + await executeProcess({ + command: 'node', + args: [cliPath, 'install', 'chromium'], + }); + + this.browserInstalled = true; + } + + /** Lazily launches or returns existing Chromium browser instance. */ + private async launchBrowser(): Promise { + if (this.browser) { + return this.browser; + } + + await this.installBrowser(); + + this.browser = await logger.task( + 'Launching Chromium browser', + async () => ({ + message: 'Launched Chromium browser', + result: await chromium.launch({ headless: true }), + }), + ); + + return this.browser; + } } -async function runAxeForPage( +async function analyzePage( page: Page, { url, ruleIds, timeout }: AxeUrlArgs, ): Promise { @@ -120,37 +206,3 @@ async function runAxeForPage( return results; } - -export async function closeBrowser(): Promise { - if (browser) { - await browser.close(); - browser = undefined; - logger.debug('Closed Chromium browser'); - } -} - -/** - * Ensures Chromium browser binary is installed before running accessibility audits. - * - * Uses Node's module resolution and npm's bin specification to locate playwright-core CLI, - * working reliably with all package managers (npm, pnpm, yarn). - */ -async function ensureBrowserInstalled(): Promise { - if (browserChecked) { - return; - } - - logger.debug('Checking Chromium browser installation ...'); - - const require = createRequire(import.meta.url); - const pkgPath = require.resolve('playwright-core/package.json'); - const pkg = require(pkgPath); - const cliPath = path.join(path.dirname(pkgPath), pkg.bin['playwright-core']); - - await executeProcess({ - command: 'node', - args: [cliPath, 'install', 'chromium'], - }); - - browserChecked = true; -} diff --git a/packages/plugin-axe/src/lib/runner/runner.ts b/packages/plugin-axe/src/lib/runner/runner.ts index d83ad4edf..8a3498b6c 100644 --- a/packages/plugin-axe/src/lib/runner/runner.ts +++ b/packages/plugin-axe/src/lib/runner/runner.ts @@ -8,19 +8,18 @@ import { shouldExpandForUrls, stringifyError, } from '@code-pushup/utils'; -import { - type AxeUrlArgs, - type AxeUrlResult, - closeBrowser, - runAxeForUrl, -} from './run-axe.js'; +import { AxeRunner, type AxeUrlArgs, type AxeUrlResult } from './run-axe.js'; +import { loadSetupScript } from './setup.js'; +/** Creates a runner function that executes Axe accessibility audits for given URLs. */ export function createRunnerFunction( urls: string[], ruleIds: string[], timeout: number, + setupScript?: string, ): RunnerFunction { return async (): Promise => { + const runner = new AxeRunner(); const urlsCount = urls.length; logger.info( @@ -28,10 +27,15 @@ export function createRunnerFunction( ); try { + if (setupScript) { + const setupFn = await loadSetupScript(setupScript); + await runner.captureAuthState(setupFn, timeout); + } + const results = await asyncSequential( urls, async (url, urlIndex): Promise => - runForUrl({ urlsCount, ruleIds, timeout, url, urlIndex }), + runForUrl(runner, { urlsCount, ruleIds, timeout, url, urlIndex }), ); const collectedResults = results.filter(res => res != null); @@ -48,15 +52,18 @@ export function createRunnerFunction( return auditOutputs; } finally { - await closeBrowser(); + await runner.close(); } }; } -async function runForUrl(args: AxeUrlArgs): Promise { +async function runForUrl( + runner: AxeRunner, + args: AxeUrlArgs, +): Promise { const { url, urlsCount, urlIndex } = args; try { - const result = await runAxeForUrl(args); + const result = await runner.analyzeUrl(args); if (shouldExpandForUrls(urlsCount)) { return { diff --git a/packages/plugin-axe/src/lib/runner/runner.unit.test.ts b/packages/plugin-axe/src/lib/runner/runner.unit.test.ts index 62f782a93..b161b9796 100644 --- a/packages/plugin-axe/src/lib/runner/runner.unit.test.ts +++ b/packages/plugin-axe/src/lib/runner/runner.unit.test.ts @@ -1,17 +1,29 @@ import type { AxeResults, IncompleteResult, Result } from 'axe-core'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type AuditOutput, DEFAULT_PERSIST_CONFIG } from '@code-pushup/models'; -import * as runAxe from './run-axe.js'; +import type { AxeUrlResult } from './run-axe.js'; import { createRunnerFunction } from './runner.js'; +import * as setup from './setup.js'; + +const mockAnalyzeUrl = vi.fn(); +const mockClose = vi.fn(); +const mockCaptureAuthState = vi.fn(); vi.mock('./run-axe.js', () => ({ - runAxeForUrl: vi.fn(), - closeBrowser: vi.fn(), + AxeRunner: vi.fn().mockImplementation(() => ({ + analyzeUrl: mockAnalyzeUrl, + close: mockClose, + captureAuthState: mockCaptureAuthState, + })), +})); + +vi.mock('./setup.js', () => ({ + loadSetupScript: vi.fn(), })); describe('createRunnerFunction', () => { - const mockRunAxeForUrl = vi.mocked(runAxe.runAxeForUrl); - const mockCloseBrowser = vi.mocked(runAxe.closeBrowser); + const mockLoadSetupScript = vi.mocked(setup.loadSetupScript); + const mockSetupFn = vi.fn<[], Promise>(); beforeEach(() => { vi.clearAllMocks(); @@ -31,7 +43,7 @@ describe('createRunnerFunction', () => { } as AxeResults; it('should handle single URL without adding index to audit slugs', async () => { - const mockResult: runAxe.AxeUrlResult = { + const mockResult: AxeUrlResult = { url: 'https://example.com', axeResults: mockAxeResults, auditOutputs: [ @@ -39,24 +51,24 @@ describe('createRunnerFunction', () => { createMockAuditOutput('html-has-lang'), ], }; - mockRunAxeForUrl.mockResolvedValue(mockResult); + mockAnalyzeUrl.mockResolvedValue(mockResult); const runnerFn = createRunnerFunction(['https://example.com'], [], 30_000); const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); - expect(mockRunAxeForUrl).toHaveBeenCalledWith({ + expect(mockAnalyzeUrl).toHaveBeenCalledWith({ url: 'https://example.com', urlIndex: 0, urlsCount: 1, ruleIds: [], timeout: 30_000, }); - expect(mockCloseBrowser).toHaveBeenCalled(); + expect(mockClose).toHaveBeenCalled(); expect(results).toEqual(mockResult.auditOutputs); }); it('should handle multiple URLs and add index to audit slugs', async () => { - const mockResult1: runAxe.AxeUrlResult = { + const mockResult1: AxeUrlResult = { url: 'https://example.com', axeResults: mockAxeResults, auditOutputs: [ @@ -64,7 +76,7 @@ describe('createRunnerFunction', () => { createMockAuditOutput('html-has-lang'), ], }; - const mockResult2: runAxe.AxeUrlResult = { + const mockResult2: AxeUrlResult = { url: 'https://another-example.org', axeResults: mockAxeResults, auditOutputs: [ @@ -73,7 +85,7 @@ describe('createRunnerFunction', () => { ], }; - mockRunAxeForUrl + mockAnalyzeUrl .mockResolvedValueOnce(mockResult1) .mockResolvedValueOnce(mockResult2); @@ -84,22 +96,22 @@ describe('createRunnerFunction', () => { ); const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); - expect(mockRunAxeForUrl).toHaveBeenCalledTimes(2); - expect(mockRunAxeForUrl).toHaveBeenNthCalledWith(1, { + expect(mockAnalyzeUrl).toHaveBeenCalledTimes(2); + expect(mockAnalyzeUrl).toHaveBeenNthCalledWith(1, { url: 'https://example.com', urlIndex: 0, urlsCount: 2, ruleIds: [], timeout: 30_000, - } satisfies runAxe.AxeUrlArgs); - expect(mockRunAxeForUrl).toHaveBeenNthCalledWith(2, { + }); + expect(mockAnalyzeUrl).toHaveBeenNthCalledWith(2, { url: 'https://another-example.org', urlIndex: 1, urlsCount: 2, ruleIds: [], timeout: 30_000, - } satisfies runAxe.AxeUrlArgs); - expect(mockCloseBrowser).toHaveBeenCalled(); + }); + expect(mockClose).toHaveBeenCalled(); expect(results).toBeArrayOfSize(4); expect(results.map(({ slug }) => slug)).toEqual([ @@ -111,7 +123,7 @@ describe('createRunnerFunction', () => { }); it('should run only specified rules when ruleIds filter is provided', async () => { - const mockResult: runAxe.AxeUrlResult = { + const mockResult: AxeUrlResult = { url: 'https://example.com', axeResults: mockAxeResults, auditOutputs: [ @@ -119,7 +131,7 @@ describe('createRunnerFunction', () => { createMockAuditOutput('html-has-lang'), ], }; - mockRunAxeForUrl.mockResolvedValue(mockResult); + mockAnalyzeUrl.mockResolvedValue(mockResult); const ruleIds = ['image-alt', 'html-has-lang']; const runnerFn = createRunnerFunction( @@ -129,7 +141,7 @@ describe('createRunnerFunction', () => { ); const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); - expect(mockRunAxeForUrl).toHaveBeenCalledWith({ + expect(mockAnalyzeUrl).toHaveBeenCalledWith({ url: 'https://example.com', urlIndex: 0, urlsCount: 1, @@ -140,13 +152,13 @@ describe('createRunnerFunction', () => { }); it('should continue with other URLs when one fails in multiple URL scenario', async () => { - const mockResult: runAxe.AxeUrlResult = { + const mockResult: AxeUrlResult = { url: 'https://working.com', axeResults: mockAxeResults, auditOutputs: [createMockAuditOutput('image-alt')], }; - mockRunAxeForUrl + mockAnalyzeUrl .mockRejectedValueOnce(new Error('Failed to load page')) .mockResolvedValueOnce(mockResult); @@ -157,14 +169,14 @@ describe('createRunnerFunction', () => { ); const results = await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); - expect(mockRunAxeForUrl).toHaveBeenCalledTimes(2); - expect(mockCloseBrowser).toHaveBeenCalled(); + expect(mockAnalyzeUrl).toHaveBeenCalledTimes(2); + expect(mockClose).toHaveBeenCalled(); expect(results).toBeArrayOfSize(1); expect(results[0]!.slug).toBe('image-alt-2'); }); it('should throw error if all URLs fail in multiple URL scenario', async () => { - mockRunAxeForUrl.mockRejectedValue(new Error('Failed to load page')); + mockAnalyzeUrl.mockRejectedValue(new Error('Failed to load page')); const runnerFn = createRunnerFunction( ['https://example.com', 'https://another-example.com'], @@ -178,7 +190,7 @@ describe('createRunnerFunction', () => { }); it('should throw error when single URL fails', async () => { - mockRunAxeForUrl.mockRejectedValue(new Error('Failed to load page')); + mockAnalyzeUrl.mockRejectedValue(new Error('Failed to load page')); const runnerFn = createRunnerFunction(['https://example.com'], [], 30_000); @@ -186,4 +198,39 @@ describe('createRunnerFunction', () => { 'Axe did not produce any results.', ); }); + + it('should run setup when setupScript is provided', async () => { + mockLoadSetupScript.mockResolvedValue(mockSetupFn); + mockAnalyzeUrl.mockResolvedValue({ + url: 'https://example.com', + axeResults: mockAxeResults, + auditOutputs: [createMockAuditOutput('image-alt')], + }); + + const runnerFn = createRunnerFunction( + ['https://example.com'], + [], + 30_000, + './setup.ts', + ); + await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); + + expect(mockLoadSetupScript).toHaveBeenCalledWith('./setup.ts'); + expect(mockCaptureAuthState).toHaveBeenCalledOnce(); + expect(mockCaptureAuthState).toHaveBeenCalledWith(mockSetupFn, 30_000); + }); + + it('should skip setup when setupScript is undefined', async () => { + mockAnalyzeUrl.mockResolvedValue({ + url: 'https://example.com', + axeResults: mockAxeResults, + auditOutputs: [createMockAuditOutput('image-alt')], + }); + + const runnerFn = createRunnerFunction(['https://example.com'], [], 30_000); + await runnerFn({ persist: DEFAULT_PERSIST_CONFIG }); + + expect(mockLoadSetupScript).not.toHaveBeenCalled(); + expect(mockCaptureAuthState).not.toHaveBeenCalled(); + }); }); diff --git a/packages/plugin-axe/src/lib/runner/setup.int.test.ts b/packages/plugin-axe/src/lib/runner/setup.int.test.ts new file mode 100644 index 000000000..3c69afb8e --- /dev/null +++ b/packages/plugin-axe/src/lib/runner/setup.int.test.ts @@ -0,0 +1,58 @@ +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import type { Page } from 'playwright-core'; +import { describe, expect, it, vi } from 'vitest'; +import { loadSetupScript, runSetup } from './setup.js'; + +describe('loadSetupScript integration', () => { + const fixturesDir = path.join( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + '..', + 'mocks', + 'fixtures', + ); + + it('should load a valid setup script with default export', async () => { + const scriptPath = path.join(fixturesDir, 'valid-setup.ts'); + + const setupFn = await loadSetupScript(scriptPath); + + expect(typeof setupFn).toBe('function'); + }); + + it('should execute loaded setup script with runSetup', async () => { + const scriptPath = path.join(fixturesDir, 'valid-setup.ts'); + const mockPage = { goto: vi.fn() } as unknown as Page; + + const setupFn = await loadSetupScript(scriptPath); + await runSetup(setupFn, mockPage); + + expect(mockPage.goto).toHaveBeenCalledWith('about:blank'); + }); + + it('should throw error for setup script without default export', async () => { + const scriptPath = path.join(fixturesDir, 'invalid-setup-no-default.ts'); + + await expect(loadSetupScript(scriptPath)).rejects.toThrow( + /Invalid.*SetupScriptModule/, + ); + }); + + it('should throw error for setup script with non-function default export', async () => { + const scriptPath = path.join(fixturesDir, 'invalid-setup-wrong-type.ts'); + + await expect(loadSetupScript(scriptPath)).rejects.toThrow( + /Invalid.*SetupScriptModule/, + ); + }); + + it('should throw error for non-existent setup script', async () => { + const scriptPath = path.join(fixturesDir, 'non-existent.ts'); + + await expect(loadSetupScript(scriptPath)).rejects.toThrow( + /Setup script not found/, + ); + }); +}); diff --git a/packages/plugin-axe/src/lib/runner/setup.ts b/packages/plugin-axe/src/lib/runner/setup.ts new file mode 100644 index 000000000..7a17ff669 --- /dev/null +++ b/packages/plugin-axe/src/lib/runner/setup.ts @@ -0,0 +1,60 @@ +import path from 'node:path'; +import type { Page } from 'playwright-core'; +import { z } from 'zod/v4'; +import { + convertAsyncZodFunctionToSchema, + validateAsync, +} from '@code-pushup/models'; +import { fileExists, logger } from '@code-pushup/utils'; + +const setupFunctionSchema = convertAsyncZodFunctionToSchema( + z.function({ + input: [z.custom(val => val != null && typeof val === 'object')], + output: z.void(), + }), +).meta({ + title: 'SetupFunction', + description: 'Async function that authenticates using a Playwright Page', +}); +export type SetupFunction = z.infer; + +const setupScriptModuleSchema = z + .object({ default: setupFunctionSchema }) + .meta({ + title: 'SetupScriptModule', + description: + 'ES module with a default export containing the authentication setup function', + }); + +/** Loads and validates a setup script module from the given path. */ +export async function loadSetupScript( + setupScript: string, +): Promise { + const absolutePath = path.isAbsolute(setupScript) + ? setupScript + : path.join(process.cwd(), setupScript); + + logger.debug(`Resolved setup script path: ${absolutePath}`); + + if (!(await fileExists(absolutePath))) { + throw new Error(`Setup script not found: ${absolutePath}`); + } + + const module: unknown = await import(absolutePath); + const validModule = await validateAsync(setupScriptModuleSchema, module, { + filePath: absolutePath, + }); + + return validModule.default; +} + +/** Executes the setup function with the provided Playwright page. */ +export async function runSetup( + setupFn: SetupFunction, + page: Page, +): Promise { + await logger.task('Running authentication setup script', async () => { + await setupFn(page); + return { message: 'Authentication setup completed', result: undefined }; + }); +} diff --git a/packages/plugin-axe/src/lib/utils.ts b/packages/plugin-axe/src/lib/utils.ts index 15639fe13..170876dd2 100644 --- a/packages/plugin-axe/src/lib/utils.ts +++ b/packages/plugin-axe/src/lib/utils.ts @@ -2,6 +2,7 @@ import type { CategoryRef } from '@code-pushup/models'; import { AXE_PLUGIN_SLUG } from './constants.js'; import type { AxeGroupSlug } from './groups.js'; +/** Creates a category ref to an Axe group. */ export function axeGroupRef(groupSlug: AxeGroupSlug, weight = 1): CategoryRef { return { plugin: AXE_PLUGIN_SLUG, @@ -11,6 +12,7 @@ export function axeGroupRef(groupSlug: AxeGroupSlug, weight = 1): CategoryRef { }; } +/** Creates a category ref to an Axe audit. */ export function axeAuditRef(auditSlug: string, weight = 1): CategoryRef { return { plugin: AXE_PLUGIN_SLUG, diff --git a/packages/plugin-axe/tsconfig.test.json b/packages/plugin-axe/tsconfig.test.json index afd7751ec..cc72e634f 100644 --- a/packages/plugin-axe/tsconfig.test.json +++ b/packages/plugin-axe/tsconfig.test.json @@ -8,6 +8,7 @@ "vitest.unit.config.ts", "vitest.int.config.ts", "src/**/*.test.ts", + "mocks/**/*.ts", "../../testing/test-setup/src/vitest.d.ts" ] } diff --git a/packages/plugin-coverage/package.json b/packages/plugin-coverage/package.json index 810f18ac7..0f74814a3 100644 --- a/packages/plugin-coverage/package.json +++ b/packages/plugin-coverage/package.json @@ -37,7 +37,7 @@ "@code-pushup/models": "0.100.1", "@code-pushup/utils": "0.100.1", "parse-lcov": "^1.0.4", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "peerDependencies": { "@nx/devkit": ">=17.0.0", diff --git a/packages/plugin-eslint/package.json b/packages/plugin-eslint/package.json index 7d68f59ca..3c8d6015c 100644 --- a/packages/plugin-eslint/package.json +++ b/packages/plugin-eslint/package.json @@ -42,7 +42,7 @@ "glob": "^11.0.0", "@code-pushup/utils": "0.100.1", "@code-pushup/models": "0.100.1", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "peerDependencies": { "@nx/devkit": ">=17.0.0", diff --git a/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts b/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts index 34ef4628f..ac1aae032 100644 --- a/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts +++ b/packages/plugin-eslint/src/lib/eslint-plugin.int.test.ts @@ -4,7 +4,15 @@ import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { fileURLToPath } from 'node:url'; -import type { MockInstance } from 'vitest'; +import { + type MockInstance, + afterAll, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; import type { Audit } from '@code-pushup/models'; import { restoreNxIgnoredFiles, @@ -130,7 +138,7 @@ describe('eslintPlugin', () => { groups: [{ slug: 'type-safety', title: 'Type safety', rules: [] }], }, ), - ).rejects.toThrow('Invalid input'); + ).rejects.toThrow(`Invalid ${ansis.bold('ESLintPluginOptions')}`); await expect( eslintPlugin( { @@ -141,7 +149,7 @@ describe('eslintPlugin', () => { groups: [{ slug: 'type-safety', title: 'Type safety', rules: {} }], }, ), - ).rejects.toThrow('Invalid input'); + ).rejects.toThrow(`Invalid ${ansis.bold('ESLintPluginOptions')}`); }); it('should throw when invalid parameters provided', async () => { diff --git a/packages/plugin-js-packages/package.json b/packages/plugin-js-packages/package.json index 1f530545e..95e8d9ece 100644 --- a/packages/plugin-js-packages/package.json +++ b/packages/plugin-js-packages/package.json @@ -42,7 +42,7 @@ "ansis": "^3.3.2", "build-md": "^0.4.1", "semver": "^7.6.0", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "files": [ "src", diff --git a/packages/plugin-jsdocs/package.json b/packages/plugin-jsdocs/package.json index 0347318c8..205e68cea 100644 --- a/packages/plugin-jsdocs/package.json +++ b/packages/plugin-jsdocs/package.json @@ -37,7 +37,7 @@ "dependencies": { "@code-pushup/models": "0.100.1", "@code-pushup/utils": "0.100.1", - "zod": "^4.0.5", + "zod": "^4.2.1", "ts-morph": "^24.0.0" }, "files": [ diff --git a/packages/plugin-typescript/package.json b/packages/plugin-typescript/package.json index 3214f237a..153331ae6 100644 --- a/packages/plugin-typescript/package.json +++ b/packages/plugin-typescript/package.json @@ -25,7 +25,7 @@ "dependencies": { "@code-pushup/models": "0.100.1", "@code-pushup/utils": "0.100.1", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "peerDependencies": { "typescript": ">=4.0.0" diff --git a/packages/utils/package.json b/packages/utils/package.json index 037203a7c..df780992c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -37,7 +37,7 @@ "simple-git": "^3.20.0", "string-width": "^8.1.0", "wrap-ansi": "^9.0.2", - "zod": "^4.0.5" + "zod": "^4.2.1" }, "files": [ "src",