From 11584cd6d5dc78dc2826c6a0b0c08b463e0dc189 Mon Sep 17 00:00:00 2001 From: Helen Hou-Sandi Date: Sat, 7 Nov 2026 14:00:36 -0500 Subject: [PATCH] Add configurable plugin failure policy Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/find/README.md | 9 + .github/actions/find/action.yml | 4 + .github/actions/find/src/findForUrl.ts | 16 + .github/actions/find/src/index.ts | 10 + .../actions/find/src/scansContextProvider.ts | 2 + .github/actions/find/tests/index.test.ts | 10 +- .../find/tests/pluginFailurePolicy.test.ts | 293 ++++++++++++++++++ PLUGINS.md | 32 ++ README.md | 2 + action.yml | 5 + 10 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 .github/actions/find/tests/pluginFailurePolicy.test.ts diff --git a/.github/actions/find/README.md b/.github/actions/find/README.md index 651babf8..17e8d05b 100644 --- a/.github/actions/find/README.md +++ b/.github/actions/find/README.md @@ -49,6 +49,15 @@ configuration option. Allowlisted NPM plugins use an object with `name`, `package`, and optional `version`. If not provided, only Axe will be performed. See [the plugin docs](../../../PLUGINS.md#loading-plugins-from-npm-packages) for an example. +#### `plugin_failure_policy` + +**Optional** `log-and-continue` (default) or `abort`. Applies to built-in, local, and NPM plugins, not +Axe or AccessLint. The default logs scan errors and returns partial findings, preserving existing behavior +(a thrown plugin error skips the remaining scans on that URL; subsequent URLs still run). +`abort` rejects failed or unavailable requested plugins, stops subsequent scans and URLs, and writes no +findings file or output for the run. See [plugin failure behavior](../../../PLUGINS.md#plugin-failure-behavior) +for limitations. + ### Outputs #### `findings_file` diff --git a/.github/actions/find/action.yml b/.github/actions/find/action.yml index 9edff453..ef4a7a70 100644 --- a/.github/actions/find/action.yml +++ b/.github/actions/find/action.yml @@ -19,6 +19,10 @@ inputs: scans: description: "Stringified JSON array of scans to perform. Core engines are 'axe' and 'accesslint'. Any other entry is either a plugin name (string) or, for a first-party NPM-published plugin, an object with 'name', 'package', and optional 'version'. If not provided, only Axe will be performed" required: false + plugin_failure_policy: + description: "Plugin failure behavior: 'log-and-continue' preserves partial findings (default); 'abort' fails before publishing findings when a requested plugin fails or is unavailable. Does not change core engine failure behavior." + required: false + default: 'log-and-continue' reduced_motion: description: 'Playwright reducedMotion setting: https://playwright.dev/docs/api/class-browser#browser-new-page-option-reduced-motion' required: false diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index ebbf0b54..c6801c7d 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -17,6 +17,8 @@ import * as core from '@actions/core' const SELECTOR_WAIT_TIMEOUT = 30000 +export type PluginFailurePolicy = 'log-and-continue' | 'abort' + export async function findForUrl( urlConfig: UrlConfig, authContext?: AuthContext, @@ -24,6 +26,7 @@ export async function findForUrl( reducedMotion?: ReducedMotionPreference, colorScheme?: ColorSchemePreference, ignoreHttpsErrors: boolean = false, + pluginFailurePolicy: PluginFailurePolicy = 'log-and-continue', ): Promise { const {url, excludeSelectors, waitForSelectors} = urlConfig const browser = await playwright.chromium.launch({ @@ -56,13 +59,22 @@ export async function findForUrl( findings.push({...findingData, screenshotId, originalUrl: url}) } + let pluginStage: string | undefined try { const scansContext = getScansContext() if (scansContext.shouldRunPlugins) { + pluginStage = 'loading requested plugins' const plugins = await loadPlugins() + if (pluginFailurePolicy === 'abort') { + const unavailable = scansContext.pluginScans.filter(name => !plugins.some(plugin => plugin.name === name)) + if (unavailable.length > 0) { + throw new Error(`Requested plugins are unavailable: ${unavailable.join(', ')}`) + } + } for (const plugin of plugins) { if (scansContext.scansToPerform.includes(plugin.name)) { + pluginStage = `running plugin '${plugin.name}'` core.info(`Running plugin: ${plugin.name}`) await invokePlugin({ plugin, @@ -73,6 +85,7 @@ export async function findForUrl( core.info(`Skipping plugin ${plugin.name} because it is not included in the 'scans' input`) } } + pluginStage = undefined } if (scansContext.shouldPerformAxeScan) { @@ -84,6 +97,9 @@ export async function findForUrl( } } catch (e) { core.error(`Error during accessibility scan: ${e}`) + if (pluginFailurePolicy === 'abort' && pluginStage) { + throw new Error(`Plugin failure while ${pluginStage} on ${url}: ${e}`, {cause: e}) + } } return findings } finally { diff --git a/.github/actions/find/src/index.ts b/.github/actions/find/src/index.ts index 836161a2..f605f073 100644 --- a/.github/actions/find/src/index.ts +++ b/.github/actions/find/src/index.ts @@ -12,6 +12,7 @@ export default async function () { const reducedMotion = loadReducedMotion() const colorScheme = loadColorScheme() const ignoreHttpsErrors = loadIgnoreHttpsErrors() + const pluginFailurePolicy = loadPluginFailurePolicy() const actualUrls = urlConfigs || urls || [] @@ -30,6 +31,7 @@ export default async function () { reducedMotion, colorScheme, ignoreHttpsErrors, + pluginFailurePolicy, ) if (findingsForUrl.length === 0) { core.info(`No accessibility gaps were found on ${url}`) @@ -120,3 +122,11 @@ function loadColorScheme() { function loadIgnoreHttpsErrors() { return core.getInput('ignore_https_errors', {required: false}).toLowerCase() === 'true' } + +function loadPluginFailurePolicy() { + const value = core.getInput('plugin_failure_policy', {required: false}) || 'log-and-continue' + if (value !== 'log-and-continue' && value !== 'abort') { + throw new Error("Input 'plugin_failure_policy' must be one of: 'log-and-continue', 'abort'.") + } + return value +} diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index d9596e89..b03e266c 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -4,6 +4,7 @@ import type {NpmPluginRequest} from './pluginManager/types.js' type ScansContext = { scansToPerform: Array npmPlugins: NpmPluginRequest[] + pluginScans: string[] shouldPerformAxeScan: boolean shouldPerformAccesslintScan: boolean shouldRunPlugins: boolean @@ -54,6 +55,7 @@ export function getScansContext() { scansContext = { scansToPerform, npmPlugins, + pluginScans, // - 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 diff --git a/.github/actions/find/tests/index.test.ts b/.github/actions/find/tests/index.test.ts index 13cf72f8..77c3a0ff 100644 --- a/.github/actions/find/tests/index.test.ts +++ b/.github/actions/find/tests/index.test.ts @@ -43,7 +43,15 @@ describe('url_configs', () => { await find() - expect(mocks.findForUrl).toHaveBeenCalledWith(urlConfig, expect.anything(), false, undefined, undefined, false) + expect(mocks.findForUrl).toHaveBeenCalledWith( + urlConfig, + expect.anything(), + false, + undefined, + undefined, + false, + 'log-and-continue', + ) }) it('rejects waitForSelectors values that are not arrays of strings', async () => { diff --git a/.github/actions/find/tests/pluginFailurePolicy.test.ts b/.github/actions/find/tests/pluginFailurePolicy.test.ts new file mode 100644 index 00000000..b0577c4b --- /dev/null +++ b/.github/actions/find/tests/pluginFailurePolicy.test.ts @@ -0,0 +1,293 @@ +import {beforeEach, describe, expect, it, vi} from 'vitest' +import * as core from '@actions/core' +import fs from 'node:fs' +import {AxeBuilder} from '@axe-core/playwright' +import {accesslintAudit} from '@accesslint/playwright' +import find from '../src/index.js' +import {clearCache} from '../src/scansContextProvider.js' +import {generateScreenshots} from '../src/generateScreenshots.js' +import type {Plugin} from '../src/pluginManager/types.js' +import type {FindingInput} from '../src/types.d.js' + +const mocks = vi.hoisted(() => { + const page = {goto: vi.fn(), url: vi.fn(() => 'https://example.com')} + const contextClose = vi.fn() + const browserClose = vi.fn() + const newPage = vi.fn(() => page) + return { + inputs: {} as Record, + plugins: [] as Plugin[], + loadPlugins: vi.fn(), + invokePlugin: vi.fn(), + page, + contextClose, + browserClose, + newPage, + launch: vi.fn(() => ({ + newContext: vi.fn(() => ({newPage, close: contextClose})), + close: browserClose, + })), + } +}) + +vi.mock('@actions/core', () => ({ + getInput: vi.fn((name: string) => mocks.inputs[name] ?? ''), + getMultilineInput: vi.fn(() => []), + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + setOutput: vi.fn(), +})) +vi.mock('node:fs', {spy: true}) +vi.mock('playwright', () => ({default: {chromium: {launch: mocks.launch}}})) +vi.mock('../src/pluginManager/index.js', () => ({ + loadPlugins: mocks.loadPlugins, + invokePlugin: mocks.invokePlugin, +})) +vi.mock('@axe-core/playwright', () => { + const builder = vi.fn() + builder.prototype.analyze = vi.fn(async () => ({violations: []})) + return {AxeBuilder: builder} +}) +vi.mock('@accesslint/playwright', () => ({ + accesslintAudit: vi.fn(async () => ({violations: []})), +})) +vi.mock('../src/generateScreenshots.js', () => ({ + generateScreenshots: vi.fn(), +})) + +const finding: FindingInput = { + scannerType: 'custom', + url: 'https://example.com', + html: '', + problemShort: 'button needs a label', + problemUrl: 'https://example.com/rules/label', + ruleId: 'label', + solutionShort: 'add a label', +} + +beforeEach(() => { + vi.clearAllMocks() + clearCache() + for (const key of Object.keys(mocks.inputs)) delete mocks.inputs[key] + mocks.inputs.url_configs = JSON.stringify([ + {url: 'https://example.com/first'}, + {url: 'https://example.com/second'}, + {url: 'https://example.com/third'}, + ]) + mocks.inputs.include_screenshots = 'false' + mocks.plugins = [] + mocks.loadPlugins.mockImplementation(async () => mocks.plugins) + mocks.invokePlugin.mockImplementation(({plugin, page, addFinding}) => plugin.default({page, addFinding})) + vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}) +}) + +function expectNoPublication() { + expect(fs.writeFileSync).not.toHaveBeenCalled() + expect(core.setOutput).not.toHaveBeenCalled() +} + +describe('plugin_failure_policy action entry point', () => { + it.each(['', 'log-and-continue'])('preserves partial findings and subsequent URLs for policy %j', async policy => { + mocks.inputs.plugin_failure_policy = policy + mocks.inputs.scans = '["custom", "later", "axe", "accesslint"]' + mocks.plugins = [ + { + name: 'custom', + default: vi.fn(async ({addFinding}) => { + await addFinding(finding) + throw new Error('synthetic plugin failure') + }), + }, + {name: 'later', default: vi.fn()}, + ] + + await find() + + expect(mocks.newPage).toHaveBeenCalledTimes(3) + expect(mocks.plugins[0].default).toHaveBeenCalledTimes(3) + expect(mocks.plugins[1].default).not.toHaveBeenCalled() + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + expect(accesslintAudit).not.toHaveBeenCalled() + expect(core.error).toHaveBeenCalledTimes(3) + expect(core.error).toHaveBeenCalledWith('Error during accessibility scan: Error: synthetic plugin failure') + expect(fs.writeFileSync).toHaveBeenCalledOnce() + const results = JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0][1] as string) + expect(results).toEqual( + ['first', 'second', 'third'].map(suffix => ({ + ...finding, + originalUrl: `https://example.com/${suffix}`, + })), + ) + expect(core.setOutput).toHaveBeenCalledWith('findings_file', expect.any(String)) + expect(mocks.contextClose).toHaveBeenCalledTimes(3) + expect(mocks.browserClose).toHaveBeenCalledTimes(3) + }) + + it('aborts on a plugin rejection without publishing findings from any URL', async () => { + const fault = new Error('synthetic plugin failure') + mocks.inputs.plugin_failure_policy = 'abort' + mocks.inputs.scans = '["custom", "later", "axe", "accesslint"]' + mocks.plugins = [ + { + name: 'custom', + default: vi + .fn(async ({addFinding}) => { + await addFinding(finding) + throw fault + }) + .mockImplementationOnce(async ({addFinding}) => { + await addFinding(finding) + }), + }, + {name: 'later', default: vi.fn()}, + ] + + await expect(find()).rejects.toMatchObject({ + message: + "Plugin failure while running plugin 'custom' on https://example.com/second: Error: synthetic plugin failure", + cause: fault, + }) + + expect(mocks.newPage).toHaveBeenCalledTimes(2) + expect(mocks.plugins[1].default).toHaveBeenCalledTimes(1) + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) + expect(accesslintAudit).toHaveBeenCalledTimes(1) + expect(core.error).toHaveBeenCalledWith(`Error during accessibility scan: ${fault}`) + expect(mocks.contextClose).toHaveBeenCalledTimes(2) + expect(mocks.browserClose).toHaveBeenCalledTimes(2) + expectNoPublication() + }) + + it('aborts on a synchronous plugin throw', async () => { + mocks.inputs.plugin_failure_policy = 'abort' + mocks.inputs.scans = '["custom", "axe"]' + mocks.plugins = [ + { + name: 'custom', + default: vi.fn(() => { + throw new Error('synchronous fault') + }), + }, + ] + + await expect(find()).rejects.toThrow("Plugin failure while running plugin 'custom'") + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + expect(mocks.browserClose).toHaveBeenCalledOnce() + expectNoPublication() + }) + + it('aborts when a plugin propagates a finding callback failure', async () => { + mocks.inputs.plugin_failure_policy = 'abort' + mocks.inputs.include_screenshots = 'true' + mocks.inputs.scans = '["custom", "axe"]' + mocks.plugins = [{name: 'custom', default: vi.fn(async ({addFinding}) => addFinding(finding))}] + vi.mocked(generateScreenshots).mockRejectedValueOnce(new Error('screenshot fault')) + + await expect(find()).rejects.toThrow("Plugin failure while running plugin 'custom'") + + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + expect(mocks.browserClose).toHaveBeenCalledOnce() + expectNoPublication() + }) + + it.each([ + ['built-in', '["built-in-check", "healthy", "axe", "accesslint"]'], + ['local', '["local-check", "healthy", "axe", "accesslint"]'], + ['NPM', '[{"name":"npm-check","package":"@example/plugin"}, "healthy", "axe", "accesslint"]'], + ])('rejects an unavailable requested %s plugin before any scanning', async (_source, scans) => { + mocks.inputs.plugin_failure_policy = 'abort' + mocks.inputs.scans = scans + mocks.plugins = [{name: 'healthy', default: vi.fn()}] + + await expect(find()).rejects.toThrow('Requested plugins are unavailable:') + + expect(mocks.invokePlugin).not.toHaveBeenCalled() + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + expect(accesslintAudit).not.toHaveBeenCalled() + expect(mocks.newPage).toHaveBeenCalledOnce() + expect(mocks.contextClose).toHaveBeenCalledOnce() + expect(mocks.browserClose).toHaveBeenCalledOnce() + expectNoPublication() + }) + + it('keeps skipped/unavailable requested plugins compatible in continue mode', async () => { + mocks.inputs.scans = '["unavailable", "axe"]' + + await find() + + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(3) + expect(core.setOutput).toHaveBeenCalledOnce() + }) + + it.each(['log-and-continue', 'abort'])('handles plugin loader rejection in %s mode', async policy => { + mocks.inputs.plugin_failure_policy = policy + mocks.inputs.scans = '["custom", "axe"]' + mocks.loadPlugins.mockRejectedValue(new Error('loader fault')) + + if (policy === 'abort') { + await expect(find()).rejects.toThrow('Plugin failure while loading requested plugins') + expect(mocks.newPage).toHaveBeenCalledOnce() + expectNoPublication() + } else { + await find() + expect(mocks.newPage).toHaveBeenCalledTimes(3) + expect(core.setOutput).toHaveBeenCalledOnce() + } + expect(core.error).toHaveBeenCalledWith('Error during accessibility scan: Error: loader fault') + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + }) + + it('publishes healthy built-in/local/NPM plugin findings in abort mode', async () => { + mocks.inputs.plugin_failure_policy = 'abort' + mocks.inputs.scans = + '["built-in-check","local-check",{"name":"npm-check","package":"@example/plugin"},"axe","accesslint"]' + mocks.plugins = ['built-in-check', 'local-check', 'npm-check'].map(name => ({ + name, + default: vi.fn(async ({addFinding}) => addFinding(finding)), + })) + + await find() + + expect(mocks.invokePlugin).toHaveBeenCalledTimes(9) + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(3) + expect(accesslintAudit).toHaveBeenCalledTimes(3) + expect(core.setOutput).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0][1] as string)).toHaveLength(9) + }) + + it.each(['axe', 'accesslint'])('preserves %s failure handling even in abort mode', async engine => { + mocks.inputs.plugin_failure_policy = 'abort' + mocks.inputs.scans = JSON.stringify(['healthy', engine]) + mocks.plugins = [{name: 'healthy', default: vi.fn(async ({addFinding}) => addFinding(finding))}] + const fault = new Error('core engine fault') + if (engine === 'axe') vi.mocked(AxeBuilder.prototype.analyze).mockRejectedValueOnce(fault) + else vi.mocked(accesslintAudit).mockRejectedValueOnce(fault) + + await find() + + expect(mocks.newPage).toHaveBeenCalledTimes(3) + expect(core.error).toHaveBeenCalledWith(`Error during accessibility scan: ${fault}`) + expect(core.setOutput).toHaveBeenCalledOnce() + expect(JSON.parse(vi.mocked(fs.writeFileSync).mock.calls[0][1] as string)).toHaveLength(3) + }) + + it.each(['continue', 'ABORT', 'invalid'])('rejects invalid value %j before scanning or publishing', async value => { + mocks.inputs.plugin_failure_policy = value + + await expect(find()).rejects.toThrow("Input 'plugin_failure_policy' must be one of: 'log-and-continue', 'abort'.") + + expect(mocks.launch).not.toHaveBeenCalled() + expectNoPublication() + }) + + it('declares the default on both actions and forwards the composite input to find', () => { + const root = fs.readFileSync(new URL('../../../../action.yml', import.meta.url), 'utf8') + const action = fs.readFileSync(new URL('../action.yml', import.meta.url), 'utf8') + for (const metadata of [root, action]) { + expect(metadata).toMatch(/ plugin_failure_policy:\n(?: .*\n)* default: 'log-and-continue'/) + } + expect(root).toContain(' plugin_failure_policy: ${{ inputs.plugin_failure_policy }}') + }) +}) diff --git a/PLUGINS.md b/PLUGINS.md index 7dd702df..88fc2b91 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -90,6 +90,38 @@ jobs: > - If an NPM plugin shares a name with a built-in or local plugin, the built-in/local plugin wins and the NPM one is skipped. > - Plugin configuration works the same as local plugins: place a config-only folder at `./.github/scanner-plugins//config.json` in your repo (the plugin reads its config relative to the repo you run the workflow from). +## Plugin failure behavior + +Set `plugin_failure_policy` on the scanner action (or directly on the `find` action): + +```yaml +with: + scans: '["axe", "my-custom-plugin"]' + plugin_failure_policy: abort +``` + +The accepted values are `log-and-continue` (default) and `abort`; other values fail input validation. +The policy applies uniformly to built-in, local, and NPM plugins. + +- `log-and-continue` preserves existing behavior: scan errors are logged, findings collected before a + thrown error are returned, and subsequent URLs still run. A thrown plugin error skips the remaining + plugins and core engines on that URL; it does not resume the scan loop. Plugin loading retains its + existing logging/skipping behavior, including skipped unavailable plugins. +- `abort` requires every requested plugin name to be available after loading, before any plugin runs. + A missing, skipped, invalid, or failed-to-load requested plugin causes failure. Existing built-in/local + precedence over an NPM plugin of the same name still applies. A plugin invocation error is logged and + propagated with plugin/URL context. Remaining plugins, core engines on that URL, and subsequent URLs + are not scanned. No findings file or `findings_file` output is published, including findings from earlier + successful URLs, so the composite action's normal filing and cache steps do not run. + +Neither mode changes Axe or AccessLint failure behavior, adds timeouts or page recovery, or detects errors +a plugin catches internally without propagating them. Plugins must reject/throw to signal incomplete work. +Browser cleanup still runs and is not time-bounded by this policy. + +For [#303](https://github.com/github/accessibility-scanner/issues/303), abort-before-filing is an opt-in +guarantee: select `abort` **and** use a plugin that propagates its failure. Under the compatibility default, +logged failures and partial findings are permitted; incomplete plugin findings may still reach issue filing. + ## Things to look out for - Plugin names should be unique. If multiple plugins have the same name, and the `scans` input array contains this name, all the plugins with that name _will_ run. However, this is not advised because if you want to turn off one plugin, you'll have to go back and change that plugin name. diff --git a/README.md b/README.md index f374ec4c..f3544bb5 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ jobs: # color_scheme: light # Optional: Playwright color scheme configuration option # ignore_https_errors: false # Optional: Set to true to ignore HTTPS errors when scanning URLs with self-signed certificates # scans: '["axe","accesslint","reflow-scan"]' # Optional: An array of scans (or plugins) to be performed. Built-in engines are 'axe' and 'accesslint'; any other entry is a plugin name. If not provided, only Axe will be performed. + # plugin_failure_policy: abort # Optional: Fail before publishing findings if a requested plugin fails or is unavailable. Default: log-and-continue. # url_configs: '[{"url":"https://example.com","excludeSelectors":["iframe","#widget"],"waitForSelectors":["#app","[data-ready]"]}]' # Optional: Per-URL config with CSS selectors to exclude from Axe or wait for before scanning. When provided, takes precedence over 'urls'. ``` @@ -140,6 +141,7 @@ Trigger the workflow manually or automatically based on your configuration. The | `color_scheme` | No | Playwright `colorScheme` setting for scan contexts. Allowed values: `light`, `dark`, `no-preference` | `dark` | | `ignore_https_errors` | No | Whether to ignore HTTPS errors when scanning URLs with self-signed certificates. Default: `false` | `true` | | `scans` | No | An array of scans (or plugins) to be performed. Built-in engines are `axe` and `accesslint`; any other entry is treated as a plugin name. If not provided, only Axe will be performed. | `'["axe", "accesslint", ...other plugins]'` | +| `plugin_failure_policy` | No | `log-and-continue` (default) preserves partial findings after plugin errors. `abort` stops scanning and publishes no findings when a requested plugin fails or is unavailable. Core engine handling is unchanged. See [plugin failure behavior](PLUGINS.md#plugin-failure-behavior). | `abort` | | `dry_run` | No | When `true`, scan and log the issues that _would_ be filed without opening, closing, reopening, or assigning any issues — and without writing to the `gh-cache` branch. Useful for safely previewing results. Default: `false` | `true` | | `url_configs` | No | A stringified JSON array of URL config objects. Each object must have a `url` field and may have `excludeSelectors` (selectors to exclude from Axe) and `waitForSelectors` (selectors that must become visible within 30 seconds before scanning). When provided, takes precedence over the `urls` input. | `'[{"url":"https://example.com","excludeSelectors":["iframe","#widget"],"waitForSelectors":["#app"]}]'` | diff --git a/action.yml b/action.yml index 8f305a3a..f6150b27 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,10 @@ inputs: scans: description: "Stringified JSON array of scans to perform. Core engines and local plugins use string names. Allowlisted NPM plugins use an object with 'name', 'package', and optional 'version'. If not provided, only Axe will be performed" required: false + plugin_failure_policy: + description: "Plugin failure behavior: 'log-and-continue' preserves partial findings (default); 'abort' fails before publishing findings when a requested plugin fails or is unavailable. Does not change core engine failure behavior." + required: false + default: 'log-and-continue' dry_run: description: 'When true, scan and log the issues that would be filed without opening, closing, reopening, or assigning any issues, and without writing to the cache.' required: false @@ -140,6 +144,7 @@ runs: color_scheme: ${{ inputs.color_scheme }} ignore_https_errors: ${{ inputs.ignore_https_errors }} scans: ${{ inputs.scans }} + plugin_failure_policy: ${{ inputs.plugin_failure_policy }} - name: File id: file uses: ./../../_actions/github/accessibility-scanner/current/.github/actions/file