diff --git a/build.config.test-utils.ts b/build.config.test-utils.ts new file mode 100644 index 0000000..4057b28 --- /dev/null +++ b/build.config.test-utils.ts @@ -0,0 +1,18 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + entries: [ + 'src/test-utils/index', + 'src/test-utils/setup', + 'src/test-utils/browser', + ], + outDir: 'dist', + clean: false, + declaration: 'node16', + externals: [ + 'vitest', + 'playwright-core', + 'axe-core', + 'linkedom', + ], +}) diff --git a/build.config.ts b/build.config.ts new file mode 100644 index 0000000..2533814 --- /dev/null +++ b/build.config.ts @@ -0,0 +1,13 @@ +import { defineBuildConfig } from 'unbuild' + +export default defineBuildConfig({ + hooks: { + 'build:done'(ctx) { + for (const warning of ctx.warnings) { + if (warning.includes('test-utils')) { + ctx.warnings.delete(warning) + } + } + }, + }, +}) diff --git a/package.json b/package.json index f940e3f..eedc14c 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,18 @@ ".": { "types": "./dist/types.d.mts", "import": "./dist/module.mjs" + }, + "./test-utils": { + "types": "./dist/test-utils/index.d.mts", + "import": "./dist/test-utils/index.mjs" + }, + "./test-utils/setup": { + "types": "./dist/test-utils/setup.d.mts", + "import": "./dist/test-utils/setup.mjs" + }, + "./test-utils/browser": { + "types": "./dist/test-utils/browser.d.mts", + "import": "./dist/test-utils/browser.mjs" } }, "main": "./dist/module.mjs", @@ -17,6 +29,15 @@ "*": { ".": [ "./dist/types.d.mts" + ], + "test-utils": [ + "./dist/test-utils/index.d.mts" + ], + "test-utils/setup": [ + "./dist/test-utils/setup.d.mts" + ], + "test-utils/browser": [ + "./dist/test-utils/browser.d.mts" ] } }, @@ -24,7 +45,8 @@ "dist" ], "scripts": { - "build": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt-module-build build && npm run client:build", + "build": "nuxt-module-build build --stub && nuxt-module-build prepare && nuxt-module-build build && npm run build:test-utils && npm run client:build", + "build:test-utils": "unbuild --config build.config.test-utils", "client:build": "nuxt generate client", "client:dev": "nuxt dev client --port 3030", "dev": "npm run play:dev", @@ -45,6 +67,18 @@ "linkedom": "^0.18.12", "sirv": "^3.0.2" }, + "peerDependencies": { + "vitest": ">=1.0.0", + "playwright-core": ">=1.0.0" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + }, + "playwright-core": { + "optional": true + } + }, "devDependencies": { "@iconify-json/carbon": "^1.2.18", "@nuxt/devtools": "latest", @@ -62,6 +96,7 @@ "pkg-pr-new": "0.0.63", "playwright-core": "1.58.2", "typescript": "~5.9.3", + "unbuild": "^3.6.1", "vite": "7.3.1", "vitest": "4.0.18", "vue-tsc": "3.2.5" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 532881e..7231618 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: typescript: specifier: ~5.9.3 version: 5.9.3 + unbuild: + specifier: ^3.6.1 + version: 3.6.1(typescript@5.9.3)(vue-sfc-transformer@0.1.17(@vue/compiler-core@3.5.29)(esbuild@0.27.3)(vue@3.5.29(typescript@5.9.3)))(vue-tsc@3.2.5(typescript@5.9.3))(vue@3.5.29(typescript@5.9.3)) vite: specifier: 7.3.1 version: 7.3.1(@types/node@24.10.13)(jiti@2.6.1)(terser@5.46.0)(yaml@2.8.2) diff --git a/src/test-utils/auto-scan.ts b/src/test-utils/auto-scan.ts new file mode 100644 index 0000000..e9df51d --- /dev/null +++ b/src/test-utils/auto-scan.ts @@ -0,0 +1,113 @@ +import type { AutoScanOptions, ScanResult } from './types' +import { runA11yScan } from './index' + +/** + * Create a multi-route auto-scan utility that accumulates accessibility + * scan results across multiple pages and supports threshold-based failure. + * + * @param options - Configuration for route exclusion and violation threshold + * @returns An object with methods to scan HTML, retrieve results, and check thresholds + * + * @example + * ```ts + * import { $fetch } from '@nuxt/test-utils' + * import { createAutoScan } from '@nuxt/a11y/test-utils' + * + * const autoScan = createAutoScan({ threshold: 10, exclude: ['/admin'] }) + * + * for (const route of ['/', '/about', '/contact']) { + * const html = await $fetch(route, { responseType: 'text' }) + * await autoScan.scanFetchedHtml(route, html) + * } + * + * const results = autoScan.getResults() + * if (autoScan.exceedsThreshold()) { + * console.error('Too many accessibility violations!') + * } + * ``` + */ +export function createAutoScan(options: AutoScanOptions = {}) { + const { exclude = [], threshold = 0 } = options + const results = new Map() + + function normalizeUrl(url: string): string { + try { + const parsed = new URL(url, 'http://nuxt-a11y.local') + return `${parsed.pathname}${parsed.search}` + } + catch { + return url + } + } + + function isExcluded(url: string): boolean { + const normalizedUrl = normalizeUrl(url) + return exclude.some(pattern => + typeof pattern === 'string' ? normalizedUrl.includes(pattern) : pattern.test(normalizedUrl), + ) + } + + function mergeResult(url: string, result: ScanResult): void { + const key = normalizeUrl(url) + const existing = results.get(key) + if (!existing) { + results.set(key, result) + return + } + + results.set(key, { + ...result, + violations: [...existing.violations, ...result.violations], + violationCount: existing.violationCount + result.violationCount, + getByImpact: level => [...existing.violations, ...result.violations].filter(v => v.impact === level), + getByRule: ruleId => [...existing.violations, ...result.violations].filter(v => v.id === ruleId), + getByTag: tag => [...existing.violations, ...result.violations].filter(v => v.tags.includes(tag)), + }) + } + + return { + /** + * Scan fetched HTML for a given URL and accumulate results. + * Excluded URLs are silently skipped. + * + * @param url - The route URL being scanned + * @param html - The HTML content to scan + */ + async scanFetchedHtml(url: string, html: string): Promise { + if (isExcluded(url)) + return + + const normalizedUrl = normalizeUrl(url) + const result = await runA11yScan(html, { route: normalizedUrl }) + mergeResult(normalizedUrl, result) + }, + + addResult(url: string, result: ScanResult): void { + if (isExcluded(url)) + return + mergeResult(url, result) + }, + + /** + * Get all accumulated scan results keyed by URL. + * + * @returns A record mapping URLs to their scan results + */ + getResults(): Record { + return Object.fromEntries(results) + }, + + /** + * Check if the total violation count exceeds the configured threshold. + * + * @returns `true` if total violations exceed the threshold, `false` otherwise + */ + exceedsThreshold(): boolean { + let total = 0 + for (const result of results.values()) { + total += result.violationCount + } + return total > threshold + }, + } +} diff --git a/src/test-utils/browser.ts b/src/test-utils/browser.ts new file mode 100644 index 0000000..d7381ec --- /dev/null +++ b/src/test-utils/browser.ts @@ -0,0 +1,309 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import type { Page } from 'playwright-core' +import type axe from 'axe-core' +import type { A11yViolation, A11yViolationNode } from '../runtime/types' +import type { ObservePageOptions, RunAxeOnPageOptions, ScanResult } from './types' +import { createScanResult } from './index' + +const require = createRequire(import.meta.url) + +interface RawAxeNode { + target: axe.NodeResult['target'] + html: string + failureSummary: string | undefined +} + +interface RawAxeViolation { + id: string + impact: axe.ImpactValue | undefined + help: string + helpUrl: string + description: string + tags: axe.TagValue[] + nodes: RawAxeNode[] +} + +type ViolationCallback = (url: string, result: ScanResult) => void +const pageCallbacks = new WeakMap>() + +function getCallbacks(page: Page): Map { + let map = pageCallbacks.get(page) + if (!map) { + map = new Map() + pageCallbacks.set(page, map) + } + return map +} + +let axeSource: string | null = null + +function getAxeSource(): string { + if (!axeSource) { + const axePath = require.resolve('axe-core/axe.min.js') + axeSource = readFileSync(axePath, 'utf-8') + } + return axeSource +} + +function mapRawViolations(rawViolations: RawAxeViolation[]): A11yViolation[] { + return rawViolations.map(v => ({ + id: v.id, + impact: v.impact, + help: v.help, + helpUrl: v.helpUrl, + description: v.description, + tags: v.tags, + timestamp: Date.now(), + nodes: v.nodes.map((n): A11yViolationNode => ({ + target: n.target, + html: n.html, + failureSummary: n.failureSummary, + })), + })) +} + +/** + * Inject the axe-core library into a Playwright page context. + * + * Reads `axe-core/axe.min.js` from `node_modules` at runtime and evaluates it + * in the page. Safe to call multiple times — subsequent calls are no-ops if + * axe is already present on the page. + * + * @param page - A Playwright `Page` instance (compatible with `NuxtPage` from `@nuxt/test-utils`) + * + * @example + * ```ts + * import { createPage } from '@nuxt/test-utils' + * import { injectAxe } from '@nuxt/a11y/test-utils/browser' + * + * const page = await createPage('/') + * await injectAxe(page) + * ``` + */ +export async function injectAxe(page: Page): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const alreadyInjected = await page.evaluate(() => typeof (window as Record).axe !== 'undefined') + if (alreadyInjected) return + + const source = getAxeSource() + await page.evaluate(source) +} + +/** + * Run an accessibility scan on a Playwright page using axe-core. + * + * Automatically injects axe-core if not already present, waits for the + * configured load state (defaults to `'networkidle'`), then runs `axe.run()` + * in the browser and maps the results to a `ScanResult`. + * + * @param page - A Playwright `Page` instance (compatible with `NuxtPage` from `@nuxt/test-utils`) + * @param options - Optional scan options including axe-core config and wait behavior + * @returns A `ScanResult` with violations and filter methods + * + * @example + * ```ts + * import { createPage } from '@nuxt/test-utils' + * import { runAxeOnPage } from '@nuxt/a11y/test-utils/browser' + * + * const page = await createPage('/') + * const result = await runAxeOnPage(page) + * expect(result.violationCount).toBe(0) + * + * // Skip waiting (page already stable) + * const result2 = await runAxeOnPage(page, { waitForState: null }) + * ``` + */ +export async function runAxeOnPage(page: Page, options: RunAxeOnPageOptions = {}): Promise { + const { waitForState = 'networkidle', axeOptions, runOptions } = options + + if (waitForState) { + await page.waitForLoadState(waitForState) + } + + await injectAxe(page) + + /* eslint-disable @typescript-eslint/no-explicit-any */ + const rawViolations: RawAxeViolation[] = await page.evaluate(async ({ axeOptions: spec, runOptions: run }) => { + const w = window as Record + + let attempts = 0 + while (w.axe._running) { + if (++attempts > 100) throw new Error('axe-core still running after 10 s') + await new Promise(r => setTimeout(r, 100)) + } + + if (spec) w.axe.configure(spec) + return w.axe.run(document, run || {}).then((results: any) => results.violations) + }, { axeOptions: axeOptions || null, runOptions: runOptions || null }) + /* eslint-enable @typescript-eslint/no-explicit-any */ + + return createScanResult(mapRawViolations(rawViolations)) +} + +/** + * Start continuous accessibility scanning on a Playwright page using a + * MutationObserver. After DOM mutations settle (debounced), axe-core runs + * automatically and reports violations via the `onViolations` callback. + * + * Skips mutations from the initial page load to avoid duplicating the + * scan already performed by `runAxeOnPage` in the `goto` wrapper. + * + * @param page - A Playwright `Page` instance + * @param onViolations - Called with the current page URL and a `ScanResult` whenever new violations are found + * @param options - Observer configuration + * @returns A function to stop observing + * + * @example + * ```ts + * import { observePage } from '@nuxt/a11y/test-utils/browser' + * + * const stop = await observePage(page, (url, result) => { + * console.log(`${result.violationCount} violations on ${url}`) + * }) + * + * // ... interact with the page ... + * + * await stop() + * ``` + */ +export async function observePage( + page: Page, + onViolations: (url: string, result: ScanResult) => void, + options: ObservePageOptions = {}, +): Promise<() => Promise> { + const { debounceMs = 500, axeOptions, runOptions } = options + + const callbacks = getCallbacks(page) + const id = Math.random().toString(36).slice(2) + callbacks.set(id, onViolations) + + try { + await page.exposeFunction('__nuxtA11yOnViolations__', (rawViolations: RawAxeViolation[]) => { + const url = page.url() + const result = createScanResult(mapRawViolations(rawViolations)) + for (const cb of callbacks.values()) { + try { + cb(url, result) + } + catch { + // swallow — must not break tests + } + } + }) + } + catch { + // already exposed by a previous observePage call — callbacks map is shared + } + + const source = getAxeSource() + await page.addInitScript(source) + + const observerScript = buildObserverScript(debounceMs, axeOptions, runOptions) + await page.addInitScript(observerScript) + + try { + await injectAxe(page) + await page.evaluate(observerScript) + } + catch { + // page may not be navigated yet + } + + return async () => { + callbacks.delete(id) + if (callbacks.size === 0) { + try { + await page.evaluate(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const w = window as Record + if (typeof w.__nuxtA11yStopObserving__ === 'function') { + w.__nuxtA11yStopObserving__() + } + }) + } + catch { + // page may already be closed + } + } + } +} + +function buildObserverScript( + debounceMs: number, + axeOptions?: axe.Spec, + runOptions?: axe.RunOptions, +): string { + const axeConfigStr = axeOptions ? JSON.stringify(axeOptions) : 'null' + const runOptionsStr = runOptions ? JSON.stringify(runOptions) : '{}' + + return `(function() { + if (window.__nuxtA11yObserver__) return; + + var DEBOUNCE_MS = ${debounceMs}; + var timer = null; + var scanning = false; + var skipInitial = true; + var runRetries = 0; + + function runScan() { + if (scanning) return; + if (typeof window.axe === 'undefined') return; + if (window.axe._running) { + if (++runRetries < 100) timer = setTimeout(runScan, 100); + return; + } + runRetries = 0; + scanning = true; + Promise.resolve().then(function() { + var config = ${axeConfigStr}; + if (config) window.axe.configure(config); + return window.axe.run(document, ${runOptionsStr}); + }).then(function(results) { + if (results.violations && results.violations.length > 0) { + var mapped = results.violations.map(function(v) { + return { + id: v.id, + impact: v.impact, + help: v.help, + helpUrl: v.helpUrl, + description: v.description, + tags: v.tags, + nodes: v.nodes.map(function(n) { + return { target: n.target, html: n.html, failureSummary: n.failureSummary }; + }) + }; + }); + if (typeof window.__nuxtA11yOnViolations__ === 'function') { + window.__nuxtA11yOnViolations__(mapped); + } + } + }).catch(function() {}).finally(function() { + scanning = false; + }); + } + + var observer = new MutationObserver(function() { + if (skipInitial) return; + if (timer) clearTimeout(timer); + timer = setTimeout(runScan, DEBOUNCE_MS); + }); + + if (document.documentElement) { + observer.observe(document.documentElement, { + childList: true, + subtree: true, + attributes: true, + }); + } + + setTimeout(function() { skipInitial = false; }, DEBOUNCE_MS + 100); + + window.__nuxtA11yObserver__ = observer; + window.__nuxtA11yStopObserving__ = function() { + observer.disconnect(); + if (timer) clearTimeout(timer); + window.__nuxtA11yObserver__ = null; + }; + })();` +} diff --git a/src/test-utils/format.ts b/src/test-utils/format.ts new file mode 100644 index 0000000..3786e91 --- /dev/null +++ b/src/test-utils/format.ts @@ -0,0 +1,65 @@ +import type { A11yViolation } from '../runtime/types' + +/** + * Formats an array of accessibility violations into a readable string + * suitable for test failure messages. + * + * Output is grouped by rule, showing impact level, help URL, and affected + * element selectors. Truncates at 3 nodes per rule. + * + * @param violations - Array of violations to format + * @returns Formatted string describing all violations + * + * @example + * ```ts + * const result = await runA11yScan(html) + * if (result.violations.length > 0) { + * console.log(formatViolations(result.violations)) + * } + * ``` + */ +export function formatViolations(violations: A11yViolation[]): string { + if (violations.length === 0) { + return 'No accessibility violations found.' + } + + const grouped = groupByRule(violations) + const lines: string[] = [] + + lines.push(`${violations.length} accessibility violation(s) found:\n`) + + for (const [ruleId, ruleViolations] of Object.entries(grouped)) { + const first = ruleViolations[0]! + const impact = first.impact || 'unknown' + const allNodes = ruleViolations.flatMap(v => v.nodes) + + lines.push(` [${impact}] ${ruleId} (${allNodes.length} element(s))`) + lines.push(` ${first.help}`) + lines.push(` ${first.helpUrl}`) + + const maxNodes = 3 + for (const node of allNodes.slice(0, maxNodes)) { + const selector = flattenSelector(node.target) + lines.push(` - ${selector}`) + } + if (allNodes.length > maxNodes) { + lines.push(` ... and ${allNodes.length - maxNodes} more element(s)`) + } + + lines.push('') + } + + return lines.join('\n') +} + +function flattenSelector(target: (string | string[])[]): string { + return target.flatMap(t => Array.isArray(t) ? t : [t]).join(' > ') +} + +function groupByRule(violations: A11yViolation[]): Record { + const grouped: Record = {} + for (const v of violations) { + (grouped[v.id] ??= []).push(v) + } + return grouped +} diff --git a/src/test-utils/index.ts b/src/test-utils/index.ts new file mode 100644 index 0000000..772f6e0 --- /dev/null +++ b/src/test-utils/index.ts @@ -0,0 +1,64 @@ +import type { A11yViolation } from '../runtime/types' +import type { ScanOptions, ScanResult } from './types' +import { runAxeOnHtml } from '../utils/axe-server' + +export { runAxeOnHtml } from '../utils/axe-server' +export { createAutoScan } from './auto-scan' +export { formatViolations } from './format' +export { toHaveNoA11yViolations } from './matchers' +export type { ScanOptions, ScanResult, MatcherOptions, AutoScanOptions, RunAxeOnPageOptions, ObservePageOptions } from './types' +export type { A11yViolation, A11yViolationNode } from '../runtime/types' + +/** + * Create a `ScanResult` from an array of violations. + * + * Used internally by `runA11yScan` and `runAxeOnPage` to wrap raw violation + * arrays with helper methods. Can also be used directly for advanced use cases. + * + * @param violations - Array of accessibility violations + * @returns A `ScanResult` with violation data and filter methods + * + * @example + * ```ts + * import { createScanResult } from '@nuxt/a11y/test-utils' + * + * const result = createScanResult(myViolations) + * const critical = result.getByImpact('critical') + * ``` + */ +export function createScanResult(violations: A11yViolation[]): ScanResult { + return { + violations, + violationCount: violations.length, + getByImpact: level => violations.filter(v => v.impact === level), + getByRule: ruleId => violations.filter(v => v.id === ruleId), + getByTag: tag => violations.filter(v => v.tags.includes(tag)), + } +} + +/** + * Scan an HTML string for accessibility violations using axe-core. + * + * Wraps `runAxeOnHtml()` and returns a rich `ScanResult` object with + * helper methods for filtering violations. + * + * @param html - The HTML string to scan + * @param options - Optional scan options including route identifier and axe-core configuration + * @returns A `ScanResult` with violations and filter methods + * + * @example + * ```ts + * import { $fetch } from '@nuxt/test-utils' + * import { runA11yScan } from '@nuxt/a11y/test-utils' + * + * const html = await $fetch('/', { responseType: 'text' }) + * const result = await runA11yScan(html, { route: '/' }) + * + * console.log(result.violationCount) + * const critical = result.getByImpact('critical') + * ``` + */ +export async function runA11yScan(html: string, options?: ScanOptions): Promise { + const violations = await runAxeOnHtml(html, options?.route ?? 'unknown', options) + return createScanResult(violations) +} diff --git a/src/test-utils/matchers.ts b/src/test-utils/matchers.ts new file mode 100644 index 0000000..0d0253f --- /dev/null +++ b/src/test-utils/matchers.ts @@ -0,0 +1,79 @@ +import type { ScanResult, MatcherOptions } from './types' +import type { A11yViolation } from '../runtime/types' +import { IMPACT_LEVELS } from '../runtime/constants' +import { formatViolations } from './format' + +function isScanResult(value: unknown): value is ScanResult { + return ( + typeof value === 'object' + && value !== null + && 'violations' in value + && 'violationCount' in value + && Array.isArray((value as ScanResult).violations) + ) +} + +function filterViolations(violations: A11yViolation[], options?: MatcherOptions): A11yViolation[] { + if (!options) return violations + + let filtered = violations + + if (options.impact) { + const minIndex = IMPACT_LEVELS.indexOf(options.impact) + filtered = filtered.filter(v => + v.impact !== null && v.impact !== undefined && IMPACT_LEVELS.indexOf(v.impact) <= minIndex, + ) + } + + if (options.rules) { + const rules = options.rules + filtered = filtered.filter(v => rules.includes(v.id)) + } + + if (options.tags) { + const tags = options.tags + filtered = filtered.filter(v => v.tags.some(t => tags.includes(t))) + } + + return filtered +} + +/** + * Vitest custom matcher that asserts a `ScanResult` has no accessibility violations. + * + * Accepts optional `MatcherOptions` to filter which violations cause failure. + * + * @example + * ```ts + * import { $fetch } from '@nuxt/test-utils' + * import { runA11yScan } from '@nuxt/a11y/test-utils' + * + * const html = await $fetch('/', { responseType: 'text' }) + * const result = await runA11yScan(html) + * + * expect(result).toHaveNoA11yViolations() + * expect(result).toHaveNoA11yViolations({ impact: 'serious' }) + * expect(result).toHaveNoA11yViolations({ rules: ['image-alt'] }) + * ``` + */ +export function toHaveNoA11yViolations(received: unknown, options?: MatcherOptions) { + if (!isScanResult(received)) { + return { + pass: false, + message: () => + 'Expected a ScanResult from runA11yScan() or runAxeOnPage(), ' + + 'but received a non-ScanResult value. ' + + `Got: ${typeof received === 'string' ? `string "${received.slice(0, 100)}..."` : typeof received}`, + } + } + + const filtered = filterViolations(received.violations, options) + + return { + pass: filtered.length === 0, + message: () => + filtered.length === 0 + ? 'Expected ScanResult to have accessibility violations, but none were found.' + : formatViolations(filtered), + } +} diff --git a/src/test-utils/setup.ts b/src/test-utils/setup.ts new file mode 100644 index 0000000..3538a26 --- /dev/null +++ b/src/test-utils/setup.ts @@ -0,0 +1,53 @@ +/** + * Auto-registers the `toHaveNoA11yViolations` Vitest matcher globally. + * + * Add this file to your Vitest `setupFiles` so the matcher is available + * on all `expect()` calls without manual registration. + * + * @example + * ```ts + * // vitest.config.ts + * export default defineConfig({ + * test: { + * setupFiles: ['@nuxt/a11y/test-utils/setup'], + * }, + * }) + * ``` + * + * @example + * ```ts + * // In your test file — no manual registration needed: + * import { $fetch } from '@nuxt/test-utils' + * import { runA11yScan } from '@nuxt/a11y/test-utils' + * + * const html = await $fetch('/', { responseType: 'text' }) + * const result = await runA11yScan(html) + * expect(result).toHaveNoA11yViolations() + * ``` + * @module + */ +import type { MatcherOptions } from './types' +import { expect } from 'vitest' +import { toHaveNoA11yViolations } from './matchers' + +expect.extend({ toHaveNoA11yViolations }) + +declare module 'vitest' { + interface Assertion { + /** + * Assert that a `ScanResult` has no accessibility violations. + * + * @param options - Optional filters for impact level, rules, or tags + * + * @example + * ```ts + * import { runA11yScan } from '@nuxt/a11y/test-utils' + * + * const result = await runA11yScan(html) + * expect(result).toHaveNoA11yViolations() + * expect(result).toHaveNoA11yViolations({ impact: 'serious' }) + * ``` + */ + toHaveNoA11yViolations: (options?: MatcherOptions) => T + } +} diff --git a/src/test-utils/types.ts b/src/test-utils/types.ts new file mode 100644 index 0000000..141b760 --- /dev/null +++ b/src/test-utils/types.ts @@ -0,0 +1,164 @@ +import type axe from 'axe-core' +import type { A11yViolation } from '../runtime/types' + +/** + * Options for running an accessibility scan on an HTML string. + * Wraps the existing `AxeServerOptions` from `src/utils/axe-server.ts`. + * + * @example + * ```ts + * const result = await runA11yScan(html, { + * axeOptions: { rules: { 'color-contrast': { enabled: false } } }, + * runOptions: { runOnly: ['wcag2a'] }, + * }) + * ``` + */ +export interface ScanOptions { + /** Route identifier attached to each violation for traceability */ + route?: string + /** axe-core configuration options passed to `axe.configure()` */ + axeOptions?: axe.Spec + /** axe-core runtime options passed to `axe.run()` */ + runOptions?: axe.RunOptions +} + +/** + * The result of an accessibility scan, providing violations and helper methods + * for filtering them by impact, rule, or tag. + * + * @example + * ```ts + * // With $fetch (server-side scanning) + * import { $fetch } from '@nuxt/test-utils' + * const html = await $fetch('/', { responseType: 'text' }) + * const result = await runA11yScan(html) + * console.log(result.violationCount) + * + * // With mountSuspended (component scanning) + * import { mountSuspended } from '@nuxt/test-utils/runtime' + * const wrapper = await mountSuspended(MyComponent) + * const result = await runA11yScan(wrapper.html()) + * const critical = result.getByImpact('critical') + * ``` + */ +export interface ScanResult { + /** All violations found during the scan */ + violations: A11yViolation[] + /** Total number of violations */ + violationCount: number + /** + * Filter violations by impact level. + * @param level - The impact level to filter by + * @returns Violations matching the given impact level + */ + getByImpact: (level: NonNullable) => A11yViolation[] + /** + * Filter violations by axe rule ID. + * @param ruleId - The rule ID to filter by (e.g., `'image-alt'`) + * @returns Violations matching the given rule ID + */ + getByRule: (ruleId: string) => A11yViolation[] + /** + * Filter violations by tag. + * @param tag - The tag to filter by (e.g., `'wcag2a'`, `'wcag2aa'`) + * @returns Violations matching the given tag + */ + getByTag: (tag: string) => A11yViolation[] +} + +/** + * Options for the `toHaveNoA11yViolations` Vitest matcher to filter + * which violations cause the assertion to fail. + * + * @example + * ```ts + * // Only fail on critical or serious violations + * expect(result).toHaveNoA11yViolations({ impact: 'serious' }) + * + * // Only check specific rules + * expect(result).toHaveNoA11yViolations({ rules: ['image-alt', 'label'] }) + * + * // Only check WCAG 2.0 Level A + * expect(result).toHaveNoA11yViolations({ tags: ['wcag2a'] }) + * ``` + */ +export interface MatcherOptions { + /** + * Minimum impact level to consider a failure. + * When set, only violations at this level or higher cause the assertion to fail. + * Severity order: `critical` > `serious` > `moderate` > `minor`. + */ + impact?: NonNullable + /** Only fail on violations from these specific rule IDs */ + rules?: string[] + /** Only fail on violations that include at least one of these tags */ + tags?: string[] +} + +/** + * Options for `createAutoScan()` to control multi-route scanning behavior. + * + * @example + * ```ts + * const autoScan = createAutoScan({ + * exclude: ['/admin', /^\/api\//], + * threshold: 10, + * }) + * ``` + */ +export interface AutoScanOptions { + /** URL patterns to exclude from scanning (strings use `includes` matching) */ + exclude?: (string | RegExp)[] + /** + * Maximum allowed total violation count across all scanned routes. + * `exceedsThreshold()` returns `true` when this count is exceeded. + * Defaults to `0` (any violation exceeds the threshold). + */ + threshold?: number +} + +/** + * Options for `runAxeOnPage()` extending `ScanOptions` with Playwright-specific + * wait behavior configuration. + * + * @example + * ```ts + * // Use default networkidle wait + * const result = await runAxeOnPage(page) + * + * // Skip waiting (page already stable) + * const result = await runAxeOnPage(page, { waitForState: null }) + * + * // Wait for DOMContentLoaded only + * const result = await runAxeOnPage(page, { waitForState: 'domcontentloaded' }) + * ``` + */ +/** + * Options for `observePage()` to configure continuous MutationObserver-based + * accessibility scanning on a Playwright page. + * + * @example + * ```ts + * await observePage(page, onViolations, { debounceMs: 300 }) + * ``` + */ +export interface ObservePageOptions { + /** + * Milliseconds to wait after the last DOM mutation before running a scan. + * @default 500 + */ + debounceMs?: number + /** axe-core configuration options passed to `axe.configure()` */ + axeOptions?: axe.Spec + /** axe-core runtime options passed to `axe.run()` */ + runOptions?: axe.RunOptions +} + +export interface RunAxeOnPageOptions extends ScanOptions { + /** + * Load state to wait for before running the scan. + * Set to `null` or `false` to skip waiting. + * @default 'networkidle' + */ + waitForState?: 'networkidle' | 'load' | 'domcontentloaded' | null | false +} diff --git a/src/utils/axe-server.ts b/src/utils/axe-server.ts index a5cc87b..7289a4a 100644 --- a/src/utils/axe-server.ts +++ b/src/utils/axe-server.ts @@ -45,7 +45,6 @@ function patchForAxe(window: Record, document: Record = Promise.resolve() @@ -121,13 +120,12 @@ export async function runAxeOnHtml(html: string, route: string, options: AxeServ const axe = await getAxe(window, document) - if (options.axeOptions && !configured) { - axe.configure(options.axeOptions) - configured = true - } - // axe.setup/teardown is not reentrant — serialize runs return withMutex(async () => { + if (options.axeOptions) { + axe.configure(options.axeOptions) + } + axe.setup(document as unknown as Document) // Suppress axe-core's console.log noise (e.g. "Frame does not have a content window") diff --git a/test/e2e/test-utils-auto-scan.test.ts b/test/e2e/test-utils-auto-scan.test.ts new file mode 100644 index 0000000..2eab9de --- /dev/null +++ b/test/e2e/test-utils-auto-scan.test.ts @@ -0,0 +1,131 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, $fetch } from '@nuxt/test-utils/e2e' +import { createAutoScan } from '../../src/test-utils/auto-scan' + +describe('auto-scan integration with playground', async () => { + await setup({ + rootDir: fileURLToPath(new URL('../../playground', import.meta.url)), + }) + + const routes = ['/', '/about-us', '/contact', '/interactive'] + + async function fetchHtml(route: string): Promise { + return await $fetch(route, { responseType: 'text' }) + } + + it('scans all playground routes and accumulates results', async () => { + const autoScan = createAutoScan({ threshold: 50 }) + + for (const route of routes) { + const html = await fetchHtml(route) + await autoScan.scanFetchedHtml(route, html) + } + + const results = autoScan.getResults() + + for (const route of routes) { + expect(results[route]).toBeDefined() + expect(results[route]!.violations).toBeInstanceOf(Array) + expect(typeof results[route]!.violationCount).toBe('number') + } + }) + + it('detects violations on playground pages', async () => { + const autoScan = createAutoScan() + + for (const route of routes) { + const html = await fetchHtml(route) + await autoScan.scanFetchedHtml(route, html) + } + + const results = autoScan.getResults() + const totalViolations = Object.values(results).reduce( + (sum, r) => sum + r.violationCount, + 0, + ) + + expect(totalViolations).toBeGreaterThan(0) + }) + + it('exceedsThreshold returns false when within high threshold', async () => { + const autoScan = createAutoScan({ threshold: 500 }) + + for (const route of routes) { + const html = await fetchHtml(route) + await autoScan.scanFetchedHtml(route, html) + } + + expect(autoScan.exceedsThreshold()).toBe(false) + }) + + it('exceedsThreshold returns true with low threshold', async () => { + const autoScan = createAutoScan({ threshold: 1 }) + + for (const route of routes) { + const html = await fetchHtml(route) + await autoScan.scanFetchedHtml(route, html) + } + + expect(autoScan.exceedsThreshold()).toBe(true) + }) + + it('excludes routes matching string patterns', async () => { + const autoScan = createAutoScan({ exclude: ['/interactive'] }) + + for (const route of routes) { + const html = await fetchHtml(route) + await autoScan.scanFetchedHtml(route, html) + } + + const results = autoScan.getResults() + + expect(results['/interactive']).toBeUndefined() + expect(results['/']).toBeDefined() + expect(results['/about-us']).toBeDefined() + expect(results['/contact']).toBeDefined() + }) + + it('excludes routes matching RegExp patterns', async () => { + const autoScan = createAutoScan({ exclude: [/interactive/] }) + + for (const route of routes) { + const html = await fetchHtml(route) + await autoScan.scanFetchedHtml(route, html) + } + + const results = autoScan.getResults() + + expect(results['/interactive']).toBeUndefined() + expect(Object.keys(results)).toHaveLength(3) + }) + + it('ScanResult helper methods work on accumulated results', async () => { + const autoScan = createAutoScan() + + const html = await fetchHtml('/') + await autoScan.scanFetchedHtml('/', html) + + const results = autoScan.getResults() + const indexResult = results['/']! + + expect(indexResult.getByImpact).toBeTypeOf('function') + expect(indexResult.getByRule).toBeTypeOf('function') + expect(indexResult.getByTag).toBeTypeOf('function') + + const critical = indexResult.getByImpact('critical') + expect(critical).toBeInstanceOf(Array) + + const imageAlt = indexResult.getByRule('image-alt') + expect(imageAlt.length).toBeGreaterThan(0) + }) + + it('exceedsThreshold defaults to 0 (any violation exceeds)', async () => { + const autoScan = createAutoScan() + + const html = await fetchHtml('/') + await autoScan.scanFetchedHtml('/', html) + + expect(autoScan.exceedsThreshold()).toBe(true) + }) +}, 60_000) diff --git a/test/e2e/test-utils-browser.test.ts b/test/e2e/test-utils-browser.test.ts new file mode 100644 index 0000000..206d2e3 --- /dev/null +++ b/test/e2e/test-utils-browser.test.ts @@ -0,0 +1,242 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, createPage, url } from '@nuxt/test-utils/e2e' +import { injectAxe, runAxeOnPage, observePage } from '../../src/test-utils/browser' +import { toHaveNoA11yViolations } from '../../src/test-utils/matchers' + +expect.extend({ toHaveNoA11yViolations }) + +describe('playwright test-utils integration with playground', async () => { + await setup({ + dev: true, + rootDir: fileURLToPath(new URL('../../playground', import.meta.url)), + browser: true, + }) + + it('runAxeOnPage returns a valid ScanResult for the index page', async () => { + const page = await createPage() + await page.goto(url('/'), { waitUntil: 'networkidle' }) + + const result = await runAxeOnPage(page) + + expect(result).toBeDefined() + expect(result.violations).toBeInstanceOf(Array) + expect(typeof result.violationCount).toBe('number') + expect(result.violationCount).toBeGreaterThan(0) + + expect(result.getByImpact).toBeTypeOf('function') + expect(result.getByRule).toBeTypeOf('function') + expect(result.getByTag).toBeTypeOf('function') + + await page.close() + }) + + it('detects violations on /about-us', async () => { + const page = await createPage() + await page.goto(url('/about-us'), { waitUntil: 'networkidle' }) + + const result = await runAxeOnPage(page) + + expect(result.violationCount).toBeGreaterThan(0) + expect(result.violations.some(v => v.id === 'html-has-lang' || v.id === 'document-title')).toBe(true) + + await page.close() + }) + + it('detects violations on /contact', async () => { + const page = await createPage() + await page.goto(url('/contact'), { waitUntil: 'networkidle' }) + + const result = await runAxeOnPage(page) + + expect(result.violationCount).toBeGreaterThan(0) + + await page.close() + }) + + it('ScanResult helper methods filter correctly on browser results', async () => { + const page = await createPage() + await page.goto(url('/'), { waitUntil: 'networkidle' }) + + const result = await runAxeOnPage(page) + + const critical = result.getByImpact('critical') + expect(critical).toBeInstanceOf(Array) + expect(critical.length).toBeGreaterThan(0) + expect(critical.every(v => v.impact === 'critical')).toBe(true) + + const imageAlt = result.getByRule('image-alt') + expect(imageAlt.length).toBeGreaterThan(0) + expect(imageAlt.every(v => v.id === 'image-alt')).toBe(true) + + const wcag2a = result.getByTag('wcag2a') + expect(wcag2a).toBeInstanceOf(Array) + expect(wcag2a.every(v => v.tags.includes('wcag2a'))).toBe(true) + + await page.close() + }) + + it('toHaveNoA11yViolations matcher works with Playwright scan results', async () => { + const page = await createPage() + await page.goto(url('/'), { waitUntil: 'networkidle' }) + + const result = await runAxeOnPage(page) + + expect(() => { + expect(result).toHaveNoA11yViolations() + }).toThrow() + + await page.close() + }) + + it('waitForState: null skips waiting', async () => { + const page = await createPage() + await page.goto(url('/'), { waitUntil: 'networkidle' }) + + const result = await runAxeOnPage(page, { waitForState: null }) + + expect(result).toBeDefined() + expect(result.violations).toBeInstanceOf(Array) + expect(typeof result.violationCount).toBe('number') + + await page.close() + }) + + it('injectAxe is idempotent', async () => { + const page = await createPage() + await page.goto(url('/'), { waitUntil: 'networkidle' }) + + await injectAxe(page) + await injectAxe(page) + + const result = await runAxeOnPage(page) + expect(result.violations).toBeInstanceOf(Array) + + await page.close() + }) + + it('observePage detects violations after click, input, and DOM mutations', async () => { + const page = await createPage() + await page.goto(url('/interactive'), { waitUntil: 'networkidle' }) + + const results: { url: string, violationCount: number }[] = [] + const stop = await observePage(page, (pageUrl, result) => { + results.push({ url: pageUrl, violationCount: result.violationCount }) + }, { debounceMs: 200 }) + + await page.waitForTimeout(400) + + // click "Load Notifications" — injects images without alt text + await page.click('button.load-btn') + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(0) + expect(results.some(r => r.violationCount > 0)).toBe(true) + + const afterClick = results.length + + // fill profile form — triggers Vue reactivity, renders preview section + await page.fill('input[placeholder="Display name"]', 'Test User') + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(afterClick) + + // select a role — triggers another DOM update + const afterFill = results.length + await page.selectOption('select.form-input', 'Developer') + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(afterFill) + + const afterSelect = results.length + + // inject raw DOM element — img without alt + await page.evaluate(() => { + const img = document.createElement('img') + img.src = 'broken.png' + document.body.appendChild(img) + }) + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(afterSelect) + + await stop() + await page.close() + }) + + it('observePage stop prevents further callbacks across interactions', async () => { + const page = await createPage() + await page.goto(url('/interactive'), { waitUntil: 'networkidle' }) + + const results: { url: string, violationCount: number }[] = [] + const stop = await observePage(page, (pageUrl, result) => { + results.push({ url: pageUrl, violationCount: result.violationCount }) + }, { debounceMs: 200 }) + + await page.waitForTimeout(400) + await stop() + + await page.click('button.load-btn') + await page.waitForTimeout(1000) + + await page.fill('input[placeholder="Display name"]', 'Test User') + await page.waitForTimeout(1000) + + await page.evaluate(() => { + const img = document.createElement('img') + img.src = 'broken.png' + document.body.appendChild(img) + }) + await page.waitForTimeout(1000) + + expect(results.length).toBe(0) + + await page.close() + }) + + it('observePage detects violations across SPA navigations and interactions', async () => { + const page = await createPage() + await page.goto(url('/interactive'), { waitUntil: 'networkidle' }) + + const results: { url: string, violationCount: number }[] = [] + const stop = await observePage(page, (pageUrl, result) => { + results.push({ url: pageUrl, violationCount: result.violationCount }) + }, { debounceMs: 200 }) + + await page.waitForTimeout(400) + + // interact on /interactive — click "Load Notifications" + await page.click('button.load-btn') + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(0) + + const afterFirstInteraction = results.length + + // SPA navigate to /contact via NuxtLink — observer survives + await page.click('a[href="/contact"]') + await page.waitForFunction(() => window.location.pathname === '/contact') + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(afterFirstInteraction) + + const afterSpaNav = results.length + + // inject a violation on /contact + await page.evaluate(() => { + const form = document.createElement('form') + const input = document.createElement('input') + input.type = 'text' + form.appendChild(input) + document.body.appendChild(form) + }) + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(afterSpaNav) + + const afterContactMutation = results.length + + // SPA navigate back to /interactive + await page.click('a[href="/interactive"]') + await page.waitForFunction(() => window.location.pathname === '/interactive') + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(afterContactMutation) + + const afterSecondNav = results.length + + // interact again — fill the comment form and submit + await page.fill('input[placeholder="Write a comment..."]', 'Test comment') + await page.click('button[type="submit"]') + await expect.poll(() => results.length, { timeout: 10_000 }).toBeGreaterThan(afterSecondNav) + + expect(results.every(r => r.url.length > 0)).toBe(true) + + await stop() + await page.close() + }) +}, 120_000) diff --git a/test/e2e/test-utils-vitest.test.ts b/test/e2e/test-utils-vitest.test.ts new file mode 100644 index 0000000..922cdcc --- /dev/null +++ b/test/e2e/test-utils-vitest.test.ts @@ -0,0 +1,120 @@ +import { fileURLToPath } from 'node:url' +import { describe, it, expect } from 'vitest' +import { setup, $fetch } from '@nuxt/test-utils/e2e' +import { runA11yScan } from '../../src/test-utils/index' +import { toHaveNoA11yViolations } from '../../src/test-utils/matchers' + +expect.extend({ toHaveNoA11yViolations }) + +describe('vitest test-utils integration with playground', async () => { + await setup({ + rootDir: fileURLToPath(new URL('../../playground', import.meta.url)), + }) + + async function fetchHtml(route: string): Promise { + return await $fetch(route, { responseType: 'text' }) + } + + it('runA11yScan returns a valid ScanResult for the index page', async () => { + const html = await fetchHtml('/') + const result = await runA11yScan(html) + + expect(result).toBeDefined() + expect(result.violations).toBeInstanceOf(Array) + expect(typeof result.violationCount).toBe('number') + expect(result.violationCount).toBeGreaterThan(0) + }) + + it('detects violations on /about-us', async () => { + const html = await fetchHtml('/about-us') + const result = await runA11yScan(html) + + expect(result.violationCount).toBeGreaterThan(0) + expect(result.violations.some(v => v.id === 'html-has-lang' || v.id === 'document-title')).toBe(true) + }) + + it('detects violations on /contact', async () => { + const html = await fetchHtml('/contact') + const result = await runA11yScan(html) + + expect(result.violationCount).toBeGreaterThan(0) + }) + + it('ScanResult helper methods filter correctly', async () => { + const html = await fetchHtml('/') + const result = await runA11yScan(html) + + const critical = result.getByImpact('critical') + expect(critical).toBeInstanceOf(Array) + expect(critical.length).toBeGreaterThan(0) + expect(critical.every(v => v.impact === 'critical')).toBe(true) + + const imageAlt = result.getByRule('image-alt') + expect(imageAlt.length).toBeGreaterThan(0) + expect(imageAlt.every(v => v.id === 'image-alt')).toBe(true) + + const wcag2a = result.getByTag('wcag2a') + expect(wcag2a).toBeInstanceOf(Array) + expect(wcag2a.every(v => v.tags.includes('wcag2a'))).toBe(true) + }) + + it('getByImpact returns only items with matching impact', async () => { + const html = await fetchHtml('/') + const result = await runA11yScan(html) + + const minor = result.getByImpact('minor') + expect(minor).toBeInstanceOf(Array) + expect(minor.every(v => v.impact === 'minor')).toBe(true) + }) + + it('getByRule returns empty array for non-existent rule', async () => { + const html = await fetchHtml('/') + const result = await runA11yScan(html) + + const nonExistent = result.getByRule('non-existent-rule') + expect(nonExistent).toHaveLength(0) + }) + + it('toHaveNoA11yViolations fails on pages with violations', async () => { + const html = await fetchHtml('/') + const result = await runA11yScan(html) + + expect(() => { + expect(result).toHaveNoA11yViolations() + }).toThrow() + }) + + it('toHaveNoA11yViolations failure message includes violation details', async () => { + const html = await fetchHtml('/') + const result = await runA11yScan(html) + + let failureMessage = '' + try { + expect(result).toHaveNoA11yViolations() + } + catch (error) { + failureMessage = (error as Error).message + } + + expect(failureMessage).toContain('image-alt') + expect(failureMessage).toContain('critical') + }) + + it('toHaveNoA11yViolations supports filtering by impact', async () => { + const html = await fetchHtml('/') + const result = await runA11yScan(html) + + expect(() => { + expect(result).toHaveNoA11yViolations({ impact: 'critical' }) + }).toThrow() + }) + + it('toHaveNoA11yViolations supports filtering by rules', async () => { + const html = await fetchHtml('/') + const result = await runA11yScan(html) + + expect(() => { + expect(result).toHaveNoA11yViolations({ rules: ['image-alt'] }) + }).toThrow() + }) +}, 60_000) diff --git a/test/unit/test-utils-auto-scan.test.ts b/test/unit/test-utils-auto-scan.test.ts new file mode 100644 index 0000000..e99c185 --- /dev/null +++ b/test/unit/test-utils-auto-scan.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect } from 'vitest' +import { createAutoScan } from '../../src/test-utils/auto-scan' + +const ACCESSIBLE_HTML = ` + +Test Page + +
+

Hello World

+

Some accessible content

+ A photo +
+ +` + +const INACCESSIBLE_HTML = ` + + + + + + +` + +describe('createAutoScan', () => { + it('accumulates results from multiple routes', async () => { + const autoScan = createAutoScan() + + await autoScan.scanFetchedHtml('/', INACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/about', INACCESSIBLE_HTML) + + const results = autoScan.getResults() + expect(Object.keys(results)).toHaveLength(2) + expect(results['/']).toBeDefined() + expect(results['/about']).toBeDefined() + }) + + it('returns results keyed by URL with ScanResult shape', async () => { + const autoScan = createAutoScan() + + await autoScan.scanFetchedHtml('/page-a', INACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/page-b', INACCESSIBLE_HTML) + + const results = autoScan.getResults() + for (const url of ['/page-a', '/page-b']) { + const result = results[url]! + expect(result).toBeDefined() + expect(result.violationCount).toBeGreaterThan(0) + expect(Array.isArray(result.violations)).toBe(true) + expect(typeof result.getByImpact).toBe('function') + expect(typeof result.getByRule).toBe('function') + expect(typeof result.getByTag).toBe('function') + } + }) + + it('excludes routes matching string patterns', async () => { + const autoScan = createAutoScan({ exclude: ['/admin'] }) + + await autoScan.scanFetchedHtml('/admin', INACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/admin/users', INACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/', INACCESSIBLE_HTML) + + const results = autoScan.getResults() + expect(Object.keys(results)).toEqual(['/']) + }) + + it('excludes routes matching RegExp patterns', async () => { + const autoScan = createAutoScan({ exclude: [/^\/api\//] }) + + await autoScan.scanFetchedHtml('/api/health', INACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/api/users', INACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/', INACCESSIBLE_HTML) + + const results = autoScan.getResults() + expect(Object.keys(results)).toEqual(['/']) + }) + + it('scans a mix of accessible and inaccessible pages', async () => { + const autoScan = createAutoScan() + + await autoScan.scanFetchedHtml('/broken-a', INACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/ok', ACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/broken-b', INACCESSIBLE_HTML) + + const results = autoScan.getResults() + expect(Object.keys(results)).toHaveLength(3) + expect(results['/broken-a']!.violationCount).toBeGreaterThan(0) + expect(results['/broken-b']!.violationCount).toBeGreaterThan(0) + expect(results['/ok']!.violationCount).toBeLessThan(results['/broken-a']!.violationCount) + }) + + it('accumulates repeated scans for the same route', async () => { + const autoScan = createAutoScan() + + await autoScan.scanFetchedHtml('/same-route', INACCESSIBLE_HTML) + const first = autoScan.getResults()['/same-route']! + + await autoScan.scanFetchedHtml('/same-route', INACCESSIBLE_HTML) + const second = autoScan.getResults()['/same-route']! + + expect(second.violationCount).toBe(first.violationCount * 2) + }) + + it('normalizes absolute and relative URLs into the same route key', async () => { + const autoScan = createAutoScan() + + await autoScan.scanFetchedHtml('/about', INACCESSIBLE_HTML) + const first = autoScan.getResults()['/about']! + + autoScan.addResult('http://localhost:3000/about', first) + + const results = autoScan.getResults() + expect(Object.keys(results)).toEqual(['/about']) + expect(results['/about']!.violationCount).toBe(first.violationCount * 2) + }) + + it('exceedsThreshold() returns false when violations are within threshold', async () => { + const autoScan = createAutoScan({ threshold: 100 }) + + await autoScan.scanFetchedHtml('/', INACCESSIBLE_HTML) + + expect(autoScan.exceedsThreshold()).toBe(false) + }) + + it('exceedsThreshold() returns true when violations exceed threshold', async () => { + const autoScan = createAutoScan({ threshold: 1 }) + + await autoScan.scanFetchedHtml('/', INACCESSIBLE_HTML) + await autoScan.scanFetchedHtml('/other', INACCESSIBLE_HTML) + + expect(autoScan.exceedsThreshold()).toBe(true) + }) + + it('exceedsThreshold() with default threshold of 0 treats any violation as exceeding', async () => { + const autoScan = createAutoScan() + + const results = autoScan.getResults() + expect(Object.keys(results)).toHaveLength(0) + expect(autoScan.exceedsThreshold()).toBe(false) + + await autoScan.scanFetchedHtml('/broken', INACCESSIBLE_HTML) + expect(autoScan.exceedsThreshold()).toBe(true) + }) +}) diff --git a/test/unit/test-utils-format.test.ts b/test/unit/test-utils-format.test.ts new file mode 100644 index 0000000..5179c8a --- /dev/null +++ b/test/unit/test-utils-format.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest' +import type { A11yViolation } from '../../src/runtime/types' +import { formatViolations } from '../../src/test-utils/format' + +function createViolation(overrides: Partial = {}): A11yViolation { + return { + id: 'image-alt', + impact: 'critical', + help: 'Images must have alternative text', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.11/image-alt', + description: 'Ensures elements have alternate text', + tags: ['wcag2a'], + timestamp: Date.now(), + route: '/', + nodes: [ + { + target: ['.hero-image'], + html: '', + failureSummary: 'Fix any of the following:\n Element does not have an alt attribute', + }, + ], + ...overrides, + } +} + +describe('formatViolations', () => { + it('returns a clean message for zero violations', () => { + const output = formatViolations([]) + expect(output).toBe('No accessibility violations found.') + }) + + it('groups violations by rule in the output', () => { + const violations = [ + createViolation({ id: 'image-alt', nodes: [{ target: ['.img-1'], html: '', failureSummary: 'fix' }] }), + createViolation({ id: 'image-alt', nodes: [{ target: ['.img-2'], html: '', failureSummary: 'fix' }] }), + createViolation({ id: 'link-name', impact: 'serious', help: 'Links must have discernible text', helpUrl: 'https://dequeuniversity.com/rules/axe/4.11/link-name', nodes: [{ target: ['a.nav'], html: '', failureSummary: 'fix' }] }), + ] + + const output = formatViolations(violations) + + expect(output).toContain('image-alt (2 element(s))') + expect(output).toContain('link-name (1 element(s))') + }) + + it('includes impact level per rule group', () => { + const violations = [ + createViolation({ impact: 'critical' }), + createViolation({ id: 'color-contrast', impact: 'serious', help: 'Elements must meet color contrast', helpUrl: 'https://example.com/color-contrast', nodes: [{ target: ['.text'], html: '

', failureSummary: 'fix' }] }), + ] + + const output = formatViolations(violations) + + expect(output).toContain('[critical] image-alt') + expect(output).toContain('[serious] color-contrast') + }) + + it('includes help text and help URL per rule group', () => { + const violations = [createViolation()] + const output = formatViolations(violations) + + expect(output).toContain('Images must have alternative text') + expect(output).toContain('https://dequeuniversity.com/rules/axe/4.11/image-alt') + }) + + it('lists affected selectors per rule group', () => { + const violations = [ + createViolation({ + nodes: [ + { target: ['.img-1'], html: '', failureSummary: 'fix' }, + { target: ['.img-2'], html: '', failureSummary: 'fix' }, + ], + }), + ] + + const output = formatViolations(violations) + + expect(output).toContain('- .img-1') + expect(output).toContain('- .img-2') + }) + + it('truncates at 3 nodes per rule with a summary message', () => { + const nodes = Array.from({ length: 5 }, (_, i) => ({ + target: [`.item-${i}`] as string[], + html: ``, + failureSummary: `Fix item ${i}` as string | undefined, + })) + const violations = [createViolation({ nodes })] + + const output = formatViolations(violations) + + expect(output).toContain('- .item-0') + expect(output).toContain('- .item-1') + expect(output).toContain('- .item-2') + expect(output).not.toContain('- .item-3') + expect(output).not.toContain('- .item-4') + expect(output).toContain('... and 2 more element(s)') + }) + + it('does not show truncation message when nodes are exactly 3', () => { + const nodes = Array.from({ length: 3 }, (_, i) => ({ + target: [`.item-${i}`] as string[], + html: ``, + failureSummary: `Fix item ${i}` as string | undefined, + })) + const violations = [createViolation({ nodes })] + + const output = formatViolations(violations) + + expect(output).toContain('- .item-0') + expect(output).toContain('- .item-1') + expect(output).toContain('- .item-2') + expect(output).not.toContain('... and') + }) + + it('handles multiple rules at different impact levels', () => { + const violations = [ + createViolation({ id: 'image-alt', impact: 'critical' }), + createViolation({ id: 'color-contrast', impact: 'serious', help: 'Elements must meet color contrast', helpUrl: 'https://example.com/cc', nodes: [{ target: ['.text'], html: '

', failureSummary: 'fix' }] }), + createViolation({ id: 'heading-order', impact: 'moderate', help: 'Heading levels should increase by one', helpUrl: 'https://example.com/ho', nodes: [{ target: ['h3'], html: '

', failureSummary: 'fix' }] }), + ] + + const output = formatViolations(violations) + + expect(output).toContain('3 accessibility violation(s) found:') + expect(output).toContain('[critical] image-alt') + expect(output).toContain('[serious] color-contrast') + expect(output).toContain('[moderate] heading-order') + }) + + it('handles undefined impact as unknown', () => { + const violations = [createViolation({ impact: undefined })] + const output = formatViolations(violations) + + expect(output).toContain('[unknown] image-alt') + }) + + it('flattens shadow DOM selectors (nested arrays)', () => { + const violations = [ + createViolation({ + nodes: [{ + target: [['#shadow-host', '.inner']] as unknown as string[], + html: '
', + failureSummary: 'fix', + }], + }), + ] + + const output = formatViolations(violations) + + expect(output).toContain('- #shadow-host > .inner') + }) + + it('aggregates nodes across multiple violations of the same rule', () => { + const violations = [ + createViolation({ id: 'image-alt', nodes: [{ target: ['.a'], html: '', failureSummary: 'fix' }, { target: ['.b'], html: '', failureSummary: 'fix' }] }), + createViolation({ id: 'image-alt', nodes: [{ target: ['.c'], html: '', failureSummary: 'fix' }] }), + ] + + const output = formatViolations(violations) + + expect(output).toContain('image-alt (3 element(s))') + expect(output).toContain('- .a') + expect(output).toContain('- .b') + expect(output).toContain('- .c') + }) + + it('shows violation count in the header', () => { + const violations = [ + createViolation(), + createViolation({ id: 'link-name', impact: 'serious', help: 'Links must have text', helpUrl: 'https://example.com', nodes: [{ target: ['a'], html: '', failureSummary: 'fix' }] }), + ] + + const output = formatViolations(violations) + + expect(output).toContain('2 accessibility violation(s) found:') + }) +}) diff --git a/test/unit/test-utils-matchers.test.ts b/test/unit/test-utils-matchers.test.ts new file mode 100644 index 0000000..d4e080d --- /dev/null +++ b/test/unit/test-utils-matchers.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest' +import type { A11yViolation } from '../../src/runtime/types' +import type { ScanResult } from '../../src/test-utils/types' +import { toHaveNoA11yViolations } from '../../src/test-utils/matchers' + +function createViolation(overrides: Partial = {}): A11yViolation { + return { + id: 'image-alt', + impact: 'critical', + help: 'Images must have alternative text', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.11/image-alt', + description: 'Ensures elements have alternate text', + tags: ['wcag2a', 'cat.text-alternatives'], + timestamp: Date.now(), + route: '/', + nodes: [ + { + target: ['.hero-image'], + html: '', + failureSummary: 'Fix any of the following:\n Element does not have an alt attribute', + }, + ], + ...overrides, + } +} + +function createScanResult(violations: A11yViolation[]): ScanResult { + return { + violations, + violationCount: violations.length, + getByImpact: level => violations.filter(v => v.impact === level), + getByRule: ruleId => violations.filter(v => v.id === ruleId), + getByTag: tag => violations.filter(v => v.tags.includes(tag)), + } +} + +describe('toHaveNoA11yViolations', () => { + it('passes when ScanResult has zero violations', () => { + const result = createScanResult([]) + const matcherResult = toHaveNoA11yViolations(result) + + expect(matcherResult.pass).toBe(true) + expect(matcherResult.message()).toContain('Expected ScanResult to have accessibility violations') + }) + + it('fails when ScanResult has violations', () => { + const result = createScanResult([createViolation()]) + const matcherResult = toHaveNoA11yViolations(result) + + expect(matcherResult.pass).toBe(false) + expect(matcherResult.message()).toContain('image-alt') + expect(matcherResult.message()).toContain('critical') + }) + + it('filters by impact level — ignores minor violations', () => { + const result = createScanResult([ + createViolation({ id: 'minor-rule', impact: 'minor', help: 'Minor issue', tags: ['best-practice'] }), + ]) + + const matcherResult = toHaveNoA11yViolations(result, { impact: 'serious' }) + + expect(matcherResult.pass).toBe(true) + }) + + it('filters by impact level — includes higher severity', () => { + const result = createScanResult([ + createViolation({ impact: 'critical' }), + createViolation({ id: 'moderate-rule', impact: 'moderate', help: 'Moderate issue', tags: ['wcag2a'] }), + ]) + + const matcherResult = toHaveNoA11yViolations(result, { impact: 'moderate' }) + + expect(matcherResult.pass).toBe(false) + const message = matcherResult.message() + expect(message).toContain('image-alt') + expect(message).toContain('moderate-rule') + }) + + it('filters by impact level — excludes lower severity', () => { + const result = createScanResult([ + createViolation({ id: 'moderate-rule', impact: 'moderate', help: 'Moderate issue', tags: ['wcag2a'] }), + createViolation({ id: 'minor-rule', impact: 'minor', help: 'Minor issue', tags: ['best-practice'] }), + ]) + + const matcherResult = toHaveNoA11yViolations(result, { impact: 'serious' }) + + expect(matcherResult.pass).toBe(true) + }) + + it('filters by specific rules', () => { + const result = createScanResult([ + createViolation({ id: 'image-alt' }), + createViolation({ id: 'link-name', impact: 'serious', help: 'Links must have text', helpUrl: 'https://dequeuniversity.com/rules/axe/4.11/link-name', tags: ['wcag2a'], nodes: [{ target: ['.nav-link'], html: '', failureSummary: 'fix' }] }), + ]) + + const matcherResult = toHaveNoA11yViolations(result, { rules: ['link-name'] }) + + expect(matcherResult.pass).toBe(false) + const message = matcherResult.message() + expect(message).toContain('link-name') + expect(message).not.toContain('[critical] image-alt') + }) + + it('passes when filtered rules have no violations', () => { + const result = createScanResult([ + createViolation({ id: 'image-alt' }), + ]) + + const matcherResult = toHaveNoA11yViolations(result, { rules: ['color-contrast'] }) + + expect(matcherResult.pass).toBe(true) + }) + + it('filters by tags', () => { + const result = createScanResult([ + createViolation({ id: 'image-alt', tags: ['wcag2a', 'cat.text-alternatives'] }), + createViolation({ id: 'color-contrast', impact: 'serious', help: 'Color contrast', helpUrl: 'https://dequeuniversity.com/rules/axe/4.11/color-contrast', tags: ['wcag2aa'], nodes: [{ target: ['.text'], html: '

', failureSummary: 'fix' }] }), + ]) + + const matcherResult = toHaveNoA11yViolations(result, { tags: ['wcag2aa'] }) + + expect(matcherResult.pass).toBe(false) + const message = matcherResult.message() + expect(message).toContain('color-contrast') + expect(message).not.toContain('[critical] image-alt') + }) + + it('passes when filtered tags have no violations', () => { + const result = createScanResult([ + createViolation({ tags: ['wcag2a'] }), + ]) + + const matcherResult = toHaveNoA11yViolations(result, { tags: ['wcag2aaa'] }) + + expect(matcherResult.pass).toBe(true) + }) + + it('produces a helpful error when receiving a raw HTML string', () => { + const matcherResult = toHaveNoA11yViolations('') + + expect(matcherResult.pass).toBe(false) + expect(matcherResult.message()).toContain('Expected a ScanResult from runA11yScan()') + expect(matcherResult.message()).toContain('string') + expect(matcherResult.message()).toContain('') + }) + + it('produces a helpful error for non-object values', () => { + const matcherResult = toHaveNoA11yViolations(42) + + expect(matcherResult.pass).toBe(false) + expect(matcherResult.message()).toContain('Expected a ScanResult from runA11yScan()') + expect(matcherResult.message()).toContain('number') + }) + + it('produces a helpful error for null', () => { + const matcherResult = toHaveNoA11yViolations(null) + + expect(matcherResult.pass).toBe(false) + expect(matcherResult.message()).toContain('Expected a ScanResult from runA11yScan()') + }) + + it('failure messages include violation details', () => { + const result = createScanResult([ + createViolation({ + id: 'image-alt', + impact: 'critical', + help: 'Images must have alternative text', + helpUrl: 'https://dequeuniversity.com/rules/axe/4.11/image-alt', + nodes: [ + { target: ['.img-1'], html: '', failureSummary: 'fix' }, + { target: ['.img-2'], html: '', failureSummary: 'fix' }, + ], + }), + ]) + + const matcherResult = toHaveNoA11yViolations(result) + + expect(matcherResult.pass).toBe(false) + const message = matcherResult.message() + expect(message).toContain('[critical] image-alt') + expect(message).toContain('Images must have alternative text') + expect(message).toContain('https://dequeuniversity.com/rules/axe/4.11/image-alt') + expect(message).toContain('.img-1') + expect(message).toContain('.img-2') + }) +}) diff --git a/test/unit/test-utils-scan.test.ts b/test/unit/test-utils-scan.test.ts new file mode 100644 index 0000000..32593dd --- /dev/null +++ b/test/unit/test-utils-scan.test.ts @@ -0,0 +1,131 @@ +import { describe, it, expect } from 'vitest' +import { runA11yScan } from '../../src/test-utils/index' + +const ACCESSIBLE_HTML = ` + +Test Page + +

+

Hello World

+

Some accessible content

+ A photo +
+ +` + +const INACCESSIBLE_HTML = ` + + + + + + +` + +describe('runA11yScan', () => { + it('returns a ScanResult with the expected shape', async () => { + const result = await runA11yScan(ACCESSIBLE_HTML) + + expect(result).toHaveProperty('violations') + expect(result).toHaveProperty('violationCount') + expect(result).toHaveProperty('getByImpact') + expect(result).toHaveProperty('getByRule') + expect(result).toHaveProperty('getByTag') + expect(Array.isArray(result.violations)).toBe(true) + expect(typeof result.violationCount).toBe('number') + expect(typeof result.getByImpact).toBe('function') + expect(typeof result.getByRule).toBe('function') + expect(typeof result.getByTag).toBe('function') + }) + + it('returns zero violations for accessible HTML', async () => { + const result = await runA11yScan(ACCESSIBLE_HTML) + + expect(result.violations).toEqual([]) + expect(result.violationCount).toBe(0) + }) + + it('detects violations in inaccessible HTML', async () => { + const result = await runA11yScan(INACCESSIBLE_HTML) + + expect(result.violationCount).toBeGreaterThan(0) + expect(result.violations.length).toBe(result.violationCount) + + const imageAlt = result.violations.find(v => v.id === 'image-alt') + expect(imageAlt).toBeDefined() + expect(imageAlt!.impact).toBe('critical') + expect(imageAlt!.nodes.length).toBeGreaterThanOrEqual(2) + }) + + it('getByImpact() filters violations by impact level', async () => { + const result = await runA11yScan(INACCESSIBLE_HTML) + + const critical = result.getByImpact('critical') + expect(critical.length).toBeGreaterThan(0) + expect(critical.every(v => v.impact === 'critical')).toBe(true) + + const minor = result.getByImpact('minor') + expect(minor).toHaveLength(0) + }) + + it('getByRule() filters violations by rule ID', async () => { + const result = await runA11yScan(INACCESSIBLE_HTML) + + const imageAlt = result.getByRule('image-alt') + expect(imageAlt.length).toBeGreaterThan(0) + expect(imageAlt.every(v => v.id === 'image-alt')).toBe(true) + + const nonExistent = result.getByRule('non-existent-rule') + expect(nonExistent).toEqual([]) + }) + + it('getByTag() filters violations by tag', async () => { + const result = await runA11yScan(INACCESSIBLE_HTML) + + const wcag2a = result.getByTag('wcag2a') + expect(wcag2a.length).toBeGreaterThan(0) + expect(wcag2a.every(v => v.tags.includes('wcag2a'))).toBe(true) + + const nonExistent = result.getByTag('non-existent-tag') + expect(nonExistent).toEqual([]) + }) + + it('passes ScanOptions through to runAxeOnHtml()', async () => { + const result = await runA11yScan(INACCESSIBLE_HTML, { + runOptions: { runOnly: { type: 'rule', values: ['image-alt'] } }, + }) + + expect(result.violationCount).toBeGreaterThan(0) + expect(result.violations.every(v => v.id === 'image-alt')).toBe(true) + }) + + it('sets route to "test" on violations', async () => { + const result = await runA11yScan(INACCESSIBLE_HTML, { route: 'test' }) + + expect(result.violations.length).toBeGreaterThan(0) + for (const v of result.violations) { + expect(v.route).toBe('test') + } + }) + + it('applies axeOptions for each scan call', async () => { + const disabled = await runA11yScan(INACCESSIBLE_HTML, { + axeOptions: { + rules: [ + { id: 'image-alt', enabled: false }, + ], + }, + }) + + const enabled = await runA11yScan(INACCESSIBLE_HTML, { + axeOptions: { + rules: [ + { id: 'image-alt', enabled: true }, + ], + }, + }) + + expect(disabled.getByRule('image-alt')).toHaveLength(0) + expect(enabled.getByRule('image-alt').length).toBeGreaterThan(0) + }) +})