Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
65 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
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 @@ -19,6 +19,14 @@ https://primer.style/octicons/

**Optional** Stringified JSON object containing `username`, `password`, `cookies`, and/or `localStorage` from an authenticated session. For example: `{"username":"some-user","password":"correct-horse-battery-staple","cookies":[{"name":"theme-preference","value":"light","domain":"primer.style","path":"/"}],"localStorage":{"https://primer.style":{"theme-preference":"light"}}}`

#### `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. Valid options currently include 'axe'

### Outputs

#### `findings`
Expand Down
23 changes: 13 additions & 10 deletions .github/actions/find/action.yml
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
name: "Find"
description: "Finds potential accessibility gaps."
name: 'Find'
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

all these quote changes were automatically done by prettier

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"

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 I'm not sure how to mock
// the dynamic import function, so mocking this instead
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also, i'm worried that mocking it will break imports across the board. and i couldn't find anything useful on google

// (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)
}
39 changes: 36 additions & 3 deletions .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ import AxeBuilder from '@axe-core/playwright'
import playwright from 'playwright'
import {AuthContext} from './AuthContext.js'
import {generateScreenshots} from './generateScreenshots.js'
import {loadPlugins} from './pluginManager.js'
import {getScansContext} from './scansContextProvider.js'
import axe from 'axe-core'

export async function findForUrl(
url: string,
Expand All @@ -19,16 +22,45 @@ export async function findForUrl(
await page.goto(url)
console.log(`Scanning ${page.url()}`)

let findings: Finding[] = []
const findings: Finding[] = []
const addFinding = (findingData: Finding) => {
findings.push(findingData)
}

try {
const rawFindings = await new AxeBuilder({page}).analyze()
const scansContext = getScansContext()

let rawFindings: axe.AxeResults | undefined
if (scansContext.shouldPerformAxeScan) {
rawFindings = await new AxeBuilder({page}).analyze()
}

// - this if condition is not required, but makes it easier to make assertions
// in unit tests on whether 'loadPlugins' was called or not (it does have the added
// benefit of completely skipping plugin loading if we just want axe - so thats
// a minor performance boost)
// - alternatively, we can wrap the 'plugin.default(...)' call in another function
// and make assertions on whether that function was called or not
// - the other option is to wrap each plugin in a class instance
// and make assertions on something like 'plugin.run' being called or not
Copy link
Author

@abdulahmad307 abdulahmad307 Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'm open to suggestions here: we can leave it as is if everyone is ok with it, or we can implement another solution as outlined in the comment (or if someone has a better approach)

if (scansContext.shouldRunPlugins) {
const plugins = await loadPlugins()
for (const plugin of plugins) {
if (scansContext.scansToPerform.includes(plugin.name)) {
console.log('Running plugin: ', plugin.name)
await plugin.default({page, addFinding, url})
} else {
console.log(`Skipping plugin ${plugin.name} because it is not included in the 'scans' input`)
}
}
}

let screenshotId: string | undefined
if (includeScreenshots) {
screenshotId = await generateScreenshots(page)
}

findings = rawFindings.violations.map(violation => ({
const axeFindings = rawFindings?.violations.map(violation => ({
scannerType: 'axe',
url,
html: violation.nodes[0].html.replace(/'/g, '''),
Expand All @@ -39,6 +71,7 @@ export async function findForUrl(
solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, '''),
screenshotId,
}))
findings.push(...(axeFindings || []))
} catch (e) {
console.error('Error during accessibility scan:', e)
}
Expand Down
88 changes: 88 additions & 0 deletions .github/actions/find/src/pluginManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
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'

// Helper to get __dirname equivalent in ES Modules
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

export type Plugin = {
name: string
default: (options: {page: playwright.Page; addFinding: (findingData: Finding) => void; url: string}) => Promise<void>
}

const plugins: Plugin[] = []
let pluginsLoaded = false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be missing something, but is this state needed vs. checking for plugins.length > 0?

Copy link
Author

@abdulahmad307 abdulahmad307 Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yea so, if plugin loading fails for some reason, plugins.length will still be 0 - notice these lines:

} catch {
  plugins.length = 0
  console.log(abortError)Expand commentComment on lines R28 to R30Resolved
} finally {
  pluginsLoaded = true
  return plugins
}

however, even if we didn't explicitly reset plugins.length back to 0 on error, if we run into an error before any plugin is loaded, the length will still be 0.

In this case (as I'm sure you're already aware) the code will re-try on every page it reaches if we check against plugins.length > 0 (because findForUrl runs for each url, and will likely fail every time). To avoid this, I used a boolean (which is set to true regardless of failure). And the only reason I care about this running many times is because it could be considered a 'heavy' operation (reading the file system and iterating on the directories).

another way we can do this is to move the loadPlugins call to the start of the action so it only runs once, and then we can have another function to getPlugins that we call in findForUrl. This way we can omit the bool and the if condition altogether. The thing to consider here is if someone accidentally calls 'loadPlugins' somewhere else in the future, we end up running that logic more than once (potentially many times if it gets moved to findForUrl by accident). I'm ok with either way but I think keeping it as-is is safer.

On a personal level, I prefer the current approach because calling loadPlugins at the top of the action is a bit out of context - someone reading the code for the first time (or without knowing what plugins are or how they work) may not understand why we're calling that there and what it does in the grand-scheme of things. Not until they reach findForUrl will they understand why that exists. Whereas calling it in the place where we do need to work directly with the returned plugins gives it more context.

The other benefit (which is not critical in this case) is the current approach is lazy-loaded on demand. I don't think we care too much about this micro-optimization in an action run. It would be more significant in a user-facing program to delay loading of things all at once at the beginning of the program.

but either approach will get the job done. lmk what you think


export async function loadPlugins() {
console.log('loading plugins')

try {
if (!pluginsLoaded) {
await loadBuiltInPlugins()
await loadCustomPlugins()
}
} catch {
plugins.length = 0
console.log(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() {
console.log('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() {
console.log('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()) {
console.log('Found plugin: ', pluginFolder)
plugins.push(await dynamicImport(path.join(importPath, pluginFolder, '/index.js')))
}
}
} catch (e) {
// - log errors here for granular info
console.log('error: ')
console.log(e)
// - throw error to handle aborting the plugin scans
throw e
}
}
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 scansJson = core.getInput('scans', {required: false})
const scansToPerform = JSON.parse(scansJson || '[]')
// - 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: !scansJson || scansToPerform.includes('axe'),
shouldRunPlugins: scansToPerform.length > 0 && !onlyAxeScan,
}
}

return scansContext
}

export function clearCache() {
scansContext = undefined
}
104 changes: 104 additions & 0 deletions .github/actions/find/tests/findForUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
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 {default: 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))

async function axeOnlyTest() {
clearAll()

await findForUrl('test.com')
expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1)
expect(pluginManager.loadPlugins).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 () => {
actionInput = JSON.stringify(['axe', 'custom-scan'])
clearAll()

await findForUrl('test.com')
expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1)
expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(1)
})
})

describe('and the list does not include axe', () => {
it('only runs plugins', async () => {
actionInput = JSON.stringify(['custom-scan'])
clearAll()

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

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)
})
})
})
Loading