diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl index 3cc9cd07..d41a31ec 100644 --- a/.beads/interactions.jsonl +++ b/.beads/interactions.jsonl @@ -21,3 +21,6 @@ {"id":"int-6dca1ed8","kind":"field_change","created_at":"2026-05-19T00:25:02.245463989Z","actor":"Stackwright Bot","issue_id":"stackwright-u4y","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in PR #448 (fix/a11y-cluster): added ArrowLeft/ArrowRight/Home/End keyboard handler to TabbedContentGrid tablist."}} {"id":"int-818ccb2f","kind":"field_change","created_at":"2026-05-19T00:25:02.779836381Z","actor":"Stackwright Bot","issue_id":"stackwright-8je","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in PR #448 (fix/a11y-cluster): darkColors.primary changed to #92400E and accent to #B45309 in stackwright-docs."}} {"id":"int-e45f120a","kind":"field_change","created_at":"2026-05-19T00:25:03.296134424Z","actor":"Stackwright Bot","issue_id":"stackwright-5y5","extra":{"field":"status","new_value":"closed","old_value":"open","reason":"Fixed in PR #448 (fix/a11y-cluster): OverflowImageCard now uses getBetterTextColor('#1a1a1a','#FFFFFF',backgroundColor)."}} +{"id":"int-e0915e56","kind":"field_change","created_at":"2026-05-20T16:24:49.474530874Z","actor":"Stackwright Bot","issue_id":"stackwright-lww","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} +{"id":"int-0d30edd0","kind":"field_change","created_at":"2026-05-20T16:32:10.421710691Z","actor":"Stackwright Bot","issue_id":"stackwright-wao","extra":{"field":"assignee","new_value":"planning-agent-2840e9","old_value":""}} +{"id":"int-b9c85641","kind":"field_change","created_at":"2026-05-20T16:41:06.035970216Z","actor":"Stackwright Bot","issue_id":"stackwright-wao","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Closed"}} diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3d73a28e..b2e1af12 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -4,7 +4,7 @@ {"_type":"issue","id":"stackwright-8je","title":"a11y: dark mode #FCC03E amber text on near-white backgrounds fails WCAG AA","description":"Dark theme uses #FCC03E (amber/yellow) for headings and sidebar links but darkColors resolves to near-white backgrounds (#fdfdfd, #f5f5f5, #f6f6f6), producing contrast ratios of 1.51–1.61. WCAG AA requires 4.5:1. Fix: darken darkColors backgrounds to ~#1a1a1a/#2a2a2a. Affects @stackwright/themes dark color configuration. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/439","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:34:07Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:57Z","closed_at":"2026-05-19T00:21:57Z","close_reason":"Fixed: changed darkColors.primary to #92400E and accent to #B45309 in stackwright-docs stackwright.yml in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-6hl","title":"a11y: code_block tabindex=0 on \u003cpre\u003e creates keyboard trap (WCAG 2.1.2)","description":"code_block component adds tabindex=0 to \u003cpre\u003e elements making them focusable, but Tab cannot escape once focus enters. WCAG 2.1 SC 2.1.2 No Keyboard Trap violation. Fix: remove tabindex or add keyboard escape mechanism. Affects Getting Started, CLI Reference, and Otter Raft pages. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/440","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:34:02Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:55Z","closed_at":"2026-05-19T00:21:55Z","close_reason":"Fixed: removed tabIndex={0} from CodeBlock \u003cpre\u003e in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-mca","title":"a11y: skip link present but does not move focus to main content","description":"Skip link is in DOM but clicking/activating it does not move focus to main content area. Fix: add id='main-content' to \u003cmain\u003e wrapper in page layout and ensure skip link href points to #main-content. Affects @stackwright/core page layout. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/441","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:57Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:22:33Z","closed_at":"2026-05-19T00:22:33Z","close_reason":"Already fixed: PageLayout.tsx line 103 has href='#main-content' on skip link, line 173 has id='main-content' on \u003cmain\u003e. Correct implementation already shipped.","dependency_count":0,"dependent_count":0,"comment_count":0} -{"_type":"issue","id":"stackwright-a45","title":"feat(cli): stackwright test:a11y — portable WCAG accessibility audit for any Stackwright site","description":"Add stackwright test:a11y CLI command. Auto-discovers pages from prebuild output, tests WCAG 2.1 AA via axe-core + Playwright, tests both light/dark modes. Phased implementation: (1) page discovery utility, (2) portable axe runner, (3) CLI command, (4) launch-stackwright integration, (5) MCP/Otter integration. High-value differentiator for enterprise/regulated-environment angle. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/270","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:54Z","created_by":"Stackwright Bot","updated_at":"2026-05-18T22:33:54Z","dependency_count":0,"dependent_count":0,"comment_count":0} +{"_type":"issue","id":"stackwright-a45","title":"feat(cli): stackwright test:a11y — portable WCAG accessibility audit for any Stackwright site","description":"Add stackwright test:a11y CLI command. Auto-discovers pages from prebuild output, tests WCAG 2.1 AA via axe-core + Playwright, tests both light/dark modes. Phased implementation: (1) page discovery utility, (2) portable axe runner, (3) CLI command, (4) launch-stackwright integration, (5) MCP/Otter integration. High-value differentiator for enterprise/regulated-environment angle. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/270","status":"closed","priority":2,"issue_type":"feature","assignee":"Stackwright Bot","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:54Z","created_by":"Stackwright Bot","updated_at":"2026-05-20T17:01:34Z","started_at":"2026-05-20T16:49:40Z","closed_at":"2026-05-20T17:01:34Z","close_reason":"Closed","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-u4y","title":"a11y: tabbed_content component missing ARIA arrow-key keyboard pattern","description":"The tabbed_content component does not respond to ArrowLeft/ArrowRight keys to switch tabs, violating ARIA APG Tabs pattern. Only mouse/click works. Fix: add keydown handler on each tab button to move focus and activate prev/next tab on arrow keys. Affects @stackwright/ui-shadcn or @stackwright/core tabs component. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/442","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:53Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:56Z","closed_at":"2026-05-19T00:21:56Z","close_reason":"Fixed: added ArrowLeft/ArrowRight/Home/End keyboard handler to TabbedContentGrid tablist in PR fix/a11y-cluster (PR #448)","dependency_count":0,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-als","title":"feat(cli): add CLI commands for integration management (list/get/add)","description":"Add stackwright integrations list/get/add CLI commands with interactive prompts (inquirer) and colored output (chalk). Depends on integration schema. Estimated 2-3 hours. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/238","status":"open","priority":2,"issue_type":"feature","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:51Z","created_by":"Stackwright Bot","updated_at":"2026-05-18T22:33:51Z","dependencies":[{"issue_id":"stackwright-als","depends_on_id":"stackwright-5ak","type":"blocks","created_at":"2026-05-18T18:34:27Z","created_by":"Stackwright Bot","metadata":"{}"}],"dependency_count":1,"dependent_count":0,"comment_count":0} {"_type":"issue","id":"stackwright-y6m","title":"a11y: Content Types page missing \u003ctitle\u003e element and lang attribute on \u003chtml\u003e","description":"The /content-types page is missing a non-empty \u003ctitle\u003e element and the lang attribute on \u003chtml\u003e. Both are WCAG 2.1 Level A requirements (SC 2.4.2 Page Titled, SC 3.1.1 Language of Page). Fix: ensure page \u003chead\u003e includes \u003ctitle\u003e and \u003chtml\u003e has lang='en'. GitHub: https://github.com/Per-Aspera-LLC/stackwright/issues/443","status":"closed","priority":2,"issue_type":"bug","owner":"bot@per-aspera.dev","created_at":"2026-05-18T22:33:49Z","created_by":"Stackwright Bot","updated_at":"2026-05-19T00:21:58Z","closed_at":"2026-05-19T00:21:58Z","close_reason":"Already fixed: content-types/content.yml has meta.title set, StackwrightDocument defaults lang='en'. No code change needed.","dependency_count":0,"dependent_count":0,"comment_count":0} diff --git a/.changeset/feat-test-a11y-command.md b/.changeset/feat-test-a11y-command.md new file mode 100644 index 00000000..0ab1410f --- /dev/null +++ b/.changeset/feat-test-a11y-command.md @@ -0,0 +1,6 @@ +--- +"@stackwright/cli": minor +"@stackwright/mcp": minor +--- + +Add `stackwright test:a11y` command for portable WCAG 2.1 AA accessibility auditing. Tests all pages (auto-discovered) in both light and dark modes using axe-core + Playwright. Also exposes `stackwright_test_a11y` MCP tool for Otter agent integration. diff --git a/packages/cli/package.json b/packages/cli/package.json index e00b3c9e..8e461fc4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -39,9 +39,13 @@ "zod": "^4.3.6" }, "peerDependencies": { + "@axe-core/playwright": "^4.11.1", "playwright": "^1.52.0" }, "peerDependenciesMeta": { + "@axe-core/playwright": { + "optional": true + }, "playwright": { "optional": true } diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 8c4cab86..179616b5 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -16,6 +16,7 @@ import { registerIntegration } from './commands/integration'; import { registerCompose } from './commands/compose'; import { registerPreview } from './commands/preview'; import { registerSBOM } from './commands/sbom'; +import { registerTestA11y } from './commands/a11y'; const { version } = require('../package.json') as { version: string }; @@ -44,6 +45,7 @@ async function main(): Promise { registerCompose(program); registerPreview(program); registerSBOM(program); + registerTestA11y(program); // Pre-parse to extract global options (including --plugin-dir) before full dispatch. // parseOptions() does NOT dispatch commands — it only extracts options. diff --git a/packages/cli/src/commands/a11y.ts b/packages/cli/src/commands/a11y.ts new file mode 100644 index 00000000..ceccef5a --- /dev/null +++ b/packages/cli/src/commands/a11y.ts @@ -0,0 +1,182 @@ +import { Command } from 'commander'; +import chalk from 'chalk'; +import { detectProject } from '../utils/project-detector'; +import { discoverPageSlugs } from '../utils/a11y-page-discovery'; +import { runA11yAudit } from '../utils/a11y-runner'; +import type { A11yAuditResult, A11yRunnerOptions } from '../utils/a11y-runner'; +import { outputResult, outputError, getErrorCode } from '../utils/json-output'; + +export type { A11yAuditResult }; + +export interface TestA11yOptions { + baseUrl?: string; + pages?: string; // comma-separated slugs + darkMode?: boolean; // default true (tests both light + dark) + tags?: string; // comma-separated axe rule tags + failOn?: string; // 'minor' | 'moderate' | 'serious' | 'critical' + json?: boolean; +} + +/** + * Pure function — run an accessibility audit for a Stackwright project. + * Auto-discovers pages from the project's pages directory if no slugs provided. + */ +export async function testA11y( + projectRoot: string, + opts: TestA11yOptions +): Promise { + const baseUrl = opts.baseUrl ?? 'http://localhost:3000'; + + // Resolve slugs: explicit list or auto-discover + let slugs: string[]; + if (opts.pages) { + slugs = opts.pages + .split(',') + .map((s) => s.trim()) + .filter(Boolean); + } else { + slugs = discoverPageSlugs(projectRoot); + if (slugs.length === 0) { + const err = new Error('No pages found. Run stackwright prebuild first or specify --pages.'); + (err as NodeJS.ErrnoException).code = 'NO_PAGES'; + throw err; + } + } + + const modes: ('light' | 'dark')[] = opts.darkMode === false ? ['light'] : ['light', 'dark']; + + const tags = opts.tags + ? opts.tags + .split(',') + .map((t) => t.trim()) + .filter(Boolean) + : undefined; + + const failOn = (opts.failOn as A11yRunnerOptions['failOn']) ?? 'serious'; + + return runA11yAudit({ baseUrl, slugs, modes, tags, failOn }); +} + +// --------------------------------------------------------------------------- +// Human-readable formatter +// --------------------------------------------------------------------------- + +function formatAuditResult(result: A11yAuditResult): void { + const { summary } = result; + + console.log(''); + console.log(chalk.bold('♿ Stackwright Accessibility Audit')); + console.log(chalk.dim(` Base URL: ${result.baseUrl}`)); + console.log(chalk.dim(` Modes: ${result.modes.join(', ')}`)); + console.log(''); + + for (const pageResult of result.results) { + const icon = pageResult.pass ? chalk.green('✓') : chalk.red('✗'); + const modeLabel = chalk.dim(`[${pageResult.mode}]`); + const label = `${icon} ${chalk.bold(pageResult.slug)} ${modeLabel}`; + + if (pageResult.pass) { + console.log(` ${label}`); + } else { + console.log(` ${label}`); + for (const v of pageResult.failingViolations) { + const impact = v.impact ?? 'unknown'; + const impactColor = + impact === 'critical' ? chalk.red : impact === 'serious' ? chalk.yellow : chalk.dim; + console.log( + ` ${impactColor(`[${impact}]`)} ${v.id}: ${v.help} (${v.nodeCount} node${v.nodeCount !== 1 ? 's' : ''})` + ); + console.log(` ${chalk.dim(v.helpUrl)}`); + } + } + } + + console.log(''); + const summaryLine = ` ${summary.total} scan${summary.total !== 1 ? 's' : ''} — ${chalk.green(`${summary.passed} passed`)}, ${summary.failed > 0 ? chalk.red(`${summary.failed} failed`) : chalk.green('0 failed')}`; + console.log(summaryLine); + + if (summary.violations > 0) { + console.log( + chalk.dim( + ` ${summary.violations} total violation${summary.violations !== 1 ? 's' : ''} found` + ) + ); + } + + console.log(''); + + if (result.pass) { + console.log(chalk.green(' ✓ All pages pass WCAG 2.1 AA')); + } else { + console.log(chalk.red(' ✗ Accessibility violations found — see details above')); + } + + console.log(''); +} + +// --------------------------------------------------------------------------- +// Commander registration +// --------------------------------------------------------------------------- + +export function registerTestA11y(program: Command): void { + program + .command('test:a11y [slug]') + .description( + 'Run a WCAG 2.1 AA accessibility audit against a running Stackwright dev server.\n' + + 'Auto-discovers pages from the project. Requires: pnpm dev (running) + playwright installed.' + ) + .option('--base-url ', 'Dev server URL (default: http://localhost:3000)') + .option( + '--pages ', + 'Comma-separated page slugs to test (default: auto-discover all pages)' + ) + .option('--no-dark-mode', 'Skip dark mode testing (test light mode only)') + .option( + '--tags ', + 'Comma-separated axe rule tags (default: wcag2a,wcag2aa,wcag21a,wcag21aa)' + ) + .option( + '--fail-on ', + 'Minimum violation impact level that fails the audit: minor|moderate|serious|critical (default: serious)', + 'serious' + ) + .option('--json', 'Output machine-readable JSON') + .action(async (slug: string | undefined, opts: TestA11yOptions & { darkMode?: boolean }) => { + // If a positional slug was given, treat it as --pages + if (slug) { + opts.pages = slug; + } + + const json = Boolean(opts.json); + + try { + const project = detectProject(); + const result = await testA11y(project.root, opts); + + if (result.pass) { + outputResult(result, { json }, () => formatAuditResult(result)); + } else { + // Violations found — output the result then exit 1 + if (json) { + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + } else { + formatAuditResult(result); + } + process.exit(1); + } + } catch (err) { + const code = getErrorCode(err); + if ( + code === 'NO_DEV_SERVER' || + code === 'MISSING_PLAYWRIGHT' || + code === 'MISSING_AXE' || + code === 'NO_PAGES' || + code === 'NOT_A_PROJECT' + ) { + outputError(String((err as Error).message), code, { json }); + } else { + outputError(String((err as Error).message), 'A11Y_AUDIT_FAILED', { json }, 2); + } + } + }); +} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 0ca4b0f2..5b279867 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -20,6 +20,7 @@ export { listCollections, addCollection, resolveContentDir } from './commands/co export { listIntegrations, getIntegration, addIntegration } from './commands/integration'; export { composeSite } from './commands/compose'; export { preview } from './commands/preview'; +export { testA11y } from './commands/a11y'; export { validateSiteComposition } from './utils/site-validator'; export { detectProject, resolvePagesDir } from './utils/project-detector'; @@ -64,6 +65,13 @@ export type { } from './commands/integration'; export type { ComposeSiteResult, ComposeSiteOptions } from './commands/compose'; export type { PreviewResult, PreviewOptions } from './commands/preview'; +export type { TestA11yOptions, A11yAuditResult } from './commands/a11y'; +export type { + A11yViolation, + A11yPageResult, + A11yRunnerOptions, + A11yColorMode, +} from './utils/a11y-runner'; export type { ValidateSiteCompositionResult, ValidateSiteCompositionOptions, diff --git a/packages/cli/src/utils/a11y-page-discovery.ts b/packages/cli/src/utils/a11y-page-discovery.ts new file mode 100644 index 00000000..027d15c5 --- /dev/null +++ b/packages/cli/src/utils/a11y-page-discovery.ts @@ -0,0 +1,17 @@ +import { listPages } from '../commands/page'; +import { resolvePagesDir } from './project-detector'; + +/** + * Discover all page URL paths for a Stackwright project. + * + * Reads the pages directory (content.yml files) and returns each page as a + * URL-style slug (e.g. '/', '/about', '/docs/getting-started'). + * + * Used by `stackwright test:a11y` to auto-discover pages when no explicit + * slug list is provided. + */ +export function discoverPageSlugs(projectRoot: string): string[] { + const pagesDir = resolvePagesDir(projectRoot); + const result = listPages(pagesDir); + return result.pages.map((p) => p.slug); +} diff --git a/packages/cli/src/utils/a11y-runner.ts b/packages/cli/src/utils/a11y-runner.ts new file mode 100644 index 00000000..7807aa38 --- /dev/null +++ b/packages/cli/src/utils/a11y-runner.ts @@ -0,0 +1,237 @@ +/** + * Portable WCAG 2.1 AA accessibility runner for Stackwright sites. + * + * Uses axe-core via @axe-core/playwright to test pages in both light and dark + * color modes. Both Playwright and @axe-core/playwright are optional peer + * dependencies — this module throws a clear error if either is missing. + */ + +export type A11yColorMode = 'light' | 'dark'; + +export interface A11yViolation { + id: string; + impact: string | null; + description: string; + help: string; + helpUrl: string; + nodeCount: number; +} + +export interface A11yPageResult { + slug: string; + url: string; + mode: A11yColorMode; + pass: boolean; + violations: A11yViolation[]; + /** Violations at critical or serious impact level */ + failingViolations: A11yViolation[]; +} + +export interface A11yAuditResult { + /** Overall pass — true only if all pages/modes pass */ + pass: boolean; + baseUrl: string; + slugs: string[]; + modes: A11yColorMode[]; + results: A11yPageResult[]; + /** Summary counts */ + summary: { + total: number; + passed: number; + failed: number; + violations: number; + }; +} + +export interface A11yRunnerOptions { + baseUrl: string; + slugs: string[]; + /** Which color modes to test. Defaults to ['light', 'dark']. */ + modes?: A11yColorMode[]; + /** axe-core rule tags. Defaults to WCAG 2.1 AA. */ + tags?: string[]; + /** Minimum impact level that causes a page to fail. Defaults to 'serious'. */ + failOn?: 'minor' | 'moderate' | 'serious' | 'critical'; +} + +const IMPACT_LEVELS = ['minor', 'moderate', 'serious', 'critical'] as const; + +function impactRank(impact: string | null): number { + if (!impact) return -1; + return IMPACT_LEVELS.indexOf(impact as (typeof IMPACT_LEVELS)[number]); +} + +/** + * Set the Stackwright color mode on a Playwright page by writing the + * sw-color-mode cookie and reloading. Falls back to class/data-attribute + * manipulation if the cookie approach doesn't take effect. + */ +async function setColorMode(page: any, mode: A11yColorMode, baseUrl: string): Promise { + // Set cookie before navigation so ColorModeScript picks it up on load + await page.context().addCookies([ + { + name: 'sw-color-mode', + value: mode, + url: baseUrl, + }, + ]); + + await page.reload({ waitUntil: 'networkidle' }); + + // Verify the mode took effect; apply directly if not + // NOTE: Use (globalThis as Record)['document'] instead of + // bare `document` — the CLI tsconfig has no lib:dom, so the DTS build + // rejects DOM globals even inside page.evaluate() callbacks. + const applied = await page.evaluate((targetMode: string): boolean => { + const doc = (globalThis as Record)['document'] as { + documentElement: { + classList: { contains: (c: string) => boolean; toggle: (c: string, v: boolean) => void }; + dataset: Record; + }; + }; + const html = doc.documentElement; + const current = + html.classList.contains('dark') || html.dataset['theme'] === 'dark' ? 'dark' : 'light'; + if (current !== targetMode) { + html.classList.toggle('dark', targetMode === 'dark'); + html.dataset['theme'] = targetMode; + return false; + } + return true; + }, mode); + + if (!applied) { + // Give React a moment to reconcile after the direct DOM change + await page.waitForTimeout(200); + } +} + +/** + * Run a WCAG 2.1 AA accessibility audit against a running Stackwright dev server. + * + * Requires: + * - A running dev server at opts.baseUrl (default: http://localhost:3000) + * - playwright installed (optional peer dependency) + * - @axe-core/playwright installed (optional peer dependency) + */ +export async function runA11yAudit(opts: A11yRunnerOptions): Promise { + const { + baseUrl, + slugs, + modes = ['light', 'dark'], + tags = ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'], + failOn = 'serious', + } = opts; + + // -- Validate dev server is reachable -- + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 5000); + try { + await fetch(baseUrl, { signal: controller.signal }); + } catch { + const err = new Error(`No dev server detected at ${baseUrl}.\nStart it with: pnpm dev`); + (err as NodeJS.ErrnoException).code = 'NO_DEV_SERVER'; + throw err; + } finally { + clearTimeout(timer); + } + + // -- Dynamic import: playwright -- + let chromium: any; + try { + const pw = await import('playwright' as string); + chromium = pw.chromium; + } catch { + const err = new Error( + 'The test:a11y command requires Playwright.\n' + + 'Install it with: pnpm add -D playwright\n' + + 'Then install the browser: pnpm exec playwright install chromium' + ); + (err as NodeJS.ErrnoException).code = 'MISSING_PLAYWRIGHT'; + throw err; + } + + // -- Dynamic import: @axe-core/playwright -- + let AxeBuilder: any; + try { + const axeMod = await import('@axe-core/playwright' as string); + AxeBuilder = axeMod.default ?? axeMod.AxeBuilder; + } catch { + const err = new Error( + 'The test:a11y command requires @axe-core/playwright.\n' + + 'Install it with: pnpm add -D @axe-core/playwright' + ); + (err as NodeJS.ErrnoException).code = 'MISSING_AXE'; + throw err; + } + + const failRank = impactRank(failOn); + const results: A11yPageResult[] = []; + + const browser = await chromium.launch({ + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + + try { + for (const slug of slugs) { + const url = new URL(slug.startsWith('/') ? slug : `/${slug}`, baseUrl).toString(); + + for (const mode of modes) { + const context = await browser.newContext(); + const page = await context.newPage(); + + try { + // Navigate initially without color mode cookie + await page.goto(url, { waitUntil: 'networkidle', timeout: 30_000 }); + + // Apply color mode + await setColorMode(page, mode, baseUrl); + + // Run axe scan + const axeResults = await new AxeBuilder({ page }).withTags(tags).analyze(); + + const violations: A11yViolation[] = axeResults.violations.map((v: any) => ({ + id: v.id, + impact: v.impact ?? null, + description: v.description, + help: v.help, + helpUrl: v.helpUrl, + nodeCount: v.nodes.length, + })); + + const failingViolations = violations.filter((v) => impactRank(v.impact) >= failRank); + + results.push({ + slug, + url, + mode, + pass: failingViolations.length === 0, + violations, + failingViolations, + }); + } finally { + await context.close(); + } + } + } + } finally { + await browser.close(); + } + + const passed = results.filter((r) => r.pass).length; + const totalViolations = results.reduce((sum, r) => sum + r.violations.length, 0); + + return { + pass: results.every((r) => r.pass), + baseUrl, + slugs, + modes, + results, + summary: { + total: results.length, + passed, + failed: results.length - passed, + violations: totalViolations, + }, + }; +} diff --git a/packages/cli/test/commands/a11y.test.ts b/packages/cli/test/commands/a11y.test.ts new file mode 100644 index 00000000..3884d4eb --- /dev/null +++ b/packages/cli/test/commands/a11y.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { A11yAuditResult } from '../../src/utils/a11y-runner'; + +// --------------------------------------------------------------------------- +// Hoisted mock functions — created before vi.mock factories run +// --------------------------------------------------------------------------- + +const { mockDiscoverPageSlugs, mockRunA11yAudit } = vi.hoisted(() => ({ + mockDiscoverPageSlugs: vi.fn(), + mockRunA11yAudit: vi.fn(), +})); + +vi.mock('../../src/utils/a11y-page-discovery', () => ({ + discoverPageSlugs: mockDiscoverPageSlugs, +})); + +vi.mock('../../src/utils/a11y-runner', () => ({ + runA11yAudit: mockRunA11yAudit, +})); + +// --------------------------------------------------------------------------- +// Import after mocks are set up +// --------------------------------------------------------------------------- + +import { testA11y } from '../../src/commands/a11y'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makePassingResult(slugs: string[] = ['/']): A11yAuditResult { + return { + pass: true, + baseUrl: 'http://localhost:3000', + slugs, + modes: ['light', 'dark'], + results: [], + summary: { total: 0, passed: 0, failed: 0, violations: 0 }, + }; +} + +// --------------------------------------------------------------------------- +// Page discovery +// --------------------------------------------------------------------------- + +describe('testA11y — page discovery', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockRunA11yAudit.mockResolvedValue(makePassingResult()); + }); + + it('calls discoverPageSlugs with the project root when --pages not given', async () => { + mockDiscoverPageSlugs.mockReturnValue(['/about', '/contact']); + await testA11y('/fake/project', {}); + expect(mockDiscoverPageSlugs).toHaveBeenCalledWith('/fake/project'); + }); + + it('passes discovered slugs to runA11yAudit', async () => { + mockDiscoverPageSlugs.mockReturnValue(['/about', '/contact']); + await testA11y('/fake/project', {}); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.slugs).toEqual(['/about', '/contact']); + }); + + it('does NOT call discoverPageSlugs when --pages is given', async () => { + await testA11y('/fake/project', { pages: '/home,/about' }); + expect(mockDiscoverPageSlugs).not.toHaveBeenCalled(); + }); + + it('passes explicit slugs to runA11yAudit when --pages is given', async () => { + await testA11y('/fake/project', { pages: '/home,/about' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.slugs).toEqual(['/home', '/about']); + }); + + it('throws NO_PAGES when discovery returns empty and no explicit pages given', async () => { + mockDiscoverPageSlugs.mockReturnValue([]); + await expect(testA11y('/fake/project', {})).rejects.toMatchObject({ + code: 'NO_PAGES', + message: expect.stringContaining('No pages found'), + }); + }); + + it('NO_PAGES error message includes prebuild hint', async () => { + mockDiscoverPageSlugs.mockReturnValue([]); + await expect(testA11y('/fake/project', {})).rejects.toMatchObject({ + message: expect.stringContaining('prebuild'), + }); + }); +}); + +// --------------------------------------------------------------------------- +// Color modes +// --------------------------------------------------------------------------- + +describe('testA11y — color modes', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDiscoverPageSlugs.mockReturnValue(['/']); + mockRunA11yAudit.mockResolvedValue(makePassingResult()); + }); + + it('tests both light and dark modes by default (no darkMode option)', async () => { + await testA11y('/fake/project', {}); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.modes).toEqual(['light', 'dark']); + }); + + it('tests only light mode when darkMode: false (--no-dark-mode)', async () => { + await testA11y('/fake/project', { darkMode: false }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.modes).toEqual(['light']); + }); + + it('tests both modes when darkMode: true (explicit)', async () => { + await testA11y('/fake/project', { darkMode: true }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.modes).toEqual(['light', 'dark']); + }); +}); + +// --------------------------------------------------------------------------- +// Options passthrough +// --------------------------------------------------------------------------- + +describe('testA11y — options passthrough', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDiscoverPageSlugs.mockReturnValue(['/']); + mockRunA11yAudit.mockResolvedValue(makePassingResult()); + }); + + it('parses comma-separated --pages into trimmed slug array', async () => { + await testA11y('/fake/project', { pages: '/a, /b, /c' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.slugs).toEqual(['/a', '/b', '/c']); + }); + + it('filters out empty entries from --pages', async () => { + await testA11y('/fake/project', { pages: '/a,,/b,' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.slugs).toEqual(['/a', '/b']); + }); + + it('uses the provided baseUrl', async () => { + await testA11y('/fake/project', { baseUrl: 'http://localhost:4321', pages: '/' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.baseUrl).toBe('http://localhost:4321'); + }); + + it('defaults baseUrl to http://localhost:3000', async () => { + await testA11y('/fake/project', { pages: '/' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.baseUrl).toBe('http://localhost:3000'); + }); + + it('parses comma-separated --tags', async () => { + await testA11y('/fake/project', { pages: '/', tags: 'wcag2a,wcag2aa' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.tags).toEqual(['wcag2a', 'wcag2aa']); + }); + + it('passes undefined tags when --tags not given (let runner use defaults)', async () => { + await testA11y('/fake/project', { pages: '/' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.tags).toBeUndefined(); + }); + + it('passes failOn through to the runner', async () => { + await testA11y('/fake/project', { pages: '/', failOn: 'critical' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.failOn).toBe('critical'); + }); + + it('defaults failOn to "serious"', async () => { + await testA11y('/fake/project', { pages: '/' }); + const opts = mockRunA11yAudit.mock.calls[0][0]; + expect(opts.failOn).toBe('serious'); + }); +}); + +// --------------------------------------------------------------------------- +// Return value pass-through +// --------------------------------------------------------------------------- + +describe('testA11y — return value', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockDiscoverPageSlugs.mockReturnValue(['/']); + }); + + it('returns the A11yAuditResult from runA11yAudit', async () => { + const fakeResult = makePassingResult(['/about']); + mockRunA11yAudit.mockResolvedValue(fakeResult); + const result = await testA11y('/fake/project', { pages: '/about' }); + expect(result).toBe(fakeResult); + }); +}); diff --git a/packages/cli/test/utils/a11y-runner.test.ts b/packages/cli/test/utils/a11y-runner.test.ts new file mode 100644 index 00000000..f97d00b4 --- /dev/null +++ b/packages/cli/test/utils/a11y-runner.test.ts @@ -0,0 +1,202 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { A11yRunnerOptions, A11yViolation } from '../../src/utils/a11y-runner'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeViolation(impact: string | null, id = 'test-rule'): A11yViolation { + return { + id, + impact, + description: `Test violation: ${id}`, + help: 'Fix it', + helpUrl: 'https://dequeuniversity.com', + nodeCount: 1, + }; +} + +// --------------------------------------------------------------------------- +// Error-path tests +// --------------------------------------------------------------------------- + +describe('a11y-runner — error paths', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it('throws NO_DEV_SERVER when fetch rejects (connection refused)', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('connection refused'))); + const { runA11yAudit } = await import('../../src/utils/a11y-runner'); + const opts: A11yRunnerOptions = { + baseUrl: 'http://localhost:3000', + slugs: ['/'], + }; + await expect(runA11yAudit(opts)).rejects.toMatchObject({ + message: expect.stringContaining('No dev server detected'), + code: 'NO_DEV_SERVER', + }); + }); + + it('NO_DEV_SERVER message includes the baseUrl', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + const { runA11yAudit } = await import('../../src/utils/a11y-runner'); + const opts: A11yRunnerOptions = { + baseUrl: 'http://localhost:4321', + slugs: ['/'], + }; + await expect(runA11yAudit(opts)).rejects.toMatchObject({ + message: expect.stringContaining('http://localhost:4321'), + }); + }); + + it('NO_DEV_SERVER message includes the pnpm dev hint', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + const { runA11yAudit } = await import('../../src/utils/a11y-runner'); + await expect( + runA11yAudit({ baseUrl: 'http://localhost:3000', slugs: ['/'] }) + ).rejects.toMatchObject({ + message: expect.stringContaining('pnpm dev'), + }); + }); + + it('MISSING_PLAYWRIGHT error code contract is correct', () => { + // Contract test: the error thrown when playwright is absent must carry + // code: 'MISSING_PLAYWRIGHT' so callers can detect and advise the user. + const err = new Error('Cannot find module playwright'); + (err as NodeJS.ErrnoException).code = 'MISSING_PLAYWRIGHT'; + expect(err.code).toBe('MISSING_PLAYWRIGHT'); + expect(err.message).toContain('playwright'); + }); + + it('MISSING_AXE error code contract is correct', () => { + // Contract test: the error thrown when @axe-core/playwright is absent must + // carry code: 'MISSING_AXE'. + const err = new Error('Cannot find module @axe-core/playwright'); + (err as NodeJS.ErrnoException).code = 'MISSING_AXE'; + expect(err.code).toBe('MISSING_AXE'); + expect(err.message).toContain('@axe-core/playwright'); + }); +}); + +// --------------------------------------------------------------------------- +// Module exports +// --------------------------------------------------------------------------- + +describe('a11y-runner — exports', () => { + it('exports runA11yAudit as a function', async () => { + const mod = await import('../../src/utils/a11y-runner'); + expect(typeof mod.runA11yAudit).toBe('function'); + }); +}); + +// --------------------------------------------------------------------------- +// Impact ranking — tested indirectly via the A11yViolation shape +// --------------------------------------------------------------------------- + +describe('a11y-runner — A11yViolation shape', () => { + it('makeViolation helper produces valid A11yViolation objects', () => { + const v = makeViolation('serious', 'color-contrast'); + expect(v.id).toBe('color-contrast'); + expect(v.impact).toBe('serious'); + expect(typeof v.nodeCount).toBe('number'); + expect(typeof v.helpUrl).toBe('string'); + }); + + it('impact null is a valid A11yViolation (some axe rules omit impact)', () => { + const v = makeViolation(null); + expect(v.impact).toBeNull(); + }); + + // Verify the ordering assumption the runner depends on: + // minor < moderate < serious < critical + it('impact levels are in the expected ascending order', () => { + // Simulate what impactRank does via IMPACT_LEVELS array ordering + const IMPACT_LEVELS = ['minor', 'moderate', 'serious', 'critical'] as const; + const rank = (lvl: string) => IMPACT_LEVELS.indexOf(lvl as (typeof IMPACT_LEVELS)[number]); + + expect(rank('minor')).toBeLessThan(rank('moderate')); + expect(rank('moderate')).toBeLessThan(rank('serious')); + expect(rank('serious')).toBeLessThan(rank('critical')); + }); + + it('failOn threshold: only violations at-or-above the rank should fail', () => { + // White-box: replicate the filter logic from the runner + const IMPACT_LEVELS = ['minor', 'moderate', 'serious', 'critical'] as const; + const impactRank = (impact: string | null) => { + if (!impact) return -1; + return IMPACT_LEVELS.indexOf(impact as (typeof IMPACT_LEVELS)[number]); + }; + + const failOn = 'serious'; + const failRank = impactRank(failOn); + + const violations = [ + makeViolation('minor'), + makeViolation('moderate'), + makeViolation('serious'), + makeViolation('critical'), + makeViolation(null), + ]; + + const failing = violations.filter((v) => impactRank(v.impact) >= failRank); + expect(failing).toHaveLength(2); // serious + critical + expect(failing.map((v) => v.impact)).toEqual(['serious', 'critical']); + }); + + it('failOn: "critical" only fails critical violations', () => { + const IMPACT_LEVELS = ['minor', 'moderate', 'serious', 'critical'] as const; + const impactRank = (impact: string | null) => { + if (!impact) return -1; + return IMPACT_LEVELS.indexOf(impact as (typeof IMPACT_LEVELS)[number]); + }; + + const failRank = impactRank('critical'); + const violations = [ + makeViolation('minor'), + makeViolation('moderate'), + makeViolation('serious'), + makeViolation('critical'), + ]; + + const failing = violations.filter((v) => impactRank(v.impact) >= failRank); + expect(failing).toHaveLength(1); + expect(failing[0].impact).toBe('critical'); + }); + + it('failOn: "minor" fails everything including minor', () => { + const IMPACT_LEVELS = ['minor', 'moderate', 'serious', 'critical'] as const; + const impactRank = (impact: string | null) => { + if (!impact) return -1; + return IMPACT_LEVELS.indexOf(impact as (typeof IMPACT_LEVELS)[number]); + }; + + const failRank = impactRank('minor'); + const violations = [ + makeViolation('minor'), + makeViolation('moderate'), + makeViolation('serious'), + makeViolation('critical'), + ]; + + const failing = violations.filter((v) => impactRank(v.impact) >= failRank); + expect(failing).toHaveLength(4); + }); + + it('null-impact violations never fail regardless of failOn threshold', () => { + const IMPACT_LEVELS = ['minor', 'moderate', 'serious', 'critical'] as const; + const impactRank = (impact: string | null) => { + if (!impact) return -1; + return IMPACT_LEVELS.indexOf(impact as (typeof IMPACT_LEVELS)[number]); + }; + + const failRank = impactRank('minor'); // strictest threshold + const nullViolation = makeViolation(null); + expect(impactRank(nullViolation.impact) >= failRank).toBe(false); + }); +}); diff --git a/packages/launch-stackwright/README.md b/packages/launch-stackwright/README.md index 77d8f545..72ea380a 100644 --- a/packages/launch-stackwright/README.md +++ b/packages/launch-stackwright/README.md @@ -110,6 +110,23 @@ pnpm install pnpm dev ``` +Once the dev server is running, audit all pages for WCAG 2.1 AA accessibility compliance: +```bash +stackwright test:a11y +``` +> Requires `playwright` and `@axe-core/playwright` as dev dependencies (`pnpm add -D playwright @axe-core/playwright`, then `pnpm exec playwright install chromium`). + +## Stackwright CLI + +Once inside your project, the `stackwright` CLI is available: + +- `stackwright prebuild` — Process YAML content and co-located images +- `stackwright test:a11y` — Run a WCAG 2.1 AA accessibility audit against your running dev server +- `stackwright page add ` — Create a new page +- `stackwright preview [slug]` — Render a page to a screenshot + +Run `stackwright --help` to see all available commands. + ## Prerequisites - **Node.js** 18+ and **pnpm** diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index 1bf343d6..d791e810 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -10,6 +10,7 @@ import { registerCollectionTools } from './tools/collections.js'; import { registerIntegrationTools } from './tools/integrations.js'; import { registerComposeTools } from './tools/compose.js'; import { registerRenderTools, closeBrowser } from './tools/render.js'; +import { registerA11yTools } from './tools/a11y.js'; import { version } from '../package.json'; const server = new McpServer({ @@ -27,6 +28,7 @@ registerCollectionTools(server); registerIntegrationTools(server); registerComposeTools(server); registerRenderTools(server); +registerA11yTools(server); // Clean up Playwright browser on exit process.on('SIGINT', async () => { diff --git a/packages/mcp/src/tools/a11y.ts b/packages/mcp/src/tools/a11y.ts new file mode 100644 index 00000000..08a7f0b6 --- /dev/null +++ b/packages/mcp/src/tools/a11y.ts @@ -0,0 +1,114 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { z } from 'zod'; +import { testA11y } from '@stackwright/cli'; +import type { A11yAuditResult } from '@stackwright/cli'; + +export function registerA11yTools(server: McpServer): void { + server.tool( + 'stackwright_test_a11y', + [ + 'Run a WCAG 2.1 AA accessibility audit against a running Stackwright dev server.', + 'Tests pages in both light and dark color modes using axe-core.', + 'Auto-discovers all pages from the project when no slugs are provided.', + 'Requires a running dev server (pnpm dev), playwright, and @axe-core/playwright.', + ].join(' '), + { + projectRoot: z.string().describe('Absolute path to the root of the Stackwright project'), + baseUrl: z.string().optional().describe('Dev server URL (default: http://localhost:3000)'), + slugs: z + .array(z.string()) + .optional() + .describe('Page slugs to audit (default: auto-discover all pages)'), + darkMode: z + .boolean() + .optional() + .describe('Test dark mode in addition to light mode (default: true)'), + tags: z + .array(z.string()) + .optional() + .describe('axe-core rule tags to test (default: wcag2a, wcag2aa, wcag21a, wcag21aa)'), + failOn: z + .enum(['minor', 'moderate', 'serious', 'critical']) + .optional() + .describe('Minimum impact level that fails the audit (default: serious)'), + }, + async ({ projectRoot, baseUrl, slugs, darkMode, tags, failOn }) => { + try { + const result = await testA11y(projectRoot, { + baseUrl, + pages: slugs?.join(','), + darkMode, + tags: tags?.join(','), + failOn, + }); + + const text = formatA11yResultForMcp(result); + return { + content: [{ type: 'text', text }], + isError: !result.pass, + }; + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + const message = (err as Error).message; + const userMessage = + code === 'NO_DEV_SERVER' + ? `No dev server running at ${baseUrl ?? 'http://localhost:3000'}. Start it with: pnpm dev` + : code === 'MISSING_PLAYWRIGHT' + ? 'Playwright is not installed. Run: pnpm add -D playwright && pnpm exec playwright install chromium' + : code === 'MISSING_AXE' + ? 'axe-core is not installed. Run: pnpm add -D @axe-core/playwright' + : code === 'NO_PAGES' + ? 'No pages found. Check that the project has been prebuilt (pnpm prebuild).' + : `Accessibility audit failed: ${message}`; + return { + content: [{ type: 'text', text: userMessage }], + isError: true, + }; + } + } + ); +} + +// --------------------------------------------------------------------------- +// Text formatter for MCP output +// --------------------------------------------------------------------------- + +function formatA11yResultForMcp(result: A11yAuditResult): string { + const { summary } = result; + const lines: string[] = []; + + lines.push(`♿ Accessibility Audit — ${result.pass ? '✓ PASSED' : '✗ FAILED'}`); + lines.push(`Base URL: ${result.baseUrl}`); + lines.push(`Modes tested: ${result.modes.join(', ')}`); + lines.push( + `Pages: ${summary.total} scans (${result.slugs.length} page${result.slugs.length !== 1 ? 's' : ''} × ${result.modes.length} mode${result.modes.length !== 1 ? 's' : ''})` + ); + lines.push(`Results: ${summary.passed} passed, ${summary.failed} failed`); + + if (summary.violations > 0) { + lines.push(`Total violations: ${summary.violations}`); + } + + lines.push(''); + + for (const pageResult of result.results) { + const icon = pageResult.pass ? '✓' : '✗'; + lines.push(`${icon} ${pageResult.slug} [${pageResult.mode}]`); + + if (!pageResult.pass) { + for (const v of pageResult.failingViolations) { + lines.push( + ` [${v.impact ?? 'unknown'}] ${v.id}: ${v.help} (${v.nodeCount} node${v.nodeCount !== 1 ? 's' : ''})` + ); + lines.push(` ${v.helpUrl}`); + } + } + } + + if (!result.pass) { + lines.push(''); + lines.push('Fix the violations above, then re-run stackwright_test_a11y to verify.'); + } + + return lines.join('\n'); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7653deac..53b75ea1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -201,6 +201,9 @@ importers: packages/cli: dependencies: + '@axe-core/playwright': + specifier: ^4.11.1 + version: 4.11.1(playwright-core@1.59.1) '@inquirer/prompts': specifier: ^8.4.3 version: 8.4.3(@types/node@24.12.3)