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
5 changes: 4 additions & 1 deletion .github/actions/find/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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<Finding[]> {
const {url, excludeSelectors} = urlConfig
const browser = await playwright.chromium.launch({
headless: true,
executablePath: process.env.CI ? '/usr/bin/google-chrome' : undefined,
Expand Down Expand Up @@ -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}`)
Expand All @@ -69,13 +70,18 @@ export async function findForUrl(
async function runAxeScan({
page,
addFinding,
excludeSelectors,
}: {
page: playwright.Page
addFinding: (findingData: Finding, options?: {includeScreenshots?: boolean}) => Promise<void>
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) {
Expand Down
99 changes: 73 additions & 26 deletions .github/actions/find/src/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -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
}
5 changes: 5 additions & 0 deletions .github/actions/find/src/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
}
36 changes: 19 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`<br>`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<br>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`<br>`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<br>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"]}]'` |

---

Expand Down
Loading