-
Notifications
You must be signed in to change notification settings - Fork 21
Add plugins #133
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Add plugins #133
Changes from all commits
af39fad
56272ed
6e30cf0
99f629a
28c2f32
66d0e7b
c18fa60
c1a88ee
4bcbc50
177b498
8568551
26f7450
920fd85
0dbb21d
0df2385
276016c
cf05219
6580781
93609d4
e3aad3e
05a12ad
c61c2ca
b0d19ec
c6929eb
500fba2
aa20c65
b0a666c
b87a5d8
b46b2e8
bc41a57
0742390
712291e
774622a
286bb84
97ca241
babf756
626723a
0dcb9d6
72a9ea8
426734d
dd97ecb
538185b
b89f21c
640b572
991fff4
beffd51
3668d95
0529838
98b2037
c2de456
ac003a1
321c623
0b87aaf
9a27f85
1d2753a
cd4771b
407bc84
2e09eb0
a8ec788
600d158
e86870e
4e43b7f
8402e61
a1d4095
64c996c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,27 +1,30 @@ | ||
| name: "Find" | ||
| description: "Finds potential accessibility gaps." | ||
| name: 'Find' | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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' | ||
| 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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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 | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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, '''), | ||
|
|
@@ -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) | ||
| } | ||
|
|
||
| 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 | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. yea so, if plugin loading fails for some reason, however, even if we didn't explicitly reset 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 another way we can do this is to move the On a personal level, I prefer the current approach because calling 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) | ||
abdulahmad307 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } 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) { | ||
abdulahmad307 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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 | ||
| } | ||
| } | ||
| 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 | ||
| } |
| 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) | ||
| }) | ||
| }) | ||
| }) |
Uh oh!
There was an error while loading. Please reload this page.