diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index fb53d901..0d3907e6 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 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 410fb747..25012857 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 7f3fd7ab..9c0ba7da 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 a93cd6b9..32296d74 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/pluginManager/index.ts b/.github/actions/find/src/pluginManager/index.ts index c6ec5d71..d48009f7 100644 --- a/.github/actions/find/src/pluginManager/index.ts +++ b/.github/actions/find/src/pluginManager/index.ts @@ -3,7 +3,9 @@ import * as path from 'path' import {fileURLToPath} from 'url' import * as core from '@actions/core' import {loadPluginViaJsFile, loadPluginViaTsFile} from './pluginFileLoaders.js' +import {loadPluginViaNpm} from './pluginNpmLoader.js' import type {Plugin, PluginDefaultParams} from './types.js' +import {getScansContext} from '../scansContextProvider.js' // Helper to get __dirname equivalent in ES Modules const __filename = fileURLToPath(import.meta.url) @@ -26,6 +28,7 @@ export async function loadPlugins() { core.info('loading plugins') await loadBuiltInPlugins() await loadCustomPlugins() + await loadNpmPlugins() } } catch { plugins.length = 0 @@ -47,6 +50,16 @@ export function clearCache() { plugins.length = 0 } +// True when the object is a usable plugin (exposes a name and default export). +function isValidPlugin(plugin: Plugin | undefined): plugin is Plugin { + return typeof plugin?.name === 'string' && typeof plugin.default === 'function' +} + +// True when a plugin with the same name is already loaded. +function isDuplicatePlugin(plugin: Plugin): boolean { + return plugins.some(existing => existing.name === plugin.name) +} + // exported for mocking/testing. not for actual use export async function loadBuiltInPlugins() { core.info('Loading built-in plugins') @@ -55,7 +68,7 @@ export async function loadBuiltInPlugins() { await loadPluginsFromPath({pluginsPath}) } -// exported for mocking/testing. not for actual use +// export to be used for mocking/testing. not for actual use export async function loadCustomPlugins() { core.info('Loading custom plugins') const pluginsPath = path.join(process.cwd(), '.github/scanner-plugins/') @@ -75,6 +88,52 @@ export async function loadCustomPlugins() { await loadPluginsFromPath({pluginsPath, skipBuiltInPlugins: BUILT_IN_PLUGINS}) } +// First-party packages allowed to be installed and loaded from NPM. +const FIRST_PARTY_NPM_PLUGINS = ['@github/accessibility-scanner-alt-text-plugin'] + +// exported for mocking/testing. not for actual use +export async function loadNpmPlugins() { + const {npmPlugins} = getScansContext() + if (npmPlugins.length === 0) { + return + } + core.info('Loading NPM plugins') + + for (const request of npmPlugins) { + // Only install first-party packages. + if (!FIRST_PARTY_NPM_PLUGINS.includes(request.package)) { + core.warning(`Skipping NPM plugin '${request.package}' because it is not a first-party package`) + continue + } + + const plugin = await loadPluginViaNpm(request) + if (!plugin) { + continue + } + + // Plugin doesn't expose a usable name/default export. + if (!isValidPlugin(plugin)) { + core.warning(`Skipping NPM plugin '${request.package}' because it does not export a valid plugin`) + continue + } + // Mismatch means the plugin would load but never run. + if (plugin.name !== request.name) { + core.warning( + `Skipping NPM plugin '${request.package}' because it exported name '${plugin.name}', which does not match requested name '${request.name}'`, + ) + continue + } + // Built-in and local plugins take precedence over NPM ones of the same name. + if (isDuplicatePlugin(plugin)) { + core.info(`Skipping NPM plugin '${plugin.name}' because a plugin with that name is already loaded`) + continue + } + + core.info(`Found NPM plugin: ${plugin.name}`) + plugins.push(plugin) + } +} + // exported for mocking/testing. not for actual use export async function loadPluginsFromPath({ pluginsPath, @@ -105,6 +164,15 @@ export async function loadPluginsFromPath({ continue } + if (!isValidPlugin(plugin)) { + core.warning(`Skipping plugin '${pluginFolder}' because it does not export a valid plugin`) + continue + } + if (isDuplicatePlugin(plugin)) { + core.warning(`Skipping plugin '${pluginFolder}' because a plugin named '${plugin.name}' is already loaded`) + continue + } + core.info(`Found plugin: ${plugin.name}`) plugins.push(plugin) } diff --git a/.github/actions/find/src/pluginManager/pluginNpmLoader.ts b/.github/actions/find/src/pluginManager/pluginNpmLoader.ts new file mode 100644 index 00000000..ce30524b --- /dev/null +++ b/.github/actions/find/src/pluginManager/pluginNpmLoader.ts @@ -0,0 +1,23 @@ +import {execFileSync} from 'child_process' +import * as core from '@actions/core' +import type {NpmPluginRequest, Plugin} from './types.js' + +// Install the package at runtime. +export function installNpmPackage(spec: string) { + execFileSync('npm', ['install', spec, '--no-save', '--no-package-lock', '--ignore-scripts'], {stdio: 'inherit'}) +} + +// Install and import a single NPM-published plugin +export async function loadPluginViaNpm(request: NpmPluginRequest): Promise { + const spec = request.version ? `${request.package}@${request.version}` : request.package + try { + core.info(`Installing NPM plugin: ${spec}`) + installNpmPackage(spec) + // Import the bare package specifier as-is; pathToFileURL would mangle it. + const imported = await import(request.package) + return imported as Plugin + } catch (e) { + core.warning(`Failed to load NPM plugin '${spec}': ${e}`) + return undefined + } +} diff --git a/.github/actions/find/src/pluginManager/types.ts b/.github/actions/find/src/pluginManager/types.ts index 8fbb315d..65b82469 100644 --- a/.github/actions/find/src/pluginManager/types.ts +++ b/.github/actions/find/src/pluginManager/types.ts @@ -10,3 +10,10 @@ export type Plugin = { name: string default: (options: PluginDefaultParams) => Promise } + +// A plugin requested from an NPM package rather than a local folder. +export type NpmPluginRequest = { + name: string + package: string + version?: string +} diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index 014c6d58..d9596e89 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -1,30 +1,66 @@ import * as core from '@actions/core' +import type {NpmPluginRequest} from './pluginManager/types.js' type ScansContext = { scansToPerform: Array + npmPlugins: NpmPluginRequest[] shouldPerformAxeScan: boolean + shouldPerformAccesslintScan: boolean shouldRunPlugins: boolean } let scansContext: ScansContext | undefined +// A scans entry is either a plain name (core engine or local plugin folder) or +// an object describing an NPM-published plugin to install and load. +type ScanEntry = string | {name?: unknown; package?: unknown; version?: unknown} + 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') + const parsed = JSON.parse(scansInput || '[]') + if (!Array.isArray(parsed)) { + throw new Error(`'scans' input must be a JSON array, got: ${scansInput}`) + } + const rawScans = parsed as ScanEntry[] + + // scansToPerform holds the name of every scan/plugin to run + const scansToPerform: string[] = [] + const npmPlugins: NpmPluginRequest[] = [] + for (const entry of rawScans) { + if (typeof entry === 'string') { + scansToPerform.push(entry) + } else if ( + typeof entry === 'object' && + entry !== null && + typeof entry.name === 'string' && + typeof entry.package === 'string' + ) { + scansToPerform.push(entry.name) + npmPlugins.push({ + name: entry.name, + package: entry.package, + version: typeof entry.version === 'string' ? entry.version : undefined, + }) + } else { + core.warning(`Ignoring invalid 'scans' entry: ${JSON.stringify(entry)}`) + } + } + + // '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, + npmPlugins, // - 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 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 31a00b3f..b5efa490 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()}, @@ -116,6 +160,15 @@ describe('findForUrl', () => { expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0) }) + + it('runs plugins when a scans entry is an object-form NPM plugin', async () => { + loadedPlugins = [] + actionInput = JSON.stringify([{name: 'alt-text-scan', package: '@github/accessibility-scanner-alt-text-plugin'}]) + clearAll() + + await findForUrl('test.com') + expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(1) + }) }) it('captures every failing element of an axe violation as nodes', async () => { diff --git a/.github/actions/find/tests/pluginNpmLoader.test.ts b/.github/actions/find/tests/pluginNpmLoader.test.ts new file mode 100644 index 00000000..e96f2e44 --- /dev/null +++ b/.github/actions/find/tests/pluginNpmLoader.test.ts @@ -0,0 +1,116 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' + +import * as childProcess from 'child_process' +import * as core from '@actions/core' +import * as pluginManager from '../src/pluginManager/index.js' +import * as npmPluginLoader from '../src/pluginManager/pluginNpmLoader.js' +import * as scansContextProvider from '../src/scansContextProvider.js' +import type {Plugin, NpmPluginRequest} from '../src/pluginManager/types.js' + +vi.mock('child_process', {spy: true}) +vi.mock('@actions/core', {spy: true}) +vi.mock('../src/pluginManager/pluginNpmLoader.js', {spy: true}) +vi.mock('../src/scansContextProvider.js', {spy: true}) + +const ALLOWED = '@github/accessibility-scanner-alt-text-plugin' + +function mockNpmPlugins(npmPlugins: NpmPluginRequest[]) { + vi.spyOn(scansContextProvider, 'getScansContext').mockReturnValue({ + scansToPerform: npmPlugins.map(plugin => plugin.name), + npmPlugins, + shouldPerformAxeScan: false, + shouldRunPlugins: true, + }) +} + +describe('npmPluginLoader', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + }) + + describe('installNpmPackage', () => { + it('installs with --no-save, --no-package-lock and --ignore-scripts', () => { + const execSpy = vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => Buffer.from('')) + npmPluginLoader.installNpmPackage('some-pkg@1.0.0') + expect(execSpy).toHaveBeenCalledWith( + 'npm', + ['install', 'some-pkg@1.0.0', '--no-save', '--no-package-lock', '--ignore-scripts'], + { + stdio: 'inherit', + }, + ) + }) + }) + + describe('loadPluginViaNpm', () => { + it('pins the version in the install spec', async () => { + const execSpy = vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => Buffer.from('')) + await npmPluginLoader.loadPluginViaNpm({name: 'p', package: 'nonexistent-pkg-xyz', version: '2.3.4'}) + expect(execSpy).toHaveBeenCalledWith( + 'npm', + ['install', 'nonexistent-pkg-xyz@2.3.4', '--no-save', '--no-package-lock', '--ignore-scripts'], + {stdio: 'inherit'}, + ) + }) + + it('returns undefined and warns when loading fails', async () => { + vi.spyOn(childProcess, 'execFileSync').mockImplementation(() => Buffer.from('')) + const warnSpy = vi.spyOn(core, 'warning').mockImplementation(() => {}) + const plugin = await npmPluginLoader.loadPluginViaNpm({name: 'p', package: 'nonexistent-pkg-xyz'}) + expect(plugin).toBeUndefined() + expect(warnSpy).toHaveBeenCalled() + }) + }) +}) + +describe('loadNpmPlugins', () => { + beforeEach(() => { + vi.restoreAllMocks() + vi.clearAllMocks() + pluginManager.clearCache() + }) + + it('loads a plugin from a first-party package', async () => { + vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'alt-text-scan', default: vi.fn()}) + mockNpmPlugins([{name: 'alt-text-scan', package: ALLOWED}]) + await pluginManager.loadNpmPlugins() + expect(pluginManager.getPlugins().map(plugin => plugin.name)).toContain('alt-text-scan') + }) + + it('skips and warns when a package is not first-party', async () => { + const loadSpy = vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue(undefined) + const warnSpy = vi.spyOn(core, 'warning').mockImplementation(() => {}) + mockNpmPlugins([{name: 'evil', package: 'evil-pkg'}]) + await pluginManager.loadNpmPlugins() + expect(loadSpy).not.toHaveBeenCalled() + expect(warnSpy).toHaveBeenCalled() + expect(pluginManager.getPlugins().length).toBe(0) + }) + + it('skips a package that does not export a valid plugin', async () => { + vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'bad'} as unknown as Plugin) + const warnSpy = vi.spyOn(core, 'warning').mockImplementation(() => {}) + mockNpmPlugins([{name: 'bad', package: ALLOWED}]) + await pluginManager.loadNpmPlugins() + expect(warnSpy).toHaveBeenCalled() + expect(pluginManager.getPlugins().length).toBe(0) + }) + + it('skips an NPM plugin whose exported name does not match the requested name', async () => { + vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'actual-name', default: vi.fn()}) + const warnSpy = vi.spyOn(core, 'warning').mockImplementation(() => {}) + mockNpmPlugins([{name: 'requested-name', package: ALLOWED}]) + await pluginManager.loadNpmPlugins() + expect(warnSpy).toHaveBeenCalled() + expect(pluginManager.getPlugins().length).toBe(0) + }) + + it('skips an NPM plugin whose name collides with an already-loaded plugin', async () => { + pluginManager.getPlugins().push({name: 'dup', default: vi.fn()}) + vi.spyOn(npmPluginLoader, 'loadPluginViaNpm').mockResolvedValue({name: 'dup', default: vi.fn()}) + mockNpmPlugins([{name: 'dup', package: ALLOWED}]) + await pluginManager.loadNpmPlugins() + expect(pluginManager.getPlugins().filter(plugin => plugin.name === 'dup').length).toBe(1) + }) +}) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 663d3e7e..e994eaec 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,7 +26,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Node - uses: actions/setup-node@v6 + uses: actions/setup-node@v7 with: node-version: 24 cache: npm diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 22449730..7624ff03 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Ruby - uses: ruby/setup-ruby@9eb537ca036ebaed86729dcb9309076e4c5c3b74 + uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 with: ruby-version: '3.4' bundler-cache: true diff --git a/AXE_VS_ACCESSLINT.md b/AXE_VS_ACCESSLINT.md new file mode 100644 index 00000000..a6197d89 --- /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/PLUGINS.md b/PLUGINS.md index 7f3058fa..31f66db7 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -43,6 +43,36 @@ jobs: # ... the rest of the workflow setup ``` +## Loading plugins from NPM packages + +In addition to local plugins under `./.github/scanner-plugins`, the scanner can install and load plugins published as NPM packages. This avoids having to vendor a plugin's source into your repo. + +To use an NPM plugin, pass an object (instead of a plain string) in the `scans` input with the following fields: + +- `name` — the plugin name exported by the package (used to match against `scans`, same as local plugins). +- `package` — the NPM package name to install. +- `version` — (optional) a version or dist-tag to pin. If omitted, the latest version is installed. + +Only the set of [first-party packages](.github/actions/find/src/pluginManager/index.ts#L91) may be loaded from NPM. Any other package is skipped with a warning. + +```yaml +jobs: + accessibility_scanner: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: github/accessibility-scanner@v3 + with: + scans: | + ["axe", {"name": "alt-text-scan", "package": "@github/accessibility-scanner-alt-text-plugin", "version": "1.0.0"}] +``` + +Notes: + +- Packages are installed at runtime with `npm install --ignore-scripts`, so install/postinstall scripts in the package will not run. Pin a `version` to avoid silently picking up future releases. +- If an NPM plugin shares a name with a built-in or local plugin, the built-in/local plugin wins and the NPM one is skipped. +- Plugin configuration works the same as local plugins: place a config-only folder at `./.github/scanner-plugins//config.json` in your repo (the plugin reads its config relative to the repo you run the workflow from). + ## Things to look out for - Plugin names should be unique. If multiple plugins have the same name, and the `scans` input array contains this name, all the plugins with that name _will_ run. However, this is not advised because if you want to turn off one plugin, you'll have to go back and change that plugin name. diff --git a/README.md b/README.md index 0e34735d..8caf4f9b 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"]}]'` | @@ -180,7 +180,18 @@ The a11y scanner leverages GitHub Copilot coding agent, which can be configured ## Plugins -See the [plugin docs](https://github.com/github/accessibility-scanner/tree/main/PLUGINS.md) for more information +See the [plugin docs](https://github.com/github/accessibility-scanner/tree/main/PLUGINS.md) for more information, including how to load plugins published to npm. + +### Alt Text Plugin + +The [Alt Text Plugin](https://github.com/github/accessibility-scanner-alt-text-plugin) is a first-party scanner plugin published to npm as [`@github/accessibility-scanner-alt-text-plugin`](https://www.npmjs.com/package/@github/accessibility-scanner-alt-text-plugin). It flags low-quality `alt` text that can pass Axe's `image-alt` rule, including vague or generic descriptions, raw filenames, repeated text, and placeholder values. The scanner installs the package at runtime; to enable it, add the plugin to the `scans` input using its package name: + +```yaml +scans: | + ["axe", {"name": "alt-text-scan", "package": "@github/accessibility-scanner-alt-text-plugin", "version": "1.0.0"}] +``` + +See the [plugin README](https://github.com/github/accessibility-scanner-alt-text-plugin#getting-started) for the current release version, full rule list, and setup instructions. --- diff --git a/package-lock.json b/package-lock.json index ac6f3f5f..b1b12d40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1168,9 +1168,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.3.tgz", - "integrity": "sha512-fy6KJm2RawA5RcHkLa1z/ScpBeA762UF9KmZQxwIbDtRJrgLzM10depAiEQ+CXYcoiqW1/m96OAAoke2nE9EeA==", + "version": "5.0.7", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", + "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "dev": true, "license": "MIT", "dependencies": {