diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index fb53d90..e437ea3 100644 --- a/.github/actions/find/action.yml +++ b/.github/actions/find/action.yml @@ -17,7 +17,7 @@ inputs: required: false default: 'false' scans: - description: 'Stringified JSON array of scans to perform. If not provided, only Axe will be performed' + description: "Stringified JSON array of scans to perform. Core engines are 'axe' and 'accesslint'; any other entry is treated as a plugin name. If not provided, only Axe will be performed" required: false reduced_motion: description: 'Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion' diff --git a/.github/actions/find/package-lock.json b/.github/actions/find/package-lock.json index 410fb74..2501285 100644 --- a/.github/actions/find/package-lock.json +++ b/.github/actions/find/package-lock.json @@ -9,16 +9,36 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@accesslint/playwright": "^0.5.0", "@actions/core": "^3.0.1", "@axe-core/playwright": "^4.11.3", + "@playwright/test": "1.60.0", "esbuild": "^0.28.0", - "playwright": "^1.60.0" + "playwright": "1.60.0" }, "devDependencies": { "@types/node": "^25.9.0", "typescript": "^6.0.3" } }, + "node_modules/@accesslint/core": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@accesslint/core/-/core-0.13.0.tgz", + "integrity": "sha512-l1R6if3qsqevQjcTdZsilnu2IBO6G6ZXaYbpYmd1tL8vgwATQ57fDKaWltdrMeRQToh0yOdpjiTORMFObfCYbA==", + "license": "MIT" + }, + "node_modules/@accesslint/playwright": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@accesslint/playwright/-/playwright-0.5.0.tgz", + "integrity": "sha512-vSMOqmMkAF8mBDYUFN1tq567PpnTj4QWEGEAvBQeQmw8GWj5mNovd8tsXJkOwVirZNmEnNhNnfI0yt/+dfcrnw==", + "license": "MIT", + "dependencies": { + "@accesslint/core": "0.13.0" + }, + "peerDependencies": { + "@playwright/test": ">=1.40.0" + } + }, "node_modules/@actions/core": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@actions/core/-/core-3.0.1.tgz", @@ -482,6 +502,21 @@ "node": ">=18" } }, + "node_modules/@playwright/test": { + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.60.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { "version": "25.9.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.0.tgz", diff --git a/.github/actions/find/package.json b/.github/actions/find/package.json index 7f3fd7a..9c0ba7d 100644 --- a/.github/actions/find/package.json +++ b/.github/actions/find/package.json @@ -13,13 +13,18 @@ "license": "MIT", "type": "module", "dependencies": { + "@accesslint/playwright": "^0.5.0", "@actions/core": "^3.0.1", "@axe-core/playwright": "^4.11.3", + "@playwright/test": "1.60.0", "esbuild": "^0.28.0", - "playwright": "^1.60.0" + "playwright": "1.60.0" }, "devDependencies": { "@types/node": "^25.9.0", "typescript": "^6.0.3" + }, + "overrides": { + "playwright-core": "1.60.0" } } diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index a93cd6b..32296d7 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -1,5 +1,6 @@ import type {ColorSchemePreference, Finding, FindingCategory, ReducedMotionPreference, UrlConfig} from './types.d.js' import {AxeBuilder} from '@axe-core/playwright' +import {accesslintAudit} from '@accesslint/playwright' import playwright from 'playwright' import {AuthContext} from './AuthContext.js' import {generateScreenshots} from './generateScreenshots.js' @@ -59,6 +60,10 @@ export async function findForUrl( if (scansContext.shouldPerformAxeScan) { await runAxeScan({page, addFinding, excludeSelectors}) } + + if (scansContext.shouldPerformAccesslintScan) { + await runAccesslintScan({page, addFinding}) + } } catch (e) { core.error(`Error during accessibility scan: ${e}`) } @@ -105,6 +110,35 @@ async function runAxeScan({ } } +async function runAccesslintScan({ + page, + addFinding, +}: { + page: playwright.Page + addFinding: (findingData: Finding, options?: {includeScreenshots?: boolean}) => Promise +}) { + const url = page.url() + core.info(`Scanning ${url} with AccessLint`) + + // One violation per element; no per-rule docs URL, so problemUrl is the core rules table + const {violations} = await accesslintAudit(page as Parameters[0]) + for (const violation of violations) { + await addFinding({ + scannerType: 'accesslint', + url, + html: violation.html.replace(/'/g, '''), + problemShort: violation.message.toLowerCase().replace(/'/g, '''), + problemUrl: 'https://github.com/AccessLint/accesslint/blob/main/core/README.md#rules-1', + ruleId: violation.ruleId, + solutionShort: + `resolve the ${violation.ruleId} violation that accesslint flagged on \`${violation.selector}\``.replace( + /'/g, + ''', + ), + }) + } +} + // Maps an Axe violation's tags to a conformance tier. Experimental is checked // first because some experimental rules also carry a wcag* tag. function categorizeAxeViolation(tags: string[]): FindingCategory { diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index 014c6d5..c8abfb0 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -3,6 +3,7 @@ import * as core from '@actions/core' type ScansContext = { scansToPerform: Array shouldPerformAxeScan: boolean + shouldPerformAccesslintScan: boolean shouldRunPlugins: boolean } let scansContext: ScansContext | undefined @@ -11,20 +12,17 @@ export function getScansContext() { if (!scansContext) { const scansInput = core.getInput('scans', {required: false}) const scansToPerform = JSON.parse(scansInput || '[]') - // - if we don't have a scans input - // or we do have a scans input, but it only has 1 item and its 'axe' - // then we only want to run 'axe' and not the plugins - // - keep in mind, 'onlyAxeScan' is not the same as 'shouldPerformAxeScan' - const onlyAxeScan = scansToPerform.length === 0 || (scansToPerform.length === 1 && scansToPerform[0] === 'axe') + // 'axe' and 'accesslint' are built-in core engines; anything else in the + // list is treated as a plugin name. + const coreEngines = ['axe', 'accesslint'] + const pluginScans = scansToPerform.filter((scan: string) => !coreEngines.includes(scan)) scansContext = { scansToPerform, - // - if no 'scans' input is provided, we default to the existing behavior - // (only axe scan) for backwards compatability. - // - we can enforce using the 'scans' input in a future major release and - // mark it as required + // No 'scans' input keeps the existing axe-only default for backwards compatibility. shouldPerformAxeScan: !scansInput || scansToPerform.includes('axe'), - shouldRunPlugins: scansToPerform.length > 0 && !onlyAxeScan, + shouldPerformAccesslintScan: scansToPerform.includes('accesslint'), + shouldRunPlugins: pluginScans.length > 0, } } diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index 31a00b3..1382f2f 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -2,6 +2,7 @@ import {describe, it, expect, vi} from 'vitest' import * as core from '@actions/core' import {findForUrl} from '../src/findForUrl.js' import {AxeBuilder} from '@axe-core/playwright' +import {accesslintAudit} from '@accesslint/playwright' import axe from 'axe-core' import * as pluginManager from '../src/pluginManager/index.js' import type {Plugin} from '../src/pluginManager/types.js' @@ -33,6 +34,10 @@ vi.mock('@axe-core/playwright', () => { return {AxeBuilder: AxeBuilderMock} }) +vi.mock('@accesslint/playwright', () => ({ + accesslintAudit: vi.fn(() => Promise.resolve({violations: []})), +})) + let actionInput: string = '' let loadedPlugins: Plugin[] = [] @@ -51,6 +56,7 @@ describe('findForUrl', () => { await findForUrl('test.com') expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) + expect(accesslintAudit).toHaveBeenCalledTimes(0) expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(0) } @@ -104,6 +110,44 @@ describe('findForUrl', () => { }) }) + describe('and the list includes accesslint', () => { + it('runs only the accesslint scan when it is the only entry', async () => { + actionInput = JSON.stringify(['accesslint']) + clearAll() + + await findForUrl({url: 'test.com'}) + expect(accesslintAudit).toHaveBeenCalledTimes(1) + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(0) + expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) + }) + + it('runs alongside axe when both are listed', async () => { + actionInput = JSON.stringify(['axe', 'accesslint']) + clearAll() + + await findForUrl({url: 'test.com'}) + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) + expect(accesslintAudit).toHaveBeenCalledTimes(1) + expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) + }) + + it('is treated as a core engine and runs alongside plugins', async () => { + loadedPlugins = [ + {name: 'custom-scan-1', default: vi.fn()}, + {name: 'custom-scan-2', default: vi.fn()}, + ] + + actionInput = JSON.stringify(['accesslint', 'custom-scan-1']) + clearAll() + + await findForUrl({url: 'test.com'}) + expect(accesslintAudit).toHaveBeenCalledTimes(1) + expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(1) + expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) + expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0) + }) + }) + it('should only run scans that are included in the list', async () => { loadedPlugins = [ {name: 'custom-scan-1', default: vi.fn()}, diff --git a/AXE_VS_ACCESSLINT.md b/AXE_VS_ACCESSLINT.md new file mode 100644 index 0000000..7277c69 --- /dev/null +++ b/AXE_VS_ACCESSLINT.md @@ -0,0 +1,45 @@ +# axe vs. AccessLint + +The a11y scanner ships two built-in scan engines: [axe-core](https://github.com/dequelabs/axe-core) (the default) and [AccessLint](https://github.com/AccessLint/accesslint), a newer, lightweight ruleset. Both run against the live page and report WCAG violations, and the two mostly overlap. axe is the mature, well-documented baseline; AccessLint adds a small set of checks axe doesn't run by default. This document covers what's different and provides advice on which to enable. + +> 👉 Pick engines with the `scans` input. They run independently and file their findings as separate issues. + +## Three ways to run + +| `scans` | Runs | Best when | +| ------------------------ | --------------- | -------------------------------------------------------------------------------------------------- | +| _(omitted)_ or `["axe"]` | axe only | the mature default; lowest noise | +| `["accesslint"]` | AccessLint only | you want AccessLint's extra checks without axe's duplicates, but you give up axe's maturity and Deque docs | +| `["axe","accesslint"]` | both | maximum coverage, at the cost of duplicate findings | + +## At a glance + +| | axe-core | AccessLint | +| ----------------- | --------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | +| **Strength** | Mature, widely adopted, well-documented baseline | Lightweight second pass; adds a few checks axe doesn't run by default | +| **Coverage** | WCAG 2.0/2.1/2.2 (A/AA/AAA) tags plus best practices; some 2.2 rules are off by default | WCAG 2.2 A/AA plus best-practice rules | +| **Per-rule docs** | Deque University help URL on every finding | Rule IDs + messages; less rich public per-rule docs | +| **Main risk** | Doesn't catch every WCAG issue automatically (~57%) | Newer and less battle-tested; smaller ecosystem | + +Both cover the same large core: alt text, link/button names, form labels, ARIA validity, heading order, landmarks, table headers, language attributes, and color contrast. + +## What AccessLint adds + +The engines share most of their rules (AccessLint ships ~93, axe ~104, and the bulk map to the same checks). Measured against the scanner's **default** axe run, these are the only AccessLint checks with no axe rule that fires. See the [AccessLint rules reference](https://github.com/AccessLint/accesslint/blob/main/core/README.md#rules-1) for the full catalog. + +**No axe rule covers these:** + +- **Visible focus indicator** (2.4.7 AA) — `keyboard-accessible/focus-visible`: focusable elements must show a visible focus indicator. +- **Accessible authentication** (3.3.8 AA) — `input-assistance/accessible-authentication`: password fields must not block password managers / paste. +- **Generic alt wording** — `text-alternatives/image-alt-words`: alt text shouldn't be "image", "photo", etc. (axe only flags alt that _duplicates adjacent text_). +- **Presentational element with focusable children** — `aria/presentational-children-focusable` (adjacent to axe's `presentation-role-conflict`, but a separate check). + +**axe has the rule, but only as _experimental_, so the scanner's default run skips it:** + +- **Orientation lock** (1.3.4 AA) — `adaptable/orientation-lock` ↔ axe `css-orientation-lock`. +- **Paragraph styled as a heading** — `navigable/p-as-heading` ↔ axe `p-as-heading`. +- **Accessible name missing visible label text** (2.5.3) — `labels-and-names/label-content-mismatch` ↔ axe `label-content-name-mismatch`. +- **Focusable element without a semantic role** — `keyboard-accessible/focus-order` ↔ axe `focus-order-semantics`. +- **Large-table data cell without a header** — `adaptable/td-has-header` ↔ axe `td-has-header`. + +Everything else overlaps. Even where rule IDs differ the checks coincide — e.g. 1.4.12 text spacing (axe's single `avoid-inline-spacing` vs AccessLint's `letter-spacing` / `line-height` / `word-spacing`) and 1.2.1 audio (axe `audio-caption` vs AccessLint `audio-transcript`). diff --git a/README.md b/README.md index 0e34735..052bf17 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ jobs: # dry_run: false # Optional: Set to true to scan and log what would be filed without creating/closing issues or writing the cache # reduced_motion: no-preference # Optional: Playwright reduced motion configuration option # color_scheme: light # Optional: Playwright color scheme configuration option - # scans: '["axe","reflow-scan"]' # Optional: An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. + # scans: '["axe","accesslint","reflow-scan"]' # Optional: An array of scans (or plugins) to be performed. Built-in engines are 'axe' and 'accesslint'; any other entry is a plugin name. If not provided, only Axe will be performed. # url_configs: '[{"url":"https://example.com","excludeSelectors":["iframe","#widget"]}]' # Optional: Per-URL config with CSS selectors to exclude from the Axe scan. When provided, takes precedence over 'urls'. ``` @@ -137,7 +137,7 @@ Trigger the workflow manually or automatically based on your configuration. The | `file_experimental_issues` | No | Whether to file issues for experimental findings (checks that are not yet stable). Set to `false` to suppress new experimental issues; existing ones are left untouched. Default: `true` | `false` | | `reduced_motion` | No | Playwright `reducedMotion` setting for scan contexts. Allowed values: `reduce`, `no-preference` | `reduce` | | `color_scheme` | No | Playwright `colorScheme` setting for scan contexts. Allowed values: `light`, `dark`, `no-preference` | `dark` | -| `scans` | No | An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. | `'["axe", "reflow-scan", ...other plugins]'` | +| `scans` | No | An array of scans (or plugins) to be performed. Built-in engines are `axe` and `accesslint`; any other entry is treated as a plugin name. If not provided, only Axe will be performed. | `'["axe", "accesslint", ...other plugins]'` | | `dry_run` | No | When `true`, scan and log the issues that _would_ be filed without opening, closing, reopening, or assigning any issues — and without writing to the `gh-cache` branch. Useful for safely previewing results. Default: `false` | `true` | | `url_configs` | No | A stringified JSON array of URL config objects. Each object must have a `url` field and may have an optional `excludeSelectors` field (array of CSS selectors to exclude from the Axe scan for that URL). When provided, takes precedence over the `urls` input. | `'[{"url":"https://example.com","excludeSelectors":["iframe","#widget"]}]'` |