From 52e401a522814d764492d6fb2e632ed350ac5b83 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 13:19:23 -0700 Subject: [PATCH 1/4] Add AccessLint scan engine --- .github/actions/find/action.yml | 2 +- .github/actions/find/package-lock.json | 49 ++++++++++++++++--- .github/actions/find/package.json | 2 + .github/actions/find/src/findForUrl.ts | 30 ++++++++++++ .../actions/find/src/scansContextProvider.ts | 18 +++---- .github/actions/find/tests/findForUrl.test.ts | 44 +++++++++++++++++ 6 files changed, 127 insertions(+), 18 deletions(-) diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index fb53d901..e437ea35 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 410fb747..c98d5378 100644 --- a/.github/actions/find/package-lock.json +++ b/.github/actions/find/package-lock.json @@ -9,8 +9,10 @@ "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" }, @@ -19,6 +21,24 @@ "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.61.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz", + "integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==", + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.61.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", @@ -557,12 +592,12 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz", + "integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==", "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.61.0" }, "bin": { "playwright": "cli.js" @@ -575,9 +610,9 @@ } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.61.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz", + "integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==", "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" diff --git a/.github/actions/find/package.json b/.github/actions/find/package.json index 7f3fd7ab..4b0ce269 100644 --- a/.github/actions/find/package.json +++ b/.github/actions/find/package.json @@ -13,8 +13,10 @@ "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" }, diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index d9f1ea87..7eec1f56 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -1,5 +1,6 @@ import type {ColorSchemePreference, Finding, 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}`) } @@ -98,3 +103,28 @@ 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}\``, + }) + } +} diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index 014c6d58..c8abfb0a 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 85299c5c..f23508cb 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('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('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('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()}, From d07f5eb151ceb618eed6aa70692de27c44b114a0 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 13:40:07 -0700 Subject: [PATCH 2/4] Document AccessLint engine in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a20261e7..9af2c9d6 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,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'. ``` @@ -131,7 +131,7 @@ Trigger the workflow manually or automatically based on your configuration. The | `open_grouped_issues` | No | Whether to create a tracking issue which groups filed issues together by violation type. Default: `false` | `true` | | `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"]}]'` | From 41f49ecc68f54ba622173079352d1e842496f786 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 13:49:51 -0700 Subject: [PATCH 3/4] Apply suggestions from code review (escape AccessLint solutionShort and pass UrlConfig in tests) Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .github/actions/find/src/findForUrl.ts | 2 +- .github/actions/find/tests/findForUrl.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index 7eec1f56..832201f5 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -124,7 +124,7 @@ async function runAccesslintScan({ 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}\``, + solutionShort: `resolve the ${violation.ruleId} violation that accesslint flagged on \`${violation.selector}\``.replace(/'/g, '''), }) } } diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index f23508cb..17cfd8bd 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -115,7 +115,7 @@ describe('findForUrl', () => { actionInput = JSON.stringify(['accesslint']) clearAll() - await findForUrl('test.com') + await findForUrl({url: 'test.com'}) expect(accesslintAudit).toHaveBeenCalledTimes(1) expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(0) expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) @@ -125,7 +125,7 @@ describe('findForUrl', () => { actionInput = JSON.stringify(['axe', 'accesslint']) clearAll() - await findForUrl('test.com') + await findForUrl({url: 'test.com'}) expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) expect(accesslintAudit).toHaveBeenCalledTimes(1) expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) @@ -140,7 +140,7 @@ describe('findForUrl', () => { actionInput = JSON.stringify(['accesslint', 'custom-scan-1']) clearAll() - await findForUrl('test.com') + await findForUrl({url: 'test.com'}) expect(accesslintAudit).toHaveBeenCalledTimes(1) expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(1) expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) From 0432b26ea98a25a98f4154913e3e86097630de81 Mon Sep 17 00:00:00 2001 From: Keenan Zhou Date: Wed, 24 Jun 2026 13:55:11 -0700 Subject: [PATCH 4/4] Fix formatting --- .github/actions/find/src/findForUrl.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index 832201f5..da95a440 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -124,7 +124,11 @@ async function runAccesslintScan({ 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, '''), + solutionShort: + `resolve the ${violation.ruleId} violation that accesslint flagged on \`${violation.selector}\``.replace( + /'/g, + ''', + ), }) } }