diff --git a/.github/actions/find/README.md b/.github/actions/find/README.md index be4323c..680b539 100644 --- a/.github/actions/find/README.md +++ b/.github/actions/find/README.md @@ -31,6 +31,14 @@ configuration option. [`colorScheme`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme) configuration option. +#### `include_screenshots` + +**Optional** Bool - whether to capture screenshots of scanned pages and include links to them in the issue + +#### `scans` + +**Optional** Stringified JSON array of scans (string) to perform. If not provided, only Axe will be performed. + ### Outputs #### `findings` diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index f4add9b..a83adb5 100644 --- a/.github/actions/find/action.yml +++ b/.github/actions/find/action.yml @@ -1,33 +1,36 @@ -name: "Find" -description: "Finds potential accessibility gaps." +name: 'Find' +description: 'Finds potential accessibility gaps.' inputs: urls: - description: "Newline-delimited list of URLs to check for accessibility issues" + description: 'Newline-delimited list of URLs to check for accessibility issues' required: true multiline: true auth_context: description: "Stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session" required: false include_screenshots: - description: "Whether to capture screenshots of scanned pages and include links to them in the issue" + description: 'Whether to capture screenshots of scanned pages and include links to them in the issue' + required: false + default: 'false' + scans: + description: 'Stringified JSON array of scans to perform. If not provided, only Axe will be performed' required: false - default: "false" reduced_motion: - description: "Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion" + description: 'Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion' required: false color_scheme: - description: "Playwright colorScheme setting: https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme" + description: 'Playwright colorScheme setting: https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme' required: false outputs: findings: - description: "List of potential accessibility gaps, as stringified JSON" + description: 'List of potential accessibility gaps, as stringified JSON' runs: - using: "node24" - main: "bootstrap.js" + using: 'node24' + main: 'bootstrap.js' branding: - icon: "compass" - color: "blue" + icon: 'compass' + color: 'blue' diff --git a/.github/actions/find/src/dynamicImport.ts b/.github/actions/find/src/dynamicImport.ts new file mode 100644 index 0000000..f00d224 --- /dev/null +++ b/.github/actions/find/src/dynamicImport.ts @@ -0,0 +1,21 @@ +// - this exists because it looks like there's no straight-forward +// way to mock the dynamic import function, so mocking this instead +// (also, if it _is_ possible to mock the dynamic import, +// there's the risk of altering/breaking the behavior of imports +// across the board - including non-dynamic imports) +// +// - also, vitest has a limitation on mocking: +// https://vitest.dev/guide/mocking/modules.html#mocking-modules-pitfalls +// +// - basically if a function is called by another function in the same file +// it can't be mocked. So this was extracted into a separate file +// +// - one thing to note is vitest does the same thing here: +// https://github.com/vitest-dev/vitest/blob/main/test/core/src/dynamic-import.ts +// - and uses that with tests here: +// https://github.com/vitest-dev/vitest/blob/main/test/core/test/mock-internals.test.ts#L27 +// +// - so this looks like a reasonable approach +export async function dynamicImport(path: string) { + return import(path) +} diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index bff83b2..4de4c4b 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -3,11 +3,14 @@ import {AxeBuilder} from '@axe-core/playwright' import playwright from 'playwright' import {AuthContext} from './AuthContext.js' import {generateScreenshots} from './generateScreenshots.js' +import {loadPlugins, invokePlugin} from './pluginManager.js' +import {getScansContext} from './scansContextProvider.js' +import core from '@actions/core' export async function findForUrl( url: string, authContext?: AuthContext, - includeScreenshots: boolean = false, + includeScreenshotsInput: boolean = false, reducedMotion?: ReducedMotionPreference, colorScheme?: ColorSchemePreference, ): Promise { @@ -23,32 +26,82 @@ export async function findForUrl( const context = await browser.newContext(contextOptions) const page = await context.newPage() await page.goto(url) - console.log(`Scanning ${page.url()}`) - let findings: Finding[] = [] + const findings: Finding[] = [] + const addFinding = async ( + findingData: Finding, + {includeScreenshots = false}: {includeScreenshots?: boolean} = {}, + ) => { + let screenshotId + if (includeScreenshotsInput || includeScreenshots) { + screenshotId = await generateScreenshots(page) + } + findings.push({...findingData, screenshotId}) + } + try { - const rawFindings = await new AxeBuilder({page}).analyze() + const scansContext = getScansContext() - let screenshotId: string | undefined - if (includeScreenshots) { - screenshotId = await generateScreenshots(page) + if (scansContext.shouldRunPlugins) { + const plugins = await loadPlugins() + for (const plugin of plugins) { + if (scansContext.scansToPerform.includes(plugin.name)) { + core.info(`Running plugin: ${plugin.name}`) + await invokePlugin({ + plugin, + page, + addFinding, + // - this will be coming soon + // runAxeScan: () => runAxeScan({page, includeScreenshots: includeScreenshotsInput, findings}), + }) + } else { + core.info(`Skipping plugin ${plugin.name} because it is not included in the 'scans' input`) + } + } } - findings = rawFindings.violations.map(violation => ({ - scannerType: 'axe', - url, - html: violation.nodes[0].html.replace(/'/g, '''), - problemShort: violation.help.toLowerCase().replace(/'/g, '''), - problemUrl: violation.helpUrl.replace(/'/g, '''), - ruleId: violation.id, - solutionShort: violation.description.toLowerCase().replace(/'/g, '''), - solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, '''), - screenshotId, - })) + if (scansContext.shouldPerformAxeScan) { + runAxeScan({ + includeScreenshots: includeScreenshotsInput, + page, + findings, + }) + } } catch (e) { - console.error('Error during accessibility scan:', e) + core.error(`Error during accessibility scan: ${e}`) } await context.close() await browser.close() return findings } + +async function runAxeScan({ + includeScreenshots, + page, + findings, +}: { + includeScreenshots: boolean + page: playwright.Page + findings: Finding[] +}) { + const url = page.url() + core.info(`Scanning ${url}`) + const rawFindings = await new AxeBuilder({page}).analyze() + let screenshotId: string | undefined + if (includeScreenshots) { + screenshotId = await generateScreenshots(page) + } + + const axeFindings = rawFindings?.violations.map(violation => ({ + scannerType: 'axe', + url, + html: violation.nodes[0].html.replace(/'/g, '''), + problemShort: violation.help.toLowerCase().replace(/'/g, '''), + problemUrl: violation.helpUrl.replace(/'/g, '''), + ruleId: violation.id, + solutionShort: violation.description.toLowerCase().replace(/'/g, '''), + solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, '''), + screenshotId, + })) + findings.push(...(axeFindings || [])) +} diff --git a/.github/actions/find/src/generateScreenshots.ts b/.github/actions/find/src/generateScreenshots.ts index d1dd353..388e430 100644 --- a/.github/actions/find/src/generateScreenshots.ts +++ b/.github/actions/find/src/generateScreenshots.ts @@ -2,6 +2,7 @@ import fs from 'node:fs' import path from 'node:path' import crypto from 'node:crypto' import type {Page} from 'playwright' +import core from '@actions/core' // Use GITHUB_WORKSPACE to ensure screenshots are saved in the workflow workspace root // where the artifact upload step can find them @@ -12,9 +13,9 @@ export const generateScreenshots = async function (page: Page) { // Ensure screenshot directory exists if (!fs.existsSync(SCREENSHOT_DIR)) { fs.mkdirSync(SCREENSHOT_DIR, {recursive: true}) - console.log(`Created screenshot directory: ${SCREENSHOT_DIR}`) + core.info(`Created screenshot directory: ${SCREENSHOT_DIR}`) } else { - console.log(`Using existing screenshot directory ${SCREENSHOT_DIR}`) + core.info(`Using existing screenshot directory ${SCREENSHOT_DIR}`) } try { @@ -28,9 +29,9 @@ export const generateScreenshots = async function (page: Page) { const filepath = path.join(SCREENSHOT_DIR, filename) fs.writeFileSync(filepath, screenshotBuffer) - console.log(`Screenshot saved: ${filename}`) + core.info(`Screenshot saved: ${filename}`) } catch (error) { - console.error('Failed to capture/save screenshot:', error) + core.error(`Failed to capture/save screenshot: ${error}`) screenshotId = undefined } diff --git a/.github/actions/find/src/pluginManager.ts b/.github/actions/find/src/pluginManager.ts new file mode 100644 index 0000000..c7d4054 --- /dev/null +++ b/.github/actions/find/src/pluginManager.ts @@ -0,0 +1,103 @@ +import * as fs from 'fs' +import * as path from 'path' +import {fileURLToPath} from 'url' +import {dynamicImport} from './dynamicImport.js' +import type {Finding} from './types.d.js' +import playwright from 'playwright' +import core from '@actions/core' + +// Helper to get __dirname equivalent in ES Modules +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +type PluginDefaultParams = { + page: playwright.Page + addFinding: (findingData: Finding) => void + // - this will be coming soon + // runAxeScan: (options: {includeScreenshots: boolean; page: playwright.Page; findings: Finding[]}) => Promise +} + +export type Plugin = { + name: string + default: (options: PluginDefaultParams) => Promise +} + +const plugins: Plugin[] = [] +let pluginsLoaded = false + +export async function loadPlugins() { + core.info('loading plugins') + + try { + if (!pluginsLoaded) { + await loadBuiltInPlugins() + await loadCustomPlugins() + } + } catch { + plugins.length = 0 + core.error(abortError) + } finally { + pluginsLoaded = true + return plugins + } +} + +export const abortError = ` +There was an error while loading plugins. +Clearing all plugins and aborting custom plugin scans. +Please check the logs for hints as to what may have gone wrong. +` + +export function clearCache() { + pluginsLoaded = false + plugins.length = 0 +} + +// exported for mocking/testing. not for actual use +export async function loadBuiltInPlugins() { + core.info('Loading built-in plugins') + + const pluginsPath = '../../../scanner-plugins/' + await loadPluginsFromPath({ + readPath: path.join(__dirname, pluginsPath), + importPath: pluginsPath, + }) +} + +// exported for mocking/testing. not for actual use +export async function loadCustomPlugins() { + core.info('Loading custom plugins') + + const pluginsPath = process.cwd() + '/.github/scanner-plugins/' + await loadPluginsFromPath({ + readPath: pluginsPath, + importPath: pluginsPath, + }) +} + +// exported for mocking/testing. not for actual use +export async function loadPluginsFromPath({readPath, importPath}: {readPath: string; importPath: string}) { + try { + const res = fs.readdirSync(readPath) + for (const pluginFolder of res) { + const pluginFolderPath = path.join(importPath, pluginFolder) + if (fs.lstatSync(pluginFolderPath).isDirectory()) { + core.info(`Found plugin: ${pluginFolder}`) + plugins.push(await dynamicImport(path.join(importPath, pluginFolder, '/index.js'))) + } + } + } catch (e) { + // - log errors here for granular info + core.error('error: ') + core.error(e as Error) + // - throw error to handle aborting the plugin scans + throw e + } +} + +type InvokePluginParams = PluginDefaultParams & { + plugin: Plugin +} +export function invokePlugin({plugin, page, addFinding}: InvokePluginParams) { + return plugin.default({page, addFinding}) +} diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts new file mode 100644 index 0000000..9824045 --- /dev/null +++ b/.github/actions/find/src/scansContextProvider.ts @@ -0,0 +1,36 @@ +import core from '@actions/core' + +type ScansContext = { + scansToPerform: Array + shouldPerformAxeScan: boolean + shouldRunPlugins: boolean +} +let scansContext: ScansContext | undefined + +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') + + 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 + shouldPerformAxeScan: !scansInput || scansToPerform.includes('axe'), + shouldRunPlugins: scansToPerform.length > 0 && !onlyAxeScan, + } + } + + return scansContext +} + +export function clearCache() { + scansContext = undefined +} diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts new file mode 100644 index 0000000..ce63b0c --- /dev/null +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -0,0 +1,118 @@ +import {describe, it, expect, vi} from 'vitest' +import core from '@actions/core' +import {findForUrl} from '../src/findForUrl.js' +import {AxeBuilder} from '@axe-core/playwright' +import axe from 'axe-core' +import * as pluginManager from '../src/pluginManager.js' +import {clearCache} from '../src/scansContextProvider.js' + +vi.mock('playwright', () => ({ + default: { + chromium: { + launch: () => ({ + newContext: () => ({ + newPage: () => ({ + pageUrl: '', + goto: () => {}, + url: () => {}, + }), + close: () => {}, + }), + close: () => {}, + }), + }, + }, +})) + +vi.mock('@axe-core/playwright', () => { + const AxeBuilderMock = vi.fn() + const rawFinding = {violations: []} as unknown as axe.AxeResults + AxeBuilderMock.prototype.analyze = vi.fn(() => Promise.resolve(rawFinding)) + return {AxeBuilder: AxeBuilderMock} +}) + +let actionInput: string = '' +let loadedPlugins: pluginManager.Plugin[] = [] + +function clearAll() { + clearCache() + vi.clearAllMocks() +} + +describe('findForUrl', () => { + vi.spyOn(core, 'getInput').mockImplementation(() => actionInput) + vi.spyOn(pluginManager, 'loadPlugins').mockImplementation(() => Promise.resolve(loadedPlugins)) + vi.spyOn(pluginManager, 'invokePlugin') + + async function axeOnlyTest() { + clearAll() + + await findForUrl('test.com') + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) + expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0) + expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(0) + } + + describe('when no scans list is provided', () => { + it('defaults to running only axe scan', async () => { + actionInput = '' + await axeOnlyTest() + }) + }) + + describe('when a scans list is provided', () => { + describe('and the list _only_ includes axe', () => { + it('runs only the axe scan', async () => { + actionInput = JSON.stringify(['axe']) + await axeOnlyTest() + }) + }) + + describe('and the list includes axe and other scans', () => { + it('runs axe and plugins', async () => { + loadedPlugins = [ + {name: 'custom-scan-1', default: vi.fn()}, + {name: 'custom-scan-2', default: vi.fn()}, + ] + + actionInput = JSON.stringify(['axe', 'custom-scan-1']) + clearAll() + + await findForUrl('test.com') + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) + expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(1) + expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(1) + }) + }) + + describe('and the list does not include axe', () => { + it('only runs plugins', async () => { + loadedPlugins = [ + {name: 'custom-scan-1', default: vi.fn()}, + {name: 'custom-scan-2', default: vi.fn()}, + ] + + actionInput = JSON.stringify(['custom-scan-1', 'custom-scan-2']) + clearAll() + + await findForUrl('test.com') + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(0) + expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(1) + expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(2) + }) + }) + + it('should only run scans that are included in the list', async () => { + loadedPlugins = [ + {name: 'custom-scan-1', default: vi.fn()}, + {name: 'custom-scan-2', default: vi.fn()}, + ] + actionInput = JSON.stringify(['custom-scan-1']) + clearAll() + + await findForUrl('test.com') + expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) + expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0) + }) + }) +}) diff --git a/.github/actions/find/tests/pluginManager.test.ts b/.github/actions/find/tests/pluginManager.test.ts new file mode 100644 index 0000000..fbd7905 --- /dev/null +++ b/.github/actions/find/tests/pluginManager.test.ts @@ -0,0 +1,62 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' + +import * as fs from 'fs' +import * as dynamicImportModule from '../src/dynamicImport.js' +import * as pluginManager from '../src/pluginManager.js' +import core from '@actions/core' + +// - enable spying on fs +// https://vitest.dev/guide/browser/#limitations +vi.mock('fs', {spy: true}) +vi.mock('../src/pluginManager.js', {spy: true}) + +describe('loadPlugins', () => { + vi.spyOn(dynamicImportModule, 'dynamicImport').mockImplementation(path => Promise.resolve(path)) + beforeEach(() => { + // @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, 'lstatSync').mockImplementation(() => { + return { + isDirectory: () => true, + } as unknown as fs.Stats + }) + }) + + describe('when plugins are not loaded', () => { + it('loads them', async () => { + pluginManager.clearCache() + const plugins = await pluginManager.loadPlugins() + expect(dynamicImportModule.dynamicImport).toHaveBeenCalledTimes(4) + expect(plugins.length).toBe(4) + }) + }) + + describe('when plugins are already loaded', () => { + it('caches them and doesnt load them again', async () => { + pluginManager.clearCache() + await pluginManager.loadPlugins() + await pluginManager.loadPlugins() + expect(pluginManager.loadBuiltInPlugins).toHaveBeenCalledTimes(0) + expect(pluginManager.loadCustomPlugins).toHaveBeenCalledTimes(0) + }) + }) + + describe('when there is an error loading plugins', () => { + beforeEach(() => { + vi.spyOn(fs, 'readdirSync').mockImplementation(() => { + throw new Error('test error') + }) + }) + + it('Aborts loading all plugins', async () => { + pluginManager.clearCache() + const logSpy = vi.spyOn(core, 'error').mockImplementation(() => {}) + const plugins = await pluginManager.loadPlugins() + expect(plugins.length).toBe(0) + expect(logSpy).toHaveBeenCalledWith(pluginManager.abortError) + }) + }) +}) diff --git a/.github/scanner-plugins/test-plugin/index.js b/.github/scanner-plugins/test-plugin/index.js new file mode 100644 index 0000000..32ac899 --- /dev/null +++ b/.github/scanner-plugins/test-plugin/index.js @@ -0,0 +1,32 @@ +export default async function test({ page, addFinding, url } = {}) { + console.log('test plugin'); + // 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); + + // If horizontal scroll is required (with 1px tolerance for rounding) + if (scrollWidth > clientWidth + 1) { + const htmlSnippet = await page.evaluate(() => { + return ``; + }); + + addFinding({ + scannerType: 'viewport', + ruleId: 'horizontal-scroll-320x256', + url, + html: htmlSnippet.replace(/'/g, "'"), + 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).` + }); + } + } catch (e) { + console.error('Error checking horizontal scroll:', e); + } + +} + +export const name = 'test-plugin'; diff --git a/.github/scanner-plugins/test-plugin/package.json b/.github/scanner-plugins/test-plugin/package.json new file mode 100644 index 0000000..5bc2b0c --- /dev/null +++ b/.github/scanner-plugins/test-plugin/package.json @@ -0,0 +1,6 @@ +{ + "name": "reflow-plugin-test", + "version": "1.0.0", + "description": "A test plugin for reflow testing", + "type": "module" +} diff --git a/.gitignore b/.gitignore index f42521c..cbb4dba 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ dist node_modules -test-results \ No newline at end of file +test-results +.vscode diff --git a/PLUGINS.md b/PLUGINS.md new file mode 100644 index 0000000..73b12b6 --- /dev/null +++ b/PLUGINS.md @@ -0,0 +1,37 @@ +# 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). + +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). + +## How plugins work + +Plugins are dynamically loaded by the scanner when it runs. The scanner will look into the `./.github` folder in your repo (where you run the workflow from) and search for a `scanner-plugins` folder. If it finds it, it will assume each folder under that is a plugin, and attempt to load the `index.js` file inside it. Once loaded, the scanner will invoke the exported default function from the `index.js` file. + +### Default function API + +When the default function is invoked, the following arguments are passed to the function: + +#### `page` + +This is the [playwright page](https://playwright.dev/docs/api/class-page) instance. + +#### `addFinding` + +This is a function that will add a finding to the list. Findings are used to generate and file issues. This function has 2 arguments: + +1. 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). +2. An 'options' object that has an optional `includeScreenshots` field. This function will automatically generate a screenshot and add the screenshotId to the `Finding` data if `includeScreenshots` is true in either the action input, or in the options field of this function. + +## 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: + +- Each separate plugin should be contained in it's own directory in `./.github/scanner-plugins`. For example, `./.github/scanner-plugins/plugin-1` would be 1 plugin loaded by the scanner. +- Each plugin should have one `index.js` file inside its folder. +- The `index.js` file must export a `name` field. This is the name used to pass to the `scans` input. So the following: `scans: ['my-custom-plugin']` would cause the scanner to only run that plugin. +- The `index.js` file must export a default function. This is the function that the scanner uses to run the plugin. This can be an async function. + +## 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 66a12c3..5431a31 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ jobs: - Admin access to add repository secrets 📚 Learn more + - [Quickstart for GitHub Actions](https://docs.github.com/en/actions/get-started/quickstart) - [Understanding GitHub Actions](https://docs.github.com/en/actions/get-started/understand-github-actions) - [Writing workflows](https://docs.github.com/en/actions/how-tos/write-workflows) @@ -90,6 +91,7 @@ The a11y scanner requires a Personal Access Token (PAT) as a repository secret: > 👉 GitHub Actions' default [GITHUB_TOKEN](https://docs.github.com/en/actions/tutorials/authenticate-with-github_token) cannot be used here. 📚 Learn more + - [Creating a fine-grained PAT](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-fine-grained-personal-access-token) - [Creating repository secrets](https://docs.github.com/en/actions/how-tos/write-workflows/choose-what-workflows-do/use-secrets#creating-secrets-for-a-repository) @@ -100,6 +102,7 @@ The a11y scanner requires a Personal Access Token (PAT) as a repository secret: Trigger the workflow manually or automatically based on your configuration. The a11y scanner will run and create issues for any accessibility findings. When issues are assigned to GitHub Copilot, always review proposed fixes before merging. 📚 Learn more + - [View workflow run history](https://docs.github.com/en/actions/how-tos/monitor-workflows/view-workflow-run-history) - [Running a workflow manually](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow#running-a-workflow) - [Re-run workflows and jobs](https://docs.github.com/en/actions/how-tos/manage-workflow-runs/re-run-workflows-and-jobs) @@ -108,20 +111,21 @@ Trigger the workflow manually or automatically based on your configuration. The ## Action inputs -| Input | Required | Description | Example | -|-------|----------|-------------|---------| -| `urls` | Yes | Newline-delimited list of URLs to scan | `https://primer.style`
`https://primer.style/octicons` | -| `repository` | Yes | Repository (with owner) for issues and PRs | `primer/primer-docs` | -| `token` | Yes | PAT with write permissions (see above) | `${{ secrets.GH_TOKEN }}` | -| `cache_key` | Yes | Key for caching results across runs
Allowed: `A-Za-z0-9._/-` | `cached_results-primer.style-main.json` | -| `login_url` | No | If scanned pages require authentication, the URL of the login page | `https://github.com/login` | -| `username` | No | If scanned pages require authentication, the username to use for login | `some-user` | -| `password` | No | If scanned pages require authentication, the password to use for login | `${{ secrets.PASSWORD }}` | -| `auth_context` | No | If scanned pages require authentication, a stringified JSON object containing username, password, cookies, and/or localStorage from an authenticated session | `{"username":"some-user","password":"***","cookies":[...]}` | -| `skip_copilot_assignment` | No | Whether to skip assigning filed issues to GitHub Copilot. Set to `true` if you don't have GitHub Copilot or prefer to handle issues manually | `true` | -| `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` | +| Input | Required | Description | Example | +| ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | +| `urls` | Yes | Newline-delimited list of URLs to scan | `https://primer.style`
`https://primer.style/octicons` | +| `repository` | Yes | Repository (with owner) for issues and PRs | `primer/primer-docs` | +| `token` | Yes | PAT with write permissions (see above) | `${{ secrets.GH_TOKEN }}` | +| `cache_key` | Yes | Key for caching results across runs
Allowed: `A-Za-z0-9._/-` | `cached_results-primer.style-main.json` | +| `login_url` | No | If scanned pages require authentication, the URL of the login page | `https://github.com/login` | +| `username` | No | If scanned pages require authentication, the username to use for login | `some-user` | +| `password` | No | If scanned pages require authentication, the password to use for login | `${{ secrets.PASSWORD }}` | +| `auth_context` | No | If scanned pages require authentication, a stringified JSON object containing username, password, cookies, and/or localStorage from an authenticated session | `{"username":"some-user","password":"***","cookies":[...]}` | +| `skip_copilot_assignment` | No | Whether to skip assigning filed issues to GitHub Copilot. Set to `true` if you don't have GitHub Copilot or prefer to handle issues manually | `true` | +| `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]` | --- @@ -144,6 +148,7 @@ The a11y scanner leverages GitHub Copilot coding agent, which can be configured - **Directory/file-scoped:** `.github/instructions/*.instructions.md` 📚 Learn more + - [Adding repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions) - [Optimizing GitHub Copilot for accessibility](https://accessibility.github.com/documentation/guide/copilot-instructions) - [GitHub Copilot .instructions.md support](https://github.blog/changelog/2025-07-23-github-copilot-coding-agent-now-supports-instructions-md-custom-instructions/) @@ -151,6 +156,12 @@ 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 + +--- + ## Feedback 💬 We welcome your feedback! To submit feedback or report issues, please create an issue in this repository. For more information on contributing, please refer to the [CONTRIBUTING](./CONTRIBUTING.md) file. diff --git a/action.yml b/action.yml index 794dc2e..b5d19f4 100644 --- a/action.yml +++ b/action.yml @@ -1,58 +1,61 @@ -name: "accessibility-scanner" -description: "Finds potential accessibility gaps, files GitHub issues to track them, and attempts to fix them with Copilot." +name: 'accessibility-scanner' +description: 'Finds potential accessibility gaps, files GitHub issues to track them, and attempts to fix them with Copilot.' inputs: urls: - description: "Newline-delimited list of URLs to check for accessibility issues" + description: 'Newline-delimited list of URLs to check for accessibility issues' required: true multiline: true repository: - description: "Repository (with owner) to file issues in" + description: 'Repository (with owner) to file issues in' required: true token: description: "Personal access token (PAT) with fine-grained permissions 'contents: write', 'issues: write', and 'pull_requests: write'" required: true cache_key: - description: "Key for caching results across runs" + description: 'Key for caching results across runs' required: true login_url: - description: "If scanned pages require authentication, the URL of the login page" + description: 'If scanned pages require authentication, the URL of the login page' required: false username: - description: "If scanned pages require authentication, the username to use for login" + description: 'If scanned pages require authentication, the username to use for login' required: false password: - description: "If scanned pages require authentication, the password to use for login" + description: 'If scanned pages require authentication, the password to use for login' required: false auth_context: description: "If scanned pages require authentication, a stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session" required: false skip_copilot_assignment: - description: "Whether to skip assigning filed issues to Copilot" + description: 'Whether to skip assigning filed issues to Copilot' required: false - default: "false" + default: 'false' include_screenshots: - description: "Whether to capture screenshots and include links to them in the issue" + description: 'Whether to capture screenshots and include links to them in the issue' required: false - default: "false" + default: 'false' open_grouped_issues: description: "In the 'file' step, also open grouped issues which link to all issues with the same problem" required: false - default: "false" + default: 'false' reduced_motion: - description: "Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion" + description: 'Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion' required: false color_scheme: - description: "Playwright colorScheme setting: https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme" + description: 'Playwright colorScheme setting: https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme' + required: false + scans: + description: 'Stringified JSON array of scans to perform. If not provided, only Axe will be performed' required: false outputs: results: - description: "List of issues and pull requests filed (and their associated finding(s)), as stringified JSON" + description: 'List of issues and pull requests filed (and their associated finding(s)), as stringified JSON' value: ${{ steps.results.outputs.results }} runs: - using: "composite" + using: 'composite' steps: - name: Make sub-actions referenceable working-directory: ${{ github.action_path }} @@ -64,6 +67,11 @@ runs: if [ "$(realpath ".github/actions")" != "$(realpath "${ACTION_DIR}/.github/actions")" ]; then cp -a ".github/actions/." "${ACTION_DIR}/.github/actions/" fi + + mkdir -p "${ACTION_DIR}/.github/scanner-plugins" + if [ "$(realpath ".github/scanner-plugins")" != "$(realpath "${ACTION_DIR}/.github/scanner-plugins")" ]; then + cp -a ".github/scanner-plugins/." "${ACTION_DIR}/.github/scanner-plugins/." + fi - name: Restore cached results id: restore uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/gh-cache/cache @@ -158,5 +166,5 @@ runs: token: ${{ inputs.token }} branding: - icon: "compass" - color: "blue" + icon: 'compass' + color: 'blue'