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
4 changes: 4 additions & 0 deletions .github/actions/find/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ configuration option.
[`colorScheme`](https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme)
configuration option.

#### `rendered_content_timeout`

**Optional** Timeout in milliseconds to wait for visible rendered content before scanning. If content is not detected in time, scanning continues. Default: `30000`.

#### `include_screenshots`

**Optional** Bool - whether to capture screenshots of scanned pages and include links to them in the issue
Expand Down
4 changes: 4 additions & 0 deletions .github/actions/find/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ inputs:
color_scheme:
description: 'Playwright colorScheme setting: https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme'
required: false
rendered_content_timeout:
description: 'Timeout in milliseconds to wait for visible rendered content before scanning. If content is not detected in time, scanning continues.'
required: false
default: '30000'

outputs:
findings_file:
Expand Down
12 changes: 12 additions & 0 deletions .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,15 @@ import {loadPlugins, invokePlugin} from './pluginManager/index.js'
import {getScansContext} from './scansContextProvider.js'
import * as core from '@actions/core'

const DEFAULT_RENDERED_CONTENT_TIMEOUT = 30000

export async function findForUrl(
urlConfig: UrlConfig,
authContext?: AuthContext,
includeScreenshots: boolean = false,
reducedMotion?: ReducedMotionPreference,
colorScheme?: ColorSchemePreference,
renderedContentTimeout: number = DEFAULT_RENDERED_CONTENT_TIMEOUT,
): Promise<Finding[]> {
const {url, excludeSelectors} = urlConfig
const browser = await playwright.chromium.launch({
Expand All @@ -27,6 +30,7 @@ export async function findForUrl(
const context = await browser.newContext(contextOptions)
const page = await context.newPage()
await page.goto(url)
await waitForRenderedContent({page, url, timeout: renderedContentTimeout})

const findings: Finding[] = []
const addFinding = async (findingData: Finding) => {
Expand Down Expand Up @@ -67,6 +71,14 @@ export async function findForUrl(
return findings
}

async function waitForRenderedContent({page, url, timeout}: {page: playwright.Page; url: string; timeout: number}) {
try {
await page.locator('body *:visible').first().waitFor({state: 'visible', timeout})
} catch (e) {
core.warning(`Unable to confirm rendered content for ${url} before scanning: ${e}`)
}
}

async function runAxeScan({
page,
addFinding,
Expand Down
22 changes: 21 additions & 1 deletion .github/actions/find/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export default async function () {
const urls = loadUrls({urlConfigs})
const reducedMotion = loadReducedMotion()
const colorScheme = loadColorScheme()
const renderedContentTimeout = loadRenderedContentTimeout()

const actualUrls = urlConfigs || urls || []

Expand All @@ -22,7 +23,14 @@ export default async function () {
for (const urlConfig of actualUrls) {
const {url} = urlConfig
core.info(`Preparing to scan ${url}`)
const findingsForUrl = await findForUrl(urlConfig, authContext, includeScreenshots, reducedMotion, colorScheme)
const findingsForUrl = await findForUrl(
urlConfig,
authContext,
includeScreenshots,
reducedMotion,
colorScheme,
renderedContentTimeout,
)
if (findingsForUrl.length === 0) {
core.info(`No accessibility gaps were found on ${url}`)
continue
Expand Down Expand Up @@ -101,3 +109,15 @@ function loadColorScheme() {

return colorSchemeInput as ColorSchemePreference
}

function loadRenderedContentTimeout() {
const renderedContentTimeoutInput = core.getInput('rendered_content_timeout', {required: false})
if (!renderedContentTimeoutInput) return

const renderedContentTimeout = Number(renderedContentTimeoutInput)
if (!Number.isSafeInteger(renderedContentTimeout) || renderedContentTimeout <= 0) {
throw new Error("Input 'rendered_content_timeout' must be a positive integer number of milliseconds.")
}

return renderedContentTimeout
}
123 changes: 108 additions & 15 deletions .github/actions/find/tests/findForUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,21 +7,54 @@ import * as pluginManager from '../src/pluginManager/index.js'
import type {Plugin} from '../src/pluginManager/types.js'
import {clearCache} from '../src/scansContextProvider.js'

const playwrightMocks = vi.hoisted(() => {
const pageGoto = vi.fn()
const pageWaitForLoadState = vi.fn()
const locatorWaitFor = vi.fn()
const locatorFirst = vi.fn(() => ({
waitFor: locatorWaitFor,
}))
const pageLocator = vi.fn(() => ({
first: locatorFirst,
}))
const pageUrl = vi.fn()
const contextClose = vi.fn()
const browserClose = vi.fn()
const contextNewPage = vi.fn(() => ({
goto: pageGoto,
waitForLoadState: pageWaitForLoadState,
locator: pageLocator,
url: pageUrl,
}))
const browserNewContext = vi.fn(() => ({
newPage: contextNewPage,
close: contextClose,
}))
const browserLaunch = vi.fn(() => ({
newContext: browserNewContext,
close: browserClose,
}))

return {
browserLaunch,
browserNewContext,
contextNewPage,
pageGoto,
pageWaitForLoadState,
pageLocator,
locatorFirst,
locatorWaitFor,
pageUrl,
contextClose,
browserClose,
}
})

vi.mock('@actions/core', {spy: true})
vi.mock('playwright', () => ({
default: {
chromium: {
launch: () => ({
newContext: () => ({
newPage: () => ({
pageUrl: '',
goto: () => {},
url: () => {},
}),
close: () => {},
}),
close: () => {},
}),
launch: playwrightMocks.browserLaunch,
},
},
}))
Expand All @@ -39,6 +72,10 @@ let loadedPlugins: Plugin[] = []
function clearAll() {
clearCache()
vi.clearAllMocks()
playwrightMocks.pageGoto.mockResolvedValue(undefined)
playwrightMocks.pageWaitForLoadState.mockResolvedValue(undefined)
playwrightMocks.locatorWaitFor.mockResolvedValue(undefined)
playwrightMocks.pageUrl.mockReturnValue('test.com')
}

describe('findForUrl', () => {
Expand All @@ -49,12 +86,68 @@ describe('findForUrl', () => {
async function axeOnlyTest() {
clearAll()

await findForUrl('test.com')
await findForUrl({url: 'test.com'})
expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1)
expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(0)
expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(0)
}

describe('page load handling', () => {
it('waits for late-rendered SPA content after navigation before scanning', async () => {
actionInput = ''
clearAll()
let resolveRenderedContent!: () => void
const renderedContent = new Promise<void>(resolve => {
resolveRenderedContent = resolve
})
playwrightMocks.locatorWaitFor.mockReturnValueOnce(renderedContent)

const scan = findForUrl({url: 'test.com'})
await new Promise(resolve => setTimeout(resolve, 0))

expect(playwrightMocks.pageGoto).toHaveBeenCalledWith('test.com')
expect(playwrightMocks.pageWaitForLoadState).not.toHaveBeenCalled()
expect(playwrightMocks.pageLocator).toHaveBeenCalledWith('body *:visible')
expect(playwrightMocks.locatorFirst).toHaveBeenCalledTimes(1)
expect(playwrightMocks.locatorWaitFor).toHaveBeenCalledWith({state: 'visible', timeout: 30000})
expect(playwrightMocks.pageGoto.mock.invocationCallOrder[0]).toBeLessThan(
playwrightMocks.locatorWaitFor.mock.invocationCallOrder[0],
)
expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(0)

resolveRenderedContent()
await scan

expect(playwrightMocks.locatorWaitFor.mock.invocationCallOrder[0]).toBeLessThan(
playwrightMocks.pageUrl.mock.invocationCallOrder[0],
)
expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1)
})

it('logs a warning and proceeds with scanning when minimal static pages do not render visible content', async () => {
const timeoutError = new Error('Timeout 30000ms exceeded')
actionInput = ''
clearAll()
playwrightMocks.locatorWaitFor.mockRejectedValueOnce(timeoutError)

await findForUrl({url: 'test.com'})

expect(core.warning).toHaveBeenCalledWith(
`Unable to confirm rendered content for test.com before scanning: ${timeoutError}`,
)
expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1)
})

it('uses the configured rendered content timeout', async () => {
actionInput = ''
clearAll()

await findForUrl({url: 'test.com'}, undefined, false, undefined, undefined, 1000)

expect(playwrightMocks.locatorWaitFor).toHaveBeenCalledWith({state: 'visible', timeout: 1000})
})
})

describe('when no scans list is provided', () => {
it('defaults to running only axe scan', async () => {
actionInput = ''
Expand All @@ -80,7 +173,7 @@ describe('findForUrl', () => {
actionInput = JSON.stringify(['axe', 'custom-scan-1'])
clearAll()

await findForUrl('test.com')
await findForUrl({url: 'test.com'})
expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1)
expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(1)
expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(1)
Expand All @@ -97,7 +190,7 @@ describe('findForUrl', () => {
actionInput = JSON.stringify(['custom-scan-1', 'custom-scan-2'])
clearAll()

await findForUrl('test.com')
await findForUrl({url: 'test.com'})
expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(0)
expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(1)
expect(pluginManager.invokePlugin).toHaveBeenCalledTimes(2)
Expand All @@ -112,7 +205,7 @@ describe('findForUrl', () => {
actionInput = JSON.stringify(['custom-scan-1'])
clearAll()

await findForUrl('test.com')
await findForUrl({url: 'test.com'})
expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1)
expect(loadedPlugins[1].default).toHaveBeenCalledTimes(0)
})
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,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
# rendered_content_timeout: 30000 # Optional: Milliseconds to wait for visible rendered content before scanning
# 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'.
```
Expand Down Expand Up @@ -130,6 +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` |
| `rendered_content_timeout` | No | Milliseconds to wait for visible rendered content before scanning. If content is not detected in time, scanning continues. Default: `30000` | `30000` |
| `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
5 changes: 5 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,10 @@ inputs:
color_scheme:
description: 'Playwright colorScheme setting: https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme'
required: false
rendered_content_timeout:
description: 'Timeout in milliseconds to wait for visible rendered content before scanning. If content is not detected in time, scanning continues.'
required: false
default: '30000'
scans:
description: 'Stringified JSON array of scans to perform. If not provided, only Axe will be performed'
required: false
Expand Down Expand Up @@ -117,6 +121,7 @@ runs:
include_screenshots: ${{ inputs.include_screenshots }}
reduced_motion: ${{ inputs.reduced_motion }}
color_scheme: ${{ inputs.color_scheme }}
rendered_content_timeout: ${{ inputs.rendered_content_timeout }}
scans: ${{ inputs.scans }}
- name: File
id: file
Expand Down