Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
21 changes: 10 additions & 11 deletions .github/actions/file/src/generateIssueBody.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,21 +19,20 @@ export function generateIssueBody(finding: Finding, screenshotRepo: string): str
}

const acceptanceCriteria = `## Acceptance Criteria
- [ ] The specific axe violation reported in this issue is no longer reproducible.
- [ ] The fix MUST meet WCAG 2.1 guidelines OR the accessibility standards specified by the repository or organization.
- [ ] A test SHOULD be added to ensure this specific axe violation does not regress.
- [ ] This PR MUST NOT introduce any new accessibility issues or regressions.
`
- [ ] The specific violation reported in this issue is no longer reproducible.
- [ ] The fix MUST meet WCAG 2.1 guidelines OR the accessibility standards specified by the repository or organization.
- [ ] A test SHOULD be added to ensure this specific violation does not regress.
- [ ] This PR MUST NOT introduce any new accessibility issues or regressions.`

const body = `## What
An accessibility scan flagged the element \`${finding.html}\` on ${finding.url} because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}.
An accessibility scan ${finding.html ? `flagged the element \`${finding.html}\`` : `found an issue on ${finding.url}`} because ${finding.problemShort}. Learn more about why this was flagged by visiting ${finding.problemUrl}.

${screenshotSection ?? ''}
To fix this, ${finding.solutionShort}.
${solutionLong ? `\nSpecifically:\n\n${solutionLong}` : ''}
${screenshotSection ?? ''}
To fix this, ${finding.solutionShort}.
${solutionLong ? `\nSpecifically:\n\n${solutionLong}` : ''}

${acceptanceCriteria}
`
${acceptanceCriteria}
`

return body
}
6 changes: 5 additions & 1 deletion .github/actions/file/src/openIssue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ export async function openIssue(octokit: Octokit, repoWithOwner: string, finding
const owner = repoWithOwner.split('/')[0]
const repo = repoWithOwner.split('/')[1]

const labels = [`${finding.scannerType} rule: ${finding.ruleId}`, `${finding.scannerType}-scanning-issue`]
// Only include a ruleId label when it's defined
const labels = [
Comment thread
lindseywild marked this conversation as resolved.
Outdated
`${finding.scannerType}-scanning-issue`,
...(finding.ruleId ? [`${finding.scannerType} rule: ${finding.ruleId}`] : []),
]
const title = truncateWithEllipsis(
`Accessibility issue: ${finding.problemShort[0].toUpperCase() + finding.problemShort.slice(1)} on ${new URL(finding.url).pathname}`,
GITHUB_ISSUE_TITLE_MAX_LENGTH,
Expand Down
4 changes: 2 additions & 2 deletions .github/actions/file/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export type Finding = {
scannerType: string
ruleId: string
ruleId?: string
url: string
html: string
html?: string
problemShort: string
problemUrl: string
solutionShort: string
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/file/src/updateFilingsWithNewFindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ function getFilingKey(filing: ResolvedFiling | RepeatedFiling): string {
}

function getFindingKey(finding: Finding): string {
return `${finding.url};${finding.ruleId};${finding.html}`
return `${finding.url};${finding.scannerType};${finding.problemUrl}`
Comment thread
lindseywild marked this conversation as resolved.
}

export function updateFilingsWithNewFindings(
Expand Down
17 changes: 16 additions & 1 deletion .github/actions/file/tests/generateIssueBody.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,21 @@ const baseFinding = {
solutionShort: 'ensure the contrast between foreground and background colors meets WCAG thresholds',
}

const findingWithEmptyOptionalFields = {
scannerType: 'reflow',
url: 'https://example.com/page',
problemShort: 'elements must meet minimum color contrast ratio thresholds',
problemUrl: 'https://dequeuniversity.com/rules/axe/4.10/color-contrast?application=playwright',
solutionShort: 'ensure the contrast between foreground and background colors meets WCAG thresholds',
}

describe('generateIssueBody', () => {
it('includes acceptance criteria and omits the Specifically section when solutionLong is missing', () => {
const body = generateIssueBody(baseFinding, 'github/accessibility-scanner')

expect(body).toContain('## What')
expect(body).toContain('## Acceptance Criteria')
expect(body).toContain('The specific axe violation reported in this issue is no longer reproducible.')
expect(body).toContain('The specific violation reported in this issue is no longer reproducible.')
expect(body).not.toContain('Specifically:')
})

Expand Down Expand Up @@ -61,4 +69,11 @@ describe('generateIssueBody', () => {
expect(body).not.toContain('View screenshot')
expect(body).not.toContain('.screenshots')
})

it('uses url fallback when html is not present', () => {
const body = generateIssueBody(findingWithEmptyOptionalFields, 'github/accessibility-scanner')

expect(body).toContain(`found an issue on ${findingWithEmptyOptionalFields.url}`)
expect(body).not.toContain('flagged the element')
})
})
2 changes: 1 addition & 1 deletion .github/actions/file/tests/openIssue.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ describe('openIssue', () => {
expect(octokit.request).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
labels: ['axe rule: color-contrast', 'axe-scanning-issue'],
labels: ['axe-scanning-issue', 'axe rule: color-contrast'],
}),
)
})
Expand Down
1 change: 1 addition & 0 deletions .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export async function findForUrl(
plugin,
page,
addFinding,
url,
})
} else {
core.info(`Skipping plugin ${plugin.name} because it is not included in the 'scans' input`)
Expand Down
17 changes: 12 additions & 5 deletions .github/actions/find/src/pluginManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ const __dirname = path.dirname(__filename)

type PluginDefaultParams = {
page: playwright.Page
addFinding: (findingData: Finding) => void
addFinding: (findingData: Finding) => Promise<void>
url: string
}

type Plugin = {
Expand Down Expand Up @@ -65,7 +66,7 @@ export async function loadCustomPlugins() {

// - currently, the plugin manager will abort loading
// all plugins if there's an error
// - the problem with this is that if a scanner user doesnt
// - the problem with this is that if a scanner user doesn't
// have custom plugins, they won't have a 'scanner-plugins' folder
// which will cause an error and abort loading all plugins, including built-in ones
// - so for custom plugins, if the path doesn't exist, we can return early
Expand All @@ -87,7 +88,13 @@ export async function loadPluginsFromPath({pluginsPath}: {pluginsPath: string})

if (fs.existsSync(pluginFolderPath) && fs.lstatSync(pluginFolderPath).isDirectory()) {
core.info(`Found plugin: ${pluginFolder}`)
plugins.push(await dynamicImport(path.join(pluginsPath, pluginFolder, 'index.js')))
const plugin = await dynamicImport(path.join(pluginsPath, pluginFolder, 'index.js'))
// Prevents a plugin from running twice
if (plugins.some(p => p.name === plugin.name)) {
core.info(`Skipping duplicate plugin: ${plugin.name}`)
continue
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This change was needed because when testing within this repo, the reflow-scan plugin was being loaded and run twice, duplicating issues. This is only going to be an issue when:

  1. The tests run within this repo, as loadBuiltInPlugins and loadCustomPlugins both contain a reflow-scan plugin since it's in the same directory,
  2. If a user adds a scanner-plugins directory in their own repo where one of them contains the same name.

A slim edge case, but we needed to handle it for the tests in this repo.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should namespace our plugins. so it would be something like builtin:<name> and it would avoid name clashes?

One of the things we discussed was that it's on the plugin authors to ensure that names are unique - does that apply in this case? or are we saying that it only applies when the name clash happens between two custom plugins (so it doesnt apply if the name clash happens between 1 built-in and 1 custom plugin)?

I don't know if I'm leaning one way or another 🤔

Copy link
Copy Markdown
Contributor

@abdulahmad307 abdulahmad307 Mar 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think one disadvantage of programmatically removing (skipping) a plugin is we can't control which one gets skipped. With the current change, only the first one in the file-system will load, but what if we want the second one to load? would we not then revert to having to change the name anyway?

I guess I'm realizing that ensuring names are unique is still going to be the end result in many cases, so we should just use that as a criteria for loading plugins

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could namespace our plugins, but in the case of any we add to the accessibility-scanner repo we would still run into it loading twice only in our tests -- specifically with the sites-with-errors tests, because it's treating "reflow-scan" as a built-in plugin and a custom plugin when we run the test.yml CI check.

So we'd run into this issue if someone else created a custom plugin called "reflow-scan" in their own scanner-plugins directory if that makes sense.

The alternative to the issue we're running into in our tests is to either:

  1. Not add tests for the reflow plugin, or
  2. Assume that it will run twice and add the duplicate issue that's created

I'm not really in love with either of these options, though...

I guess a third alternative is if someone does create a naming clash and we are to skip the plugin, it could core.warn with a message stating which plugin is being skipped in that workflow run?

so we should just use that as a criteria for loading plugins

Are you talking about ensuring for unique names?

plugins.push(plugin)
}
}
} catch (e) {
Expand All @@ -102,6 +109,6 @@ export async function loadPluginsFromPath({pluginsPath}: {pluginsPath: string})
type InvokePluginParams = PluginDefaultParams & {
plugin: Plugin
}
export function invokePlugin({plugin, page, addFinding}: InvokePluginParams) {
return plugin.default({page, addFinding})
export function invokePlugin({plugin, page, addFinding, url}: InvokePluginParams) {
return plugin.default({page, addFinding, url})
Comment thread
lindseywild marked this conversation as resolved.
Outdated
}
2 changes: 1 addition & 1 deletion .github/actions/find/src/types.d.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type Finding = {
scannerType: string
url: string
html: string
html?: string
problemShort: string
problemUrl: string
solutionShort: string
Expand Down
33 changes: 30 additions & 3 deletions .github/actions/find/tests/pluginManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,17 @@ vi.mock('../src/pluginManager.js', {spy: true})
vi.mock('@actions/core', {spy: true})

describe('loadPlugins', () => {
vi.spyOn(dynamicImportModule, 'dynamicImport').mockImplementation(path => Promise.resolve(path))
let dynamicImportCallCount = 0
vi.spyOn(dynamicImportModule, 'dynamicImport').mockImplementation(() => {
dynamicImportCallCount++
return Promise.resolve({name: `plugin-${dynamicImportCallCount}`, default: vi.fn()})
})
beforeEach(() => {
dynamicImportCallCount = 0
// @ts-expect-error - we don't need the full fs readdirsync
// method signature here
vi.spyOn(fs, 'readdirSync').mockImplementation(readPath => {
return [readPath + '/plugin-1', readPath + '/plugin-2']
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
return ['folder-a', 'folder-b']
})
vi.spyOn(fs, 'lstatSync').mockImplementation(() => {
return {
Expand Down Expand Up @@ -61,4 +66,26 @@ describe('loadPlugins', () => {
expect(logSpy).toHaveBeenCalledWith(pluginManager.abortError)
})
})

describe('when built-in and custom plugins share the same name', () => {
beforeEach(() => {
// @ts-expect-error - we don't need the full fs readdirsync
// method signature here
vi.spyOn(fs, 'readdirSync').mockImplementation(() => {
return ['reflow-scan']
})
vi.spyOn(dynamicImportModule, 'dynamicImport').mockImplementation(() => {
return Promise.resolve({name: 'reflow-scan', default: vi.fn()})
})
})

it('skips the duplicate and only loads the plugin once', async () => {
pluginManager.clearCache()
const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {})
const plugins = await pluginManager.loadPlugins()
expect(plugins.length).toBe(1)
expect(plugins[0].name).toBe('reflow-scan')
expect(infoSpy).toHaveBeenCalledWith('Skipping duplicate plugin: reflow-scan')
})
})
})
Original file line number Diff line number Diff line change
@@ -1,32 +1,31 @@
export default async function test({ page, addFinding, url } = {}) {
console.log('test plugin');
// Check for horizontal scrolling at 320x256 viewport
export default async function reflowScan({ page, addFinding, url } = {}) {
console.log('reflow plugin');
const originalViewport = page.viewportSize()
// Check for horizontal scrolling at 320x256 viewport
try {
await page.setViewportSize({ width: 320, height: 256 });
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth);
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth);
await page.setViewportSize({width: 320, height: 256})
const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth)
const clientWidth = await page.evaluate(() => document.documentElement.clientWidth)

// If horizontal scroll is required (with 1px tolerance for rounding)
if (scrollWidth > clientWidth + 1) {
const htmlSnippet = await page.evaluate(() => {
return `<html lang="${document.documentElement.lang || 'en'}">`;
});

addFinding({
scannerType: 'viewport',
ruleId: 'horizontal-scroll-320x256',
await addFinding({
scannerType: 'reflow-scan',
url,
html: htmlSnippet.replace(/'/g, "&apos;"),
problemShort: 'page requires horizontal scrolling at 320x256 viewport',
problemUrl: 'https://www.w3.org/WAI/WCAG21/Understanding/reflow.html',
solutionShort: 'ensure content is responsive and does not require horizontal scrolling at small viewport sizes',
solutionLong: `The page has a scroll width of ${scrollWidth}px but a client width of only ${clientWidth}px at 320x256 viewport, requiring horizontal scrolling. This violates WCAG 2.1 Level AA Success Criterion 1.4.10 (Reflow).`
});
solutionLong: `The page has a scroll width of ${scrollWidth}px but a client width of only ${clientWidth}px at 320x256 viewport, requiring horizontal scrolling. This violates WCAG 2.1 Level AA Success Criterion 1.4.10 (Reflow).`,
})
}
} catch (e) {
console.error('Error checking horizontal scroll:', e);
console.error('Error checking horizontal scroll:', e)
} finally {
// Restore original viewport so subsequent scans (e.g. Axe) aren't affected
if (originalViewport) {
await page.setViewportSize(originalViewport)
}
}

}

export const name = 'test-plugin';
export const name = 'reflow-scan';
6 changes: 6 additions & 0 deletions .github/scanner-plugins/reflow-scan/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"name": "reflow-scan",
"version": "1.0.0",
"description": "Scans pages at a 320x256 viewport size to identify potential reflow issues, such as horizontal scrolling and content overflow.",
"type": "module"
}
6 changes: 0 additions & 6 deletions .github/scanner-plugins/test-plugin/package.json

This file was deleted.

1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ jobs:
repository: ${{ env.TESTING_REPOSITORY }}
token: ${{ secrets.GH_TOKEN }}
cache_key: ${{ steps.cache_key.outputs.cache_key }}
scans: '["axe", "reflow-scan"]'

- name: Retrieve cached results
uses: ./.github/actions/gh-cache/restore
Expand Down
6 changes: 5 additions & 1 deletion PLUGINS.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Plugins

The plugin system allows teams to create custom scans/tests to run on their pages. An example of this is Axe interaction tests. In some cases, it might be desirable to perform specific interactions on elements of a given page before doing an Axe scan. These interactions are usually unique to each page that is scanned, so it would require the owning team to write a custom plugin that can interact with the page and run the Axe scan when ready. See the example under [.github/scanner-plugins/test-plugin](https://github.com/github/accessibility-scanner/tree/main/.github/scanner-plugins/test-plugin) (this is not an Axe interaction test, but should give a general understanding of plugin structure).
The plugin system allows teams to create custom scans/tests to run on their pages. An example of this is Axe interaction tests. In some cases, it might be desirable to perform specific interactions on elements of a given page before doing an Axe scan. These interactions are usually unique to each page that is scanned, so it would require the owning team to write a custom plugin that can interact with the page and run the Axe scan when ready. See the existing plugins under [.github/scanner-plugins](https://github.com/github/accessibility-scanner/tree/main/.github/scanner-plugins) for examples of plugin structure.

Some plugins come built-in with the scanner and can be enabled via [actions inputs](https://github.com/github/accessibility-scanner/tree/main/action.yml#L48-L50).
Comment thread
lindseywild marked this conversation as resolved.

Expand All @@ -22,6 +22,10 @@ A async function (you must use `await` or `.then` when invoking this function) t

- An object that should match the [`Finding` type](https://github.com/github/accessibility-scanner/blob/main/.github/actions/find/src/types.d.ts#L1-L9).

#### `url`

Passes in the URL of the page being scanned to be used when a finding is added.

## How to create plugins

As mentioned above, plugins need to exist under `./.github/scanner-plugins`. For a plugin to work, it needs to meet the following criteria:
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ jobs:
# open_grouped_issues: false # Optional: Set to true to open an issue grouping individual issues per violation
# 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.
```

> 👉 Update all `REPLACE_THIS` placeholders with your actual values. See [Action Inputs](#action-inputs) for details.
Expand Down Expand Up @@ -125,7 +126,7 @@ Trigger the workflow manually or automatically based on your configuration. The
| `include_screenshots` | No | Whether to capture screenshots of scanned pages and include links to them in filed issues. Screenshots are stored on the `gh-cache` branch of the repository running the workflow. 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', ...other plugins]` |
| `scans` | No | An array of scans (or plugins) to be performed. If not provided, only Axe will be performed. | `'["axe", "reflow-scan", ...other plugins]'` |
Comment thread
lindseywild marked this conversation as resolved.
Outdated

---

Expand Down
4 changes: 4 additions & 0 deletions sites/site-with-errors/404.html
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@
line-height: 1;
letter-spacing: -1px;
}
.wide-element {
width: 500px;
}
</style>

<div class="container">
<h1>404</h1>

<p><strong>Page not found :(</strong></p>
<p>The requested page could not be found.</p>
<div class="wide-element">This element is too wide for small viewports.</div>
</div>
17 changes: 13 additions & 4 deletions tests/site-with-errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ describe('site-with-errors', () => {
// Check volatile fields for existence only
expect(issueUrl).toBeDefined()
expect(problemUrl).toBeDefined()
expect(solutionLong).toBeDefined()
// Check `problemUrl`, ignoring axe version
expect(problemUrl.startsWith('https://dequeuniversity.com/rules/axe/')).toBe(true)
expect(problemUrl.endsWith(`/${finding.ruleId}?application=playwright`)).toBe(true)
// Axe-specific assertions
if (finding.scannerType === 'axe') {
expect(solutionLong).toBeDefined()
expect(problemUrl.startsWith('https://dequeuniversity.com/rules/axe/')).toBe(true)
expect(problemUrl.endsWith(`/${finding.ruleId}?application=playwright`)).toBe(true)
}
// screenshotId is only present when include_screenshots is enabled
if (screenshotId !== undefined) {
expect(screenshotId).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
Expand Down Expand Up @@ -107,6 +109,12 @@ describe('site-with-errors', () => {
ruleId: 'empty-heading',
solutionShort: 'ensure headings have discernible text',
},
{
scannerType: 'reflow-scan',
url: 'http://127.0.0.1:4000/404.html',
problemShort: 'page requires horizontal scrolling at 320x256 viewport',
solutionShort: 'ensure content is responsive and does not require horizontal scrolling at small viewport sizes',
},
Comment thread
lindseywild marked this conversation as resolved.
]
// Check that:
// - every expected object exists (no more and no fewer), and
Expand Down Expand Up @@ -153,6 +161,7 @@ describe('site-with-errors', () => {
'Accessibility issue: Headings should not be empty on /404.html',
'Accessibility issue: Elements must meet minimum color contrast ratio thresholds on /about/',
'Accessibility issue: Elements must meet minimum color contrast ratio thresholds on /jekyll/update/2025/07/30/welcome-to-jekyll.html',
'Accessibility issue: Page requires horizontal scrolling at 320x256 viewport on /404.html',
]
expect(actualTitles).toHaveLength(expectedTitles.length)
expect(actualTitles).toEqual(expect.arrayContaining(expectedTitles))
Expand Down
2 changes: 1 addition & 1 deletion tests/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ export type Finding = {
scannerType: string
ruleId: string
url: string
html: string
html?: string
problemShort: string
problemUrl: string
solutionShort: string
Expand Down
Loading