diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index c4e1cc5..fb53d90 100644 --- a/.github/actions/find/action.yml +++ b/.github/actions/find/action.yml @@ -4,8 +4,11 @@ description: 'Finds potential accessibility gaps.' inputs: urls: description: 'Newline-delimited list of URLs to check for accessibility issues' - required: true + required: false multiline: true + url_configs: + description: "Stringified JSON array of URL config objects, each with a 'url' field and 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." + required: false auth_context: description: "Stringified JSON object containing 'username', 'password', 'cookies', and/or 'localStorage' from an authenticated session" required: false diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index e651779..d9f1ea8 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -1,4 +1,4 @@ -import type {ColorSchemePreference, Finding, ReducedMotionPreference} from './types.d.js' +import type {ColorSchemePreference, Finding, ReducedMotionPreference, UrlConfig} from './types.d.js' import {AxeBuilder} from '@axe-core/playwright' import playwright from 'playwright' import {AuthContext} from './AuthContext.js' @@ -8,12 +8,13 @@ import {getScansContext} from './scansContextProvider.js' import * as core from '@actions/core' export async function findForUrl( - url: string, + urlConfig: UrlConfig, authContext?: AuthContext, includeScreenshots: boolean = false, reducedMotion?: ReducedMotionPreference, colorScheme?: ColorSchemePreference, ): Promise { + const {url, excludeSelectors} = urlConfig const browser = await playwright.chromium.launch({ headless: true, executablePath: process.env.CI ? '/usr/bin/google-chrome' : undefined, @@ -56,7 +57,7 @@ export async function findForUrl( } if (scansContext.shouldPerformAxeScan) { - await runAxeScan({page, addFinding}) + await runAxeScan({page, addFinding, excludeSelectors}) } } catch (e) { core.error(`Error during accessibility scan: ${e}`) @@ -69,13 +70,18 @@ export async function findForUrl( async function runAxeScan({ page, addFinding, + excludeSelectors, }: { page: playwright.Page addFinding: (findingData: Finding, options?: {includeScreenshots?: boolean}) => Promise + excludeSelectors?: string[] }) { const url = page.url() core.info(`Scanning ${url}`) - const rawFindings = await new AxeBuilder({page}).analyze() + const axeBuilder = new AxeBuilder({page}) + excludeSelectors?.forEach(selector => axeBuilder.exclude(selector)) + + const rawFindings = await axeBuilder.analyze() if (rawFindings) { for (const violation of rawFindings.violations) { diff --git a/.github/actions/find/src/index.ts b/.github/actions/find/src/index.ts index e581bcb..675dff6 100644 --- a/.github/actions/find/src/index.ts +++ b/.github/actions/find/src/index.ts @@ -1,4 +1,4 @@ -import type {AuthContextInput, ColorSchemePreference, ReducedMotionPreference} from './types.js' +import type {AuthContextInput, ColorSchemePreference, ReducedMotionPreference, UrlConfig} from './types.js' import fs from 'node:fs' import path from 'node:path' import * as core from '@actions/core' @@ -7,37 +7,22 @@ import {findForUrl} from './findForUrl.js' export default async function () { core.info("Starting 'find' action") - const urls = core.getMultilineInput('urls', {required: true}) - core.debug(`Input: 'urls: ${JSON.stringify(urls)}'`) + const urlConfigs = loadUrlConfigs() + const urls = loadUrls({urlConfigs}) + const reducedMotion = loadReducedMotion() + const colorScheme = loadColorScheme() + + const actualUrls = urlConfigs || urls || [] + const authContextInput: AuthContextInput = JSON.parse(core.getInput('auth_context', {required: false}) || '{}') const authContext = new AuthContext(authContextInput) - const includeScreenshots = core.getInput('include_screenshots', {required: false}) !== 'false' - const reducedMotionInput = core.getInput('reduced_motion', {required: false}) - let reducedMotion: ReducedMotionPreference | undefined - if (reducedMotionInput) { - if (!['reduce', 'no-preference', null].includes(reducedMotionInput)) { - throw new Error( - "Input 'reduced_motion' must be one of: 'reduce', 'no-preference', or null per Playwright documentation.", - ) - } - reducedMotion = reducedMotionInput as ReducedMotionPreference - } - const colorSchemeInput = core.getInput('color_scheme', {required: false}) - let colorScheme: ColorSchemePreference | undefined - if (colorSchemeInput) { - if (!['light', 'dark', 'no-preference', null].includes(colorSchemeInput)) { - throw new Error( - "Input 'color_scheme' must be one of: 'light', 'dark', 'no-preference', or null per Playwright documentation.", - ) - } - colorScheme = colorSchemeInput as ColorSchemePreference - } const findings = [] - for (const url of urls) { + for (const urlConfig of actualUrls) { + const {url} = urlConfig core.info(`Preparing to scan ${url}`) - const findingsForUrl = await findForUrl(url, authContext, includeScreenshots, reducedMotion, colorScheme) + const findingsForUrl = await findForUrl(urlConfig, authContext, includeScreenshots, reducedMotion, colorScheme) if (findingsForUrl.length === 0) { core.info(`No accessibility gaps were found on ${url}`) continue @@ -54,3 +39,65 @@ export default async function () { core.info(`Found ${findings.length} findings in total`) core.info("Finished 'find' action") } + +function loadUrlConfigs() { + const urlConfigInput = core.getInput('url_configs', {required: false}) + if (!urlConfigInput) return + + try { + const parsed = JSON.parse(urlConfigInput) + + if (!Array.isArray(parsed)) { + throw new Error("Input 'url_configs' must be a JSON array.") + } + + for (const item of parsed) { + if (typeof item !== 'object' || item === null || typeof item.url !== 'string') { + throw new Error("Each entry in 'url_configs' must be an object with a 'url' string field.") + } + } + + return parsed as UrlConfig[] + } catch (e) { + throw new Error(`Invalid 'url_configs' input: ${(e as Error).message}`) + } +} + +function loadUrls({urlConfigs}: {urlConfigs?: UrlConfig[]} = {}) { + // - no need to process this input if url_configs is provided + if (urlConfigs) return + + const urls: string[] = core.getMultilineInput('urls', {required: false}) + core.debug(`Input: 'urls: ${JSON.stringify(urls)}'`) + + if (urls.length === 0) { + throw new Error("Either 'urls' or 'url_configs' input must be provided.") + } + + return urls.map(url => ({url})) as UrlConfig[] +} + +function loadReducedMotion() { + const reducedMotionInput = core.getInput('reduced_motion', {required: false}) + if (!reducedMotionInput) return + + if (!['reduce', 'no-preference', null].includes(reducedMotionInput)) { + throw new Error( + "Input 'reduced_motion' must be one of: 'reduce', 'no-preference', or null per Playwright documentation.", + ) + } + return reducedMotionInput as ReducedMotionPreference +} + +function loadColorScheme() { + const colorSchemeInput = core.getInput('color_scheme', {required: false}) + if (!colorSchemeInput) return + + if (!['light', 'dark', 'no-preference', null].includes(colorSchemeInput)) { + throw new Error( + "Input 'color_scheme' must be one of: 'light', 'dark', 'no-preference', or null per Playwright documentation.", + ) + } + + return colorSchemeInput as ColorSchemePreference +} diff --git a/.github/actions/find/src/types.d.ts b/.github/actions/find/src/types.d.ts index f8fb720..dcbc860 100644 --- a/.github/actions/find/src/types.d.ts +++ b/.github/actions/find/src/types.d.ts @@ -37,3 +37,8 @@ export type AuthContextInput = { export type ReducedMotionPreference = 'reduce' | 'no-preference' | null export type ColorSchemePreference = 'light' | 'dark' | 'no-preference' | null + +export type UrlConfig = { + url: string + excludeSelectors?: string[] +} diff --git a/README.md b/README.md index 10ff503..88b68c1 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,7 @@ jobs: # 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. + # 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'. ``` > 👉 Update all `REPLACE_THIS` placeholders with your actual values. See [Action Inputs](#action-inputs) for details. @@ -113,23 +114,24 @@ 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` | -| `base_url` | No | GitHub API base URL used by Octokit. Set this for GitHub Enterprise Server (format: `https://HOSTNAME/api/v3`). Defaults to `https://api.github.com` | `https://ghe.example.com/api/v3` | -| `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` | -| `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]'` | +| Input | Required | Description | Example | +| ------------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------- | +| `urls` | No\* | Newline-delimited list of URLs to scan. Required unless `url_configs` is provided. | `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` | +| `base_url` | No | GitHub API base URL used by Octokit. Set this for GitHub Enterprise Server (format: `https://HOSTNAME/api/v3`). Defaults to `https://api.github.com` | `https://ghe.example.com/api/v3` | +| `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` | +| `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]'` | +| `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"]}]'` | --- diff --git a/action.yml b/action.yml index 054d6a4..b86a45e 100644 --- a/action.yml +++ b/action.yml @@ -4,8 +4,11 @@ description: 'Finds potential accessibility gaps, files GitHub issues to track t inputs: urls: description: 'Newline-delimited list of URLs to check for accessibility issues' - required: true + required: false multiline: true + url_configs: + description: "Stringified JSON array of URL config objects, each with a 'url' field and 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." + required: false repository: description: 'Repository (with owner) to file issues in' required: true @@ -109,6 +112,7 @@ runs: uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/find with: urls: ${{ inputs.urls }} + url_configs: ${{ inputs.url_configs }} auth_context: ${{ inputs.auth_context || steps.auth.outputs.auth_context }} include_screenshots: ${{ inputs.include_screenshots }} reduced_motion: ${{ inputs.reduced_motion }}