-
Notifications
You must be signed in to change notification settings - Fork 23
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
Add plugins #133
Changes from 110 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
74f131f
2ce029f
fbc7513
8f9b250
6ffffc7
765d845
7a17cc6
0359d54
d75e16e
9f9bb3d
59da986
538b7f0
b6140dd
a0f6007
e38c3db
6efea39
ef3edf8
9131d52
be91558
6f1b698
aed40f9
e502a80
92dff88
45d8c95
5751568
694a1db
9f6458c
d214546
8882eab
82114f6
64c7479
070ed73
78ea4c9
91d5222
21a5294
7ba1e07
9cda61b
417afdd
f588f0c
5537a3e
4056403
441aabb
7e1efb7
877646f
e83bfbf
1b7fc8d
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,33 +1,36 @@ | ||
| name: "Find" | ||
| description: "Finds potential accessibility gaps." | ||
| name: 'Find' | ||
abdulahmad307 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| 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' | ||
| 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 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, invokePlugin} from './pluginManager.js' | ||
| import {getScansContext} from './scansContextProvider.js' | ||
| import * as core from '@actions/core' | ||
|
|
||
| export async function findForUrl( | ||
| url: string, | ||
|
|
@@ -23,32 +26,71 @@ 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[] = [] | ||
| try { | ||
| const rawFindings = await new AxeBuilder({page}).analyze() | ||
|
|
||
| let screenshotId: string | undefined | ||
| const findings: Finding[] = [] | ||
| const addFinding = async (findingData: Finding) => { | ||
| let screenshotId | ||
| if (includeScreenshots) { | ||
| screenshotId = await generateScreenshots(page) | ||
| } | ||
| findings.push({...findingData, screenshotId}) | ||
| } | ||
|
|
||
| try { | ||
| const scansContext = getScansContext() | ||
|
|
||
| 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, 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, '''), | ||
| problemShort: violation.help.toLowerCase().replace(/'/g, '''), | ||
| problemUrl: violation.helpUrl.replace(/'/g, '''), | ||
| ruleId: violation.id, | ||
| solutionShort: violation.description.toLowerCase().replace(/'/g, '''), | ||
| solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, '''), | ||
| screenshotId, | ||
| })) | ||
| if (scansContext.shouldPerformAxeScan) { | ||
| await runAxeScan({page, addFinding}) | ||
| } | ||
| } 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({ | ||
| page, | ||
| addFinding, | ||
| }: { | ||
| page: playwright.Page | ||
| addFinding: (findingData: Finding, options?: {includeScreenshots?: boolean}) => Promise<void> | ||
| }) { | ||
| const url = page.url() | ||
| core.info(`Scanning ${url}`) | ||
| const rawFindings = await new AxeBuilder({page}).analyze() | ||
|
|
||
| if (rawFindings) { | ||
| for (const violation of rawFindings.violations) { | ||
| await addFinding({ | ||
| scannerType: 'axe', | ||
| url, | ||
| html: violation.nodes[0].html.replace(/'/g, '''), | ||
| problemShort: violation.help.toLowerCase().replace(/'/g, '''), | ||
| problemUrl: violation.helpUrl.replace(/'/g, '''), | ||
| ruleId: violation.id, | ||
| solutionShort: violation.description.toLowerCase().replace(/'/g, '''), | ||
| solutionLong: violation.nodes[0].failureSummary?.replace(/'/g, '''), | ||
| }) | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| 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 * as 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 | ||
abdulahmad307 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| export async function loadPlugins() { | ||
| try { | ||
| if (!pluginsLoaded) { | ||
| core.info('loading plugins') | ||
| 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 = path.join(__dirname, '../../../scanner-plugins/') | ||
| await loadPluginsFromPath({pluginsPath}) | ||
| } | ||
|
|
||
| // exported for mocking/testing. not for actual use | ||
| export async function loadCustomPlugins() { | ||
| core.info('Loading custom plugins') | ||
|
|
||
| const pluginsPath = path.join(process.cwd(), '/.github/scanner-plugins/') | ||
| await loadPluginsFromPath({pluginsPath}) | ||
| } | ||
|
|
||
| // exported for mocking/testing. not for actual use | ||
| export async function loadPluginsFromPath({pluginsPath}: {pluginsPath: string}) { | ||
| try { | ||
| const res = fs.readdirSync(pluginsPath) | ||
| for (const pluginFolder of res) { | ||
| const pluginFolderPath = path.join(pluginsPath, pluginFolder) | ||
|
|
||
| if (fs.existsSync(pluginFolderPath) && fs.lstatSync(pluginFolderPath).isDirectory()) { | ||
| core.info(`Found plugin: ${pluginFolder}`) | ||
| plugins.push(await dynamicImport(path.join(pluginsPath, 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}) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| import * as 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 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,13 @@ | ||
| export type Finding = { | ||
| scannerType: string | ||
| url: string | ||
| html: string | ||
| problemShort: string | ||
| problemUrl: string | ||
| solutionShort: string | ||
| solutionLong?: string | ||
| screenshotId?: string | ||
| ruleId: string | ||
|
||
| } | ||
|
|
||
| export type Cookie = { | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.