Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/actions/find/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
37 changes: 36 additions & 1 deletion .github/actions/find/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 6 additions & 1 deletion .github/actions/find/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
34 changes: 34 additions & 0 deletions .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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}`)
}
Expand Down Expand Up @@ -105,6 +110,35 @@ async function runAxeScan({
}
}

async function runAccesslintScan({
page,
addFinding,
}: {
page: playwright.Page
addFinding: (findingData: Finding, options?: {includeScreenshots?: boolean}) => Promise<void>
}) {
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<typeof accesslintAudit>[0])
for (const violation of violations) {
await addFinding({
scannerType: 'accesslint',
url,
html: violation.html.replace(/'/g, '&apos;'),
problemShort: violation.message.toLowerCase().replace(/'/g, '&apos;'),
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,
'&apos;',
),
})
}
}

// 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 {
Expand Down
18 changes: 8 additions & 10 deletions .github/actions/find/src/scansContextProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as core from '@actions/core'
type ScansContext = {
scansToPerform: Array<string>
shouldPerformAxeScan: boolean
shouldPerformAccesslintScan: boolean
shouldRunPlugins: boolean
}
let scansContext: ScansContext | undefined
Expand All @@ -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']
Comment thread
kzhou314 marked this conversation as resolved.
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,
}
}

Expand Down
44 changes: 44 additions & 0 deletions .github/actions/find/tests/findForUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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[] = []

Expand All @@ -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)
}
Expand Down Expand Up @@ -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()},
Expand Down
45 changes: 45 additions & 0 deletions AXE_VS_ACCESSLINT.md
Original file line number Diff line number Diff line change
@@ -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`).
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
```

Expand Down Expand Up @@ -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"]}]'` |

Expand Down