Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
76 commits
Select commit Hold shift + click to select a range
af39fad
testing
abdulahmad307 Feb 17, 2026
56272ed
testing
abdulahmad307 Feb 17, 2026
6e30cf0
testing
abdulahmad307 Feb 17, 2026
99f629a
test
abdulahmad307 Feb 17, 2026
28c2f32
test
abdulahmad307 Feb 17, 2026
66d0e7b
test
abdulahmad307 Feb 17, 2026
c18fa60
test
abdulahmad307 Feb 17, 2026
c1a88ee
try hardcoded string
abdulahmad307 Feb 17, 2026
4bcbc50
test import
abdulahmad307 Feb 17, 2026
177b498
try require
abdulahmad307 Feb 17, 2026
8568551
testing local index file
abdulahmad307 Feb 18, 2026
26f7450
testing plugin usage
abdulahmad307 Feb 18, 2026
920fd85
try new plugin folder
abdulahmad307 Feb 18, 2026
0dbb21d
update local path lookup
abdulahmad307 Feb 18, 2026
0df2385
update local path lookup
abdulahmad307 Feb 18, 2026
276016c
update local path lookup
abdulahmad307 Feb 18, 2026
cf05219
update local path lookup
abdulahmad307 Feb 18, 2026
6580781
update local path lookup
abdulahmad307 Feb 18, 2026
93609d4
update local path lookup
abdulahmad307 Feb 18, 2026
e3aad3e
update local path lookup
abdulahmad307 Feb 18, 2026
05a12ad
update local path lookup
abdulahmad307 Feb 18, 2026
c61c2ca
update local path lookup
abdulahmad307 Feb 18, 2026
b0d19ec
update local path lookup
abdulahmad307 Feb 18, 2026
c6929eb
add all new content
abdulahmad307 Feb 18, 2026
500fba2
log dirs
abdulahmad307 Feb 18, 2026
aa20c65
log dirs
abdulahmad307 Feb 18, 2026
b0a666c
log dirs
abdulahmad307 Feb 18, 2026
b87a5d8
log dirs
abdulahmad307 Feb 18, 2026
b46b2e8
log dirs
abdulahmad307 Feb 18, 2026
bc41a57
test import
abdulahmad307 Feb 18, 2026
0742390
test import
abdulahmad307 Feb 18, 2026
712291e
test import
abdulahmad307 Feb 18, 2026
774622a
test import
abdulahmad307 Feb 18, 2026
286bb84
test reflow plugin
abdulahmad307 Feb 18, 2026
97ca241
move findings back into function
abdulahmad307 Feb 18, 2026
babf756
remove unused test folder
abdulahmad307 Feb 18, 2026
626723a
update code structure
abdulahmad307 Feb 18, 2026
0dcb9d6
update code structure
abdulahmad307 Feb 18, 2026
72a9ea8
test reading files for custom plugins
abdulahmad307 Feb 23, 2026
426734d
test reading files for custom plugins
abdulahmad307 Feb 23, 2026
dd97ecb
test reading files for custom plugins
abdulahmad307 Feb 23, 2026
538185b
test with custom plugins
abdulahmad307 Feb 23, 2026
b89f21c
test with custom plugins
abdulahmad307 Feb 23, 2026
640b572
test with custom plugins
abdulahmad307 Feb 23, 2026
991fff4
test with custom plugins
abdulahmad307 Feb 23, 2026
beffd51
testing after refactor
abdulahmad307 Feb 23, 2026
3668d95
testing after refactor
abdulahmad307 Feb 23, 2026
0529838
testing after refactor
abdulahmad307 Feb 23, 2026
98b2037
move pluginManager to dedicated file to not bloat the 'find' file
abdulahmad307 Feb 23, 2026
c2de456
update messaging on plugin abort
abdulahmad307 Feb 23, 2026
ac003a1
merge main
abdulahmad307 Feb 23, 2026
321c623
add plugin manager test
abdulahmad307 Feb 23, 2026
0b87aaf
eslint cleanup
abdulahmad307 Feb 23, 2026
9a27f85
fix formatting
abdulahmad307 Feb 24, 2026
1d2753a
read scans input
abdulahmad307 Feb 24, 2026
cd4771b
add tests
abdulahmad307 Feb 24, 2026
407bc84
update comments
abdulahmad307 Feb 24, 2026
2e09eb0
remove console.log
abdulahmad307 Feb 24, 2026
a8ec788
remove .vscode and add to gitignore
abdulahmad307 Feb 24, 2026
600d158
clean up a line to make it easier to read
abdulahmad307 Feb 24, 2026
e86870e
update action and readme
abdulahmad307 Feb 24, 2026
4e43b7f
fix casing for 'scans' in readme
abdulahmad307 Feb 24, 2026
8402e61
copilot PR feedback
abdulahmad307 Feb 24, 2026
a1d4095
update string literal
abdulahmad307 Feb 25, 2026
64c996c
update code comments with more context
abdulahmad307 Feb 25, 2026
74f131f
update comment
abdulahmad307 Feb 26, 2026
2ce029f
PR feedback
abdulahmad307 Mar 9, 2026
fbc7513
change reflow test to 'test-plugin'
abdulahmad307 Mar 9, 2026
8f9b250
merge main
abdulahmad307 Mar 9, 2026
6ffffc7
fix prettier stuff
abdulahmad307 Mar 9, 2026
765d845
add plugin docs
abdulahmad307 Mar 11, 2026
7a17cc6
update docs with minor detail
abdulahmad307 Mar 11, 2026
0359d54
move plugin docs to standalone file
abdulahmad307 Mar 11, 2026
d75e16e
Merge branch 'main' into abdul/test-plugins
abdulahmad307 Mar 11, 2026
9f9bb3d
update docs more
abdulahmad307 Mar 11, 2026
59da986
update pulgins docs
abdulahmad307 Mar 11, 2026
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
8 changes: 8 additions & 0 deletions .github/actions/find/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
27 changes: 15 additions & 12 deletions .github/actions/find/action.yml
Original file line number Diff line number Diff line change
@@ -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'
21 changes: 21 additions & 0 deletions .github/actions/find/src/dynamicImport.ts
Original file line number Diff line number Diff line change
@@ -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)
}
91 changes: 72 additions & 19 deletions .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Finding[]> {
Expand All @@ -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, '&apos;'),
problemShort: violation.help.toLowerCase().replace(/'/g, '&apos;'),
problemUrl: violation.helpUrl.replace(/'/g, '&apos;'),
ruleId: violation.id,
solutionShort: violation.description.toLowerCase().replace(/'/g, '&apos;'),
solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, '&apos;'),
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, '&apos;'),
problemShort: violation.help.toLowerCase().replace(/'/g, '&apos;'),
problemUrl: violation.helpUrl.replace(/'/g, '&apos;'),
ruleId: violation.id,
solutionShort: violation.description.toLowerCase().replace(/'/g, '&apos;'),
solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, '&apos;'),
screenshotId,
}))
findings.push(...(axeFindings || []))
}
9 changes: 5 additions & 4 deletions .github/actions/find/src/generateScreenshots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand All @@ -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
}

Expand Down
103 changes: 103 additions & 0 deletions .github/actions/find/src/pluginManager.ts
Original file line number Diff line number Diff line change
@@ -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<void>
}

export type Plugin = {
name: string
default: (options: PluginDefaultParams) => Promise<void>
}

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})
}
36 changes: 36 additions & 0 deletions .github/actions/find/src/scansContextProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import core from '@actions/core'

type ScansContext = {
scansToPerform: Array<string>
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
}
Loading
Loading