diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index 3e66cd6..0d3907e 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. Each entry is either a scan/plugin name (string) or, for a first-party NPM-published plugin, an object with 'name', 'package', and optional 'version'. 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 either a plugin name (string) or, for a first-party NPM-published plugin, an object with 'name', 'package', and optional 'version'. 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 81858bd..d9596e8 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -5,6 +5,7 @@ type ScansContext = { scansToPerform: Array npmPlugins: NpmPluginRequest[] shouldPerformAxeScan: boolean + shouldPerformAccesslintScan: boolean shouldRunPlugins: boolean } let scansContext: ScansContext | undefined @@ -45,11 +46,10 @@ export function getScansContext() { } } - // - 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 (local folder or NPM-published plugin). + const coreEngines = ['axe', 'accesslint'] + const pluginScans = scansToPerform.filter(scan => !coreEngines.includes(scan)) scansContext = { scansToPerform, @@ -59,7 +59,8 @@ export function getScansContext() { // - we can enforce using the 'scans' input in a future major release and // mark it as required 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 8af9f4a..b5efa49 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..a6197d8 --- /dev/null +++ b/AXE_VS_ACCESSLINT.md @@ -0,0 +1,46 @@ +# 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 as well as some best practices suggestions (unless disabled), 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. + +> [!TIP] +> 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"]}]'` |