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
49 changes: 42 additions & 7 deletions .github/actions/find/package-lock.json

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

2 changes: 2 additions & 0 deletions .github/actions/find/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
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, 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 @@ -98,3 +103,32 @@ 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;',
),
})
}
}
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'.
```

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

Expand Down