From 94025eeaaf296016e617c2f51d9a98d925b82fb8 Mon Sep 17 00:00:00 2001 From: Helen Hou-Sandi Date: Sat, 7 Nov 2026 13:32:44 -0500 Subject: [PATCH 1/4] fix: fail reflow scans safely Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/find/src/findForUrl.ts | 25 ++- .github/actions/find/tests/findForUrl.test.ts | 25 +++ .github/actions/find/tests/index.test.ts | 11 ++ .github/actions/find/tests/reflowScan.test.ts | 143 +++++++++++++++++ .github/scanner-plugins/reflow-scan/index.ts | 147 +++++++++++++++--- 5 files changed, 326 insertions(+), 25 deletions(-) create mode 100644 .github/actions/find/tests/reflowScan.test.ts diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index ebbf0b54..898734c2 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -56,19 +56,33 @@ export async function findForUrl( findings.push({...findingData, screenshotId, originalUrl: url}) } + let reflowScanFailed = false try { const scansContext = getScansContext() if (scansContext.shouldRunPlugins) { + const reflowRequested = scansContext.scansToPerform.includes('reflow-scan') + reflowScanFailed = reflowRequested const plugins = await loadPlugins() + if (reflowRequested && !plugins.some(plugin => plugin.name === 'reflow-scan')) { + throw new Error( + "The requested 'reflow-scan' plugin could not be loaded; refusing to publish incomplete results.", + ) + } + reflowScanFailed = false for (const plugin of plugins) { if (scansContext.scansToPerform.includes(plugin.name)) { core.info(`Running plugin: ${plugin.name}`) - await invokePlugin({ - plugin, - page, - addFinding, - }) + try { + await invokePlugin({ + plugin, + page, + addFinding, + }) + } catch (e) { + if (plugin.name === 'reflow-scan') reflowScanFailed = true + throw e + } } else { core.info(`Skipping plugin ${plugin.name} because it is not included in the 'scans' input`) } @@ -84,6 +98,7 @@ export async function findForUrl( } } catch (e) { core.error(`Error during accessibility scan: ${e}`) + if (reflowScanFailed) throw e } return findings } finally { diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index 387e2360..a26eb984 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -186,6 +186,31 @@ describe('findForUrl', () => { expect(playwrightMocks.contextClose).toHaveBeenCalledTimes(1) expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) }) + + it('fails the URL scan when reflow does not complete', async () => { + const error = new Error('overflow evaluation timed out') + loadedPlugins = [{name: 'reflow-scan', default: vi.fn().mockRejectedValue(error)}] + actionInput = JSON.stringify(['reflow-scan']) + clearAll() + + await expect(findForUrl({url: 'test.com'})).rejects.toBe(error) + + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + expect(playwrightMocks.contextClose).toHaveBeenCalledTimes(1) + expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) + }) + + it('fails the URL scan when requested reflow could not be loaded', async () => { + loadedPlugins = [] + actionInput = JSON.stringify(['reflow-scan']) + clearAll() + + await expect(findForUrl({url: 'test.com'})).rejects.toThrow( + "The requested 'reflow-scan' plugin could not be loaded; refusing to publish incomplete results.", + ) + + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + }) }) describe('when no scans list is provided', () => { diff --git a/.github/actions/find/tests/index.test.ts b/.github/actions/find/tests/index.test.ts index 13cf72f8..a439aa8d 100644 --- a/.github/actions/find/tests/index.test.ts +++ b/.github/actions/find/tests/index.test.ts @@ -54,4 +54,15 @@ describe('url_configs', () => { ) expect(mocks.findForUrl).not.toHaveBeenCalled() }) + + it('does not write findings or set the output when a URL scan fails', async () => { + mocks.inputs.url_configs = JSON.stringify([{url: 'https://example.com'}]) + mocks.inputs.include_screenshots = 'false' + const error = new Error('Reflow scan failed') + mocks.findForUrl.mockRejectedValueOnce(error) + + await expect(find()).rejects.toBe(error) + + expect(mocks.writeFileSync).not.toHaveBeenCalled() + }) }) diff --git a/.github/actions/find/tests/reflowScan.test.ts b/.github/actions/find/tests/reflowScan.test.ts new file mode 100644 index 00000000..4ac1a8c8 --- /dev/null +++ b/.github/actions/find/tests/reflowScan.test.ts @@ -0,0 +1,143 @@ +import {afterEach, describe, expect, it, vi} from 'vitest' +import reflowScan, {OPERATION_TIMEOUT_MS} from '../../../scanner-plugins/reflow-scan/index.js' + +const originalViewport = {width: 1280, height: 720} + +function createPage({scrollWidth = 320, clientWidth = 320} = {}) { + return { + viewportSize: vi.fn(() => originalViewport), + url: vi.fn(() => 'https://example.com'), + setViewportSize: vi.fn().mockResolvedValue(undefined), + evaluate: vi.fn().mockResolvedValue({scrollWidth, clientWidth}), + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('reflow scan', () => { + it('restores the original viewport when no overflow is found', async () => { + const page = createPage() + const addFinding = vi.fn() + + await reflowScan({page, addFinding}) + + expect(page.setViewportSize).toHaveBeenNthCalledWith(1, {width: 320, height: 256}) + expect(page.setViewportSize).toHaveBeenNthCalledWith(2, originalViewport) + expect(addFinding).not.toHaveBeenCalled() + }) + + it('preserves the overflow finding and restores the original viewport', async () => { + const page = createPage({scrollWidth: 400, clientWidth: 320}) + const addFinding = vi.fn().mockResolvedValue(undefined) + + await reflowScan({page, addFinding}) + + expect(addFinding).toHaveBeenCalledWith( + expect.objectContaining({ + scannerType: 'reflow-scan', + url: 'https://example.com', + problemShort: 'needs review: page presents a horizontal scrollbar at a 320px wide viewport', + solutionLong: expect.stringContaining('scroll width of 400px but a client width of only 320px'), + }), + ) + expect(page.setViewportSize).toHaveBeenLastCalledWith(originalViewport) + }) + + it.each([ + [ + 'viewport resize', + (page: ReturnType) => page.setViewportSize.mockRejectedValueOnce(new Error('resize')), + ], + [ + 'overflow evaluation', + (page: ReturnType) => page.evaluate.mockRejectedValueOnce(new Error('evaluate')), + ], + [ + 'finding callback', + (page: ReturnType) => + page.evaluate.mockResolvedValueOnce({scrollWidth: 400, clientWidth: 320}), + ], + ])('reports a rejected %s and attempts restoration', async (operation, configurePage) => { + const page = createPage() + configurePage(page) + const addFinding = vi.fn().mockRejectedValue(new Error('finding')) + + await expect(reflowScan({page, addFinding})).rejects.toMatchObject({ + name: 'ReflowScanError', + operation, + }) + + expect(page.setViewportSize).toHaveBeenCalledTimes(2) + }) + + it('reports restoration failure after a successful check', async () => { + const page = createPage() + page.setViewportSize.mockResolvedValueOnce(undefined).mockRejectedValueOnce(new Error('restore')) + + await expect(reflowScan({page, addFinding: vi.fn()})).rejects.toMatchObject({ + name: 'ReflowScanError', + operation: 'viewport restoration', + pageStateUnsafe: true, + }) + }) + + it('preserves the scan error when viewport restoration also fails', async () => { + const page = createPage() + const scanError = new Error('resize') + const restoreError = new Error('restore') + page.setViewportSize.mockRejectedValueOnce(scanError).mockRejectedValueOnce(restoreError) + + await expect(reflowScan({page, addFinding: vi.fn()})).rejects.toMatchObject({ + name: 'ReflowScanError', + operation: 'viewport resize', + cause: expect.objectContaining({cause: scanError}), + cleanupError: expect.objectContaining({cause: restoreError}), + pageStateUnsafe: true, + }) + }) + + it.each(['viewport resize', 'overflow evaluation', 'finding callback', 'viewport restoration'])( + 'bounds a non-returning %s operation', + async operation => { + vi.useFakeTimers() + const page = createPage({scrollWidth: 400, clientWidth: 320}) + const never = () => new Promise(() => {}) + + if (operation === 'viewport resize') { + page.setViewportSize.mockImplementationOnce(never) + } else if (operation === 'overflow evaluation') { + page.evaluate.mockImplementationOnce(never) + } else if (operation === 'finding callback') { + page.evaluate.mockResolvedValueOnce({scrollWidth: 400, clientWidth: 320}) + } else { + page.setViewportSize.mockResolvedValueOnce(undefined).mockImplementationOnce(never) + } + + const addFinding = operation === 'finding callback' ? vi.fn(never) : vi.fn().mockResolvedValue(undefined) + const scan = reflowScan({page, addFinding}) + const rejection = expect(scan).rejects.toMatchObject({ + name: 'ReflowScanError', + operation, + pageStateUnsafe: true, + }) + + await vi.advanceTimersByTimeAsync(OPERATION_TIMEOUT_MS) + + await rejection + }, + ) + + it('does not change the page when its original viewport is unavailable', async () => { + const page = createPage() + page.viewportSize.mockReturnValue(null) + + await expect(reflowScan({page, addFinding: vi.fn()})).rejects.toMatchObject({ + name: 'ReflowScanError', + operation: 'viewport capture', + }) + + expect(page.setViewportSize).not.toHaveBeenCalled() + }) +}) diff --git a/.github/scanner-plugins/reflow-scan/index.ts b/.github/scanner-plugins/reflow-scan/index.ts index 7f6a5e31..9f880679 100644 --- a/.github/scanner-plugins/reflow-scan/index.ts +++ b/.github/scanner-plugins/reflow-scan/index.ts @@ -1,32 +1,139 @@ +// Each phase has its own budget; the full check can wait at most 20 seconds. +export const OPERATION_TIMEOUT_MS = 5000 + +class ReflowOperationError extends Error { + constructor( + readonly operation: string, + message: string, + readonly timedOut: boolean, + options?: ErrorOptions, + ) { + super(`${operation} ${message}`, options) + this.name = 'ReflowOperationError' + } +} + +export class ReflowScanError extends Error { + constructor( + readonly operation: string, + cause: ReflowOperationError, + readonly cleanupError: ReflowOperationError | undefined, + readonly pageStateUnsafe: boolean, + ) { + const cleanupMessage = cleanupError ? `; viewport restoration also failed: ${cleanupError.message}` : '' + const unsafeMessage = pageStateUnsafe ? '; page state may be unsafe and will not be reused' : '' + super(`${cause.message}${cleanupMessage}${unsafeMessage}`, {cause}) + this.name = 'ReflowScanError' + } +} + export default async function reflowScan({page, addFinding} = {}) { - const originalViewport = page.viewportSize() + let originalViewport + try { + originalViewport = page.viewportSize() + } catch (error) { + throw new ReflowScanError( + 'viewport capture', + new ReflowOperationError('viewport capture', `failed: ${errorMessage(error)}`, false, {cause: error}), + undefined, + false, + ) + } + + if (!originalViewport) { + throw new ReflowScanError( + 'viewport capture', + new ReflowOperationError('viewport capture', 'is unavailable; cannot safely change the viewport', false), + undefined, + false, + ) + } + const url = page.url() - // Check for horizontal scrolling at 320 viewport + let resizeAttempted = false + let operationError + let cleanupError + try { - await page.setViewportSize({width: 320, height: 256}) - const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth) - const clientWidth = await page.evaluate(() => document.documentElement.clientWidth) + resizeAttempted = true + await runBounded('viewport resize', () => page.setViewportSize({width: 320, height: 256})) + + const {scrollWidth, clientWidth} = await runBounded('overflow evaluation', () => + page.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })), + ) - // If horizontal scroll is required (with 1px tolerance for rounding) if (scrollWidth > clientWidth + 1) { - await addFinding({ - scannerType: 'reflow-scan', - url, - problemShort: 'needs review: page presents a horizontal scrollbar at a 320px wide viewport', - problemUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/reflow.html', - solutionShort: - 'verify if sections of content can be viewed within the 320px wide viewport without needing to scroll in two dimensions to read the content of an individual section', - solutionLong: `The page has a scroll width of ${scrollWidth}px but a client width of only ${clientWidth}px at a 320px wide viewport, resulting in a horizontal scrollbar. Ensure that multi-line text does not require scrolling in two-dimensions to read, as this would be a violation of WCAG Success Criterion 1.4.10 (Reflow).`, - }) + await runBounded('finding callback', () => + addFinding({ + scannerType: 'reflow-scan', + url, + problemShort: 'needs review: page presents a horizontal scrollbar at a 320px wide viewport', + problemUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/reflow.html', + solutionShort: + 'verify if sections of content can be viewed within the 320px wide viewport without needing to scroll in two dimensions to read the content of an individual section', + solutionLong: `The page has a scroll width of ${scrollWidth}px but a client width of only ${clientWidth}px at a 320px wide viewport, resulting in a horizontal scrollbar. Ensure that multi-line text does not require scrolling in two-dimensions to read, as this would be a violation of WCAG Success Criterion 1.4.10 (Reflow).`, + }), + ) } - } catch (e) { - console.error('Error checking horizontal scroll:', e) + } catch (error) { + operationError = + error instanceof ReflowOperationError + ? error + : new ReflowOperationError('reflow scan', `failed: ${errorMessage(error)}`, false, {cause: error}) } finally { - // Restore original viewport so subsequent scans (e.g. Axe) aren't affected - if (originalViewport) { - await page.setViewportSize(originalViewport) + if (resizeAttempted) { + try { + await runBounded('viewport restoration', () => page.setViewportSize(originalViewport)) + } catch (error) { + cleanupError = + error instanceof ReflowOperationError + ? error + : new ReflowOperationError('viewport restoration', `failed: ${errorMessage(error)}`, false, { + cause: error, + }) + } } } + + if (operationError || cleanupError) { + const failure = operationError ?? cleanupError + throw new ReflowScanError( + failure.operation, + failure, + operationError ? cleanupError : undefined, + failure.timedOut || cleanupError !== undefined, + ) + } +} + +async function runBounded(operation: string, action: () => Promise): Promise { + let timer: ReturnType | undefined + const timeout = new Promise((_, reject) => { + timer = setTimeout( + () => reject(new ReflowOperationError(operation, `timed out after ${OPERATION_TIMEOUT_MS}ms`, true)), + OPERATION_TIMEOUT_MS, + ) + }) + + try { + return await Promise.race([ + Promise.resolve() + .then(action) + .catch(cause => { + throw new ReflowOperationError(operation, `failed: ${errorMessage(cause)}`, false, {cause}) + }), + timeout, + ]) + } finally { + if (timer) clearTimeout(timer) + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) } export const name = 'reflow-scan' From 4e865df6e497a5241f75e497e5f701314a862ff1 Mon Sep 17 00:00:00 2001 From: Helen Hou-Sandi Date: Sat, 7 Nov 2026 13:38:33 -0500 Subject: [PATCH 2/4] fix: keep reflow pending until successful completion Document cooperative deadlines and allow screenshot-compatible finding callbacks. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/find/src/findForUrl.ts | 24 +++---- .github/actions/find/tests/findForUrl.test.ts | 62 +++++++++++++++++++ .github/actions/find/tests/index.test.ts | 4 +- .github/actions/find/tests/reflowScan.test.ts | 19 +++++- .github/scanner-plugins/reflow-scan/index.ts | 54 ++++++++++------ PLUGINS.md | 10 +++ 6 files changed, 136 insertions(+), 37 deletions(-) diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index 898734c2..2bab7544 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -56,33 +56,29 @@ export async function findForUrl( findings.push({...findingData, screenshotId, originalUrl: url}) } - let reflowScanFailed = false + let reflowPending = false try { const scansContext = getScansContext() if (scansContext.shouldRunPlugins) { const reflowRequested = scansContext.scansToPerform.includes('reflow-scan') - reflowScanFailed = reflowRequested + reflowPending = reflowRequested const plugins = await loadPlugins() if (reflowRequested && !plugins.some(plugin => plugin.name === 'reflow-scan')) { throw new Error( "The requested 'reflow-scan' plugin could not be loaded; refusing to publish incomplete results.", ) } - reflowScanFailed = false for (const plugin of plugins) { if (scansContext.scansToPerform.includes(plugin.name)) { core.info(`Running plugin: ${plugin.name}`) - try { - await invokePlugin({ - plugin, - page, - addFinding, - }) - } catch (e) { - if (plugin.name === 'reflow-scan') reflowScanFailed = true - throw e - } + if (plugin.name === 'reflow-scan') reflowPending = true + await invokePlugin({ + plugin, + page, + addFinding, + }) + if (plugin.name === 'reflow-scan') reflowPending = false } else { core.info(`Skipping plugin ${plugin.name} because it is not included in the 'scans' input`) } @@ -98,7 +94,7 @@ export async function findForUrl( } } catch (e) { core.error(`Error during accessibility scan: ${e}`) - if (reflowScanFailed) throw e + if (reflowPending) throw e } return findings } finally { diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index a26eb984..2cf80447 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -1,6 +1,8 @@ import {describe, it, expect, vi} from 'vitest' import * as core from '@actions/core' +import fs from 'node:fs' import {findForUrl} from '../src/findForUrl.js' +import find from '../src/index.js' import {AxeBuilder} from '@axe-core/playwright' import {accesslintAudit} from '@accesslint/playwright' import axe from 'axe-core' @@ -211,6 +213,66 @@ describe('findForUrl', () => { expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() }) + + it('publishes no findings when an earlier plugin fails before requested reflow runs', async () => { + const error = new Error('Custom plugin failed before reflow') + loadedPlugins = [ + { + name: 'custom-scan', + default: vi.fn(async ({addFinding}) => { + await addFinding({ + scannerType: 'custom-scan', + url: 'https://example.com', + problemShort: 'Generic test finding', + }) + throw error + }), + }, + {name: 'reflow-scan', default: vi.fn()}, + ] + actionInput = JSON.stringify(['custom-scan', 'reflow-scan', 'axe', 'accesslint']) + clearAll() + const writeFindings = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}) + + try { + await vi.mocked(core.getInput).withImplementation( + name => { + if (name === 'scans') return actionInput + if (name === 'url_configs') return JSON.stringify([{url: 'https://example.com'}]) + if (name === 'include_screenshots') return 'false' + return '' + }, + async () => { + await expect(find()).rejects.toBe(error) + }, + ) + + expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) + expect(loadedPlugins[1].default).not.toHaveBeenCalled() + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + expect(accesslintAudit).not.toHaveBeenCalled() + expect(writeFindings).not.toHaveBeenCalled() + expect(core.setOutput).not.toHaveBeenCalled() + expect(playwrightMocks.contextClose).toHaveBeenCalledTimes(1) + expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) + } finally { + writeFindings.mockRestore() + } + }) + + it('preserves existing partial-result behavior after reflow has completed successfully', async () => { + loadedPlugins = [ + {name: 'reflow-scan', default: vi.fn()}, + {name: 'custom-scan', default: vi.fn().mockRejectedValue(new Error('Later plugin failed'))}, + ] + actionInput = JSON.stringify(['reflow-scan', 'custom-scan']) + clearAll() + + await expect(findForUrl({url: 'https://example.com'})).resolves.toEqual([]) + + expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) + expect(loadedPlugins[1].default).toHaveBeenCalledTimes(1) + }) }) describe('when no scans list is provided', () => { diff --git a/.github/actions/find/tests/index.test.ts b/.github/actions/find/tests/index.test.ts index a439aa8d..4dad5dc2 100644 --- a/.github/actions/find/tests/index.test.ts +++ b/.github/actions/find/tests/index.test.ts @@ -5,6 +5,7 @@ const mocks = vi.hoisted(() => ({ inputs: {} as Record, findForUrl: vi.fn(), writeFileSync: vi.fn(), + setOutput: vi.fn(), })) vi.mock('@actions/core', () => ({ @@ -12,7 +13,7 @@ vi.mock('@actions/core', () => ({ getMultilineInput: vi.fn(() => []), debug: vi.fn(), info: vi.fn(), - setOutput: vi.fn(), + setOutput: mocks.setOutput, })) vi.mock('node:fs', () => ({ @@ -64,5 +65,6 @@ describe('url_configs', () => { await expect(find()).rejects.toBe(error) expect(mocks.writeFileSync).not.toHaveBeenCalled() + expect(mocks.setOutput).not.toHaveBeenCalled() }) }) diff --git a/.github/actions/find/tests/reflowScan.test.ts b/.github/actions/find/tests/reflowScan.test.ts index 4ac1a8c8..88f797b7 100644 --- a/.github/actions/find/tests/reflowScan.test.ts +++ b/.github/actions/find/tests/reflowScan.test.ts @@ -1,11 +1,11 @@ import {afterEach, describe, expect, it, vi} from 'vitest' -import reflowScan, {OPERATION_TIMEOUT_MS} from '../../../scanner-plugins/reflow-scan/index.js' +import reflowScan, {FINDING_TIMEOUT_MS, OPERATION_TIMEOUT_MS} from '../../../scanner-plugins/reflow-scan/index.js' const originalViewport = {width: 1280, height: 720} function createPage({scrollWidth = 320, clientWidth = 320} = {}) { return { - viewportSize: vi.fn(() => originalViewport), + viewportSize: vi.fn<() => typeof originalViewport | null>(() => originalViewport), url: vi.fn(() => 'https://example.com'), setViewportSize: vi.fn().mockResolvedValue(undefined), evaluate: vi.fn().mockResolvedValue({scrollWidth, clientWidth}), @@ -123,12 +123,25 @@ describe('reflow scan', () => { pageStateUnsafe: true, }) - await vi.advanceTimersByTimeAsync(OPERATION_TIMEOUT_MS) + await vi.advanceTimersByTimeAsync(operation === 'finding callback' ? FINDING_TIMEOUT_MS : OPERATION_TIMEOUT_MS) await rejection }, ) + it('allows a healthy finding callback to take longer than the viewport-operation budget', async () => { + vi.useFakeTimers() + const page = createPage({scrollWidth: 400}) + const addFinding = vi.fn(() => new Promise(resolve => setTimeout(resolve, 31000))) + const scan = reflowScan({page, addFinding}) + + await vi.advanceTimersByTimeAsync(31000) + await expect(scan).resolves.toBeUndefined() + + expect(page.setViewportSize).toHaveBeenLastCalledWith(originalViewport) + expect(vi.getTimerCount()).toBe(0) + }) + it('does not change the page when its original viewport is unavailable', async () => { const page = createPage() page.viewportSize.mockReturnValue(null) diff --git a/.github/scanner-plugins/reflow-scan/index.ts b/.github/scanner-plugins/reflow-scan/index.ts index 9f880679..f8d0c206 100644 --- a/.github/scanner-plugins/reflow-scan/index.ts +++ b/.github/scanner-plugins/reflow-scan/index.ts @@ -1,5 +1,8 @@ -// Each phase has its own budget; the full check can wait at most 20 seconds. +import type {PluginDefaultParams} from '../../actions/find/src/pluginManager/types.js' + +// Cooperative wait budgets only: timers must run, and browser teardown is not included. export const OPERATION_TIMEOUT_MS = 5000 +export const FINDING_TIMEOUT_MS = 35000 class ReflowOperationError extends Error { constructor( @@ -27,7 +30,13 @@ export class ReflowScanError extends Error { } } -export default async function reflowScan({page, addFinding} = {}) { +export default async function reflowScan({ + page, + addFinding, +}: { + page: Pick + addFinding: PluginDefaultParams['addFinding'] +}) { let originalViewport try { originalViewport = page.viewportSize() @@ -51,8 +60,8 @@ export default async function reflowScan({page, addFinding} = {}) { const url = page.url() let resizeAttempted = false - let operationError - let cleanupError + let operationError: ReflowOperationError | undefined + let cleanupError: ReflowOperationError | undefined try { resizeAttempted = true @@ -66,16 +75,19 @@ export default async function reflowScan({page, addFinding} = {}) { ) if (scrollWidth > clientWidth + 1) { - await runBounded('finding callback', () => - addFinding({ - scannerType: 'reflow-scan', - url, - problemShort: 'needs review: page presents a horizontal scrollbar at a 320px wide viewport', - problemUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/reflow.html', - solutionShort: - 'verify if sections of content can be viewed within the 320px wide viewport without needing to scroll in two dimensions to read the content of an individual section', - solutionLong: `The page has a scroll width of ${scrollWidth}px but a client width of only ${clientWidth}px at a 320px wide viewport, resulting in a horizontal scrollbar. Ensure that multi-line text does not require scrolling in two-dimensions to read, as this would be a violation of WCAG Success Criterion 1.4.10 (Reflow).`, - }), + await runBounded( + 'finding callback', + () => + addFinding({ + scannerType: 'reflow-scan', + url, + problemShort: 'needs review: page presents a horizontal scrollbar at a 320px wide viewport', + problemUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/reflow.html', + solutionShort: + 'verify if sections of content can be viewed within the 320px wide viewport without needing to scroll in two dimensions to read the content of an individual section', + solutionLong: `The page has a scroll width of ${scrollWidth}px but a client width of only ${clientWidth}px at a 320px wide viewport, resulting in a horizontal scrollbar. Ensure that multi-line text does not require scrolling in two-dimensions to read, as this would be a violation of WCAG Success Criterion 1.4.10 (Reflow).`, + }), + FINDING_TIMEOUT_MS, ) } } catch (error) { @@ -98,8 +110,8 @@ export default async function reflowScan({page, addFinding} = {}) { } } - if (operationError || cleanupError) { - const failure = operationError ?? cleanupError + const failure = operationError ?? cleanupError + if (failure) { throw new ReflowScanError( failure.operation, failure, @@ -109,12 +121,16 @@ export default async function reflowScan({page, addFinding} = {}) { } } -async function runBounded(operation: string, action: () => Promise): Promise { +async function runBounded( + operation: string, + action: () => Promise, + timeoutMs = OPERATION_TIMEOUT_MS, +): Promise { let timer: ReturnType | undefined const timeout = new Promise((_, reject) => { timer = setTimeout( - () => reject(new ReflowOperationError(operation, `timed out after ${OPERATION_TIMEOUT_MS}ms`, true)), - OPERATION_TIMEOUT_MS, + () => reject(new ReflowOperationError(operation, `timed out after ${timeoutMs}ms`, true)), + timeoutMs, ) }) diff --git a/PLUGINS.md b/PLUGINS.md index 7dd702df..5d5603e0 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -5,6 +5,16 @@ The plugin system allows teams to create custom scans/tests to run on their page Some first-party plugins come built-in with the scanner repository and can be enabled via [actions inputs](https://github.com/github/accessibility-scanner/tree/main/action.yml#L48-L50). There are also some [allowlisted](https://github.com/github/accessibility-scanner/blob/7ec4b73ca2fdb471ad6f71a48a88e7b1a84ab3ad/.github/actions/find/src/pluginManager/index.ts#L92) first-party plugins hosted in a different repository; we also have the ability to support loading approved third-party plugins [from NPM](#loading-plugins-from-npm-packages). +## Built-in reflow scan + +Enable `reflow-scan` in the `scans` input to check for horizontal overflow at a 320 x 256 viewport. It keeps the existing 1px rounding tolerance and restores the original viewport after checking. If the original viewport is unavailable, it fails without resizing the page. + +Viewport resize, overflow evaluation, and viewport restoration each have a **5-second cooperative wait budget**. The finding callback has a separate **35-second budget**, allowing the screenshot library's existing 30-second timeout plus headroom for saving and recording the finding. This avoids reducing normal screenshot capture time to five seconds, but a callback exceeding 35 seconds now fails the reflow check instead of waiting indefinitely. + +The total plugin wait budget is 50 seconds when all phases run (15 seconds without an overflow finding). These are not hard wall-clock guarantees: timers need a responsive JavaScript event loop, timed-out Playwright operations are not cancelled, and context/browser teardown is outside these budgets. A blocked controller or browser teardown still requires runtime-level recovery. + +When requested reflow cannot load, fails, or is prevented from running by an earlier plugin failure, the find action fails rather than publishing partial findings as a successful clean check. No `findings_file` output is produced, so the normal issue-filing flow cannot use incomplete results. Later scans on that page are skipped after a reflow failure, and the caller attempts to close its context and browser. Restoration failures retain the original scan error with cleanup context; timeouts and failed restoration mark the page state as unsafe even if a timed-out operation later settles. + ## How to create plugins ### Default function API (all plugins) From 506b7067838cf24c12faba0a71f4ab7ea7a3cb46 Mon Sep 17 00:00:00 2001 From: Helen Hou-Sandi Date: Sat, 7 Nov 2026 13:51:53 -0500 Subject: [PATCH 3/4] refactor: require completion of requested plugins generically Remove reflow-specific caller handling and document fail-closed behavior for built-in, local, and NPM plugins. Keep core-engine error handling unchanged. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/find/src/findForUrl.ts | 17 +- .../actions/find/src/scansContextProvider.ts | 6 +- .github/actions/find/tests/findForUrl.test.ts | 157 +++++++++++------- .../find/tests/pluginNpmLoader.test.ts | 2 + PLUGINS.md | 8 +- 5 files changed, 119 insertions(+), 71 deletions(-) diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index 2bab7544..a9fcf65e 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -56,33 +56,34 @@ export async function findForUrl( findings.push({...findingData, screenshotId, originalUrl: url}) } - let reflowPending = false + let pluginsPending = false try { const scansContext = getScansContext() if (scansContext.shouldRunPlugins) { - const reflowRequested = scansContext.scansToPerform.includes('reflow-scan') - reflowPending = reflowRequested + pluginsPending = true const plugins = await loadPlugins() - if (reflowRequested && !plugins.some(plugin => plugin.name === 'reflow-scan')) { + const missingPlugins = scansContext.pluginsToPerform.filter( + name => !plugins.some(plugin => plugin.name === name), + ) + if (missingPlugins.length > 0) { throw new Error( - "The requested 'reflow-scan' plugin could not be loaded; refusing to publish incomplete results.", + `Requested plugins could not be loaded: ${missingPlugins.join(', ')}; refusing to publish incomplete results.`, ) } for (const plugin of plugins) { if (scansContext.scansToPerform.includes(plugin.name)) { core.info(`Running plugin: ${plugin.name}`) - if (plugin.name === 'reflow-scan') reflowPending = true await invokePlugin({ plugin, page, addFinding, }) - if (plugin.name === 'reflow-scan') reflowPending = false } else { core.info(`Skipping plugin ${plugin.name} because it is not included in the 'scans' input`) } } + pluginsPending = false } if (scansContext.shouldPerformAxeScan) { @@ -94,7 +95,7 @@ export async function findForUrl( } } catch (e) { core.error(`Error during accessibility scan: ${e}`) - if (reflowPending) throw e + if (pluginsPending) throw e } return findings } finally { diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index d9596e89..6ffc34a7 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -3,6 +3,7 @@ import type {NpmPluginRequest} from './pluginManager/types.js' type ScansContext = { scansToPerform: Array + pluginsToPerform: string[] npmPlugins: NpmPluginRequest[] shouldPerformAxeScan: boolean shouldPerformAccesslintScan: boolean @@ -49,10 +50,11 @@ export function getScansContext() { // 'axe' and 'accesslint' are built-in core engines; anything else in the // list is treated as a plugin name (local folder or NPM-published plugin). const coreEngines = ['axe', 'accesslint'] - const pluginScans = scansToPerform.filter(scan => !coreEngines.includes(scan)) + const pluginsToPerform = scansToPerform.filter(scan => !coreEngines.includes(scan)) scansContext = { scansToPerform, + pluginsToPerform, npmPlugins, // - if no 'scans' input is provided, we default to the existing behavior // (only axe scan) for backwards compatability. @@ -60,7 +62,7 @@ export function getScansContext() { // mark it as required shouldPerformAxeScan: !scansInput || scansToPerform.includes('axe'), shouldPerformAccesslintScan: scansToPerform.includes('accesslint'), - shouldRunPlugins: pluginScans.length > 0, + shouldRunPlugins: pluginsToPerform.length > 0, } } diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index 2cf80447..c16326ea 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -189,10 +189,10 @@ describe('findForUrl', () => { expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) }) - it('fails the URL scan when reflow does not complete', async () => { + it.each(['reflow-scan', 'custom-scan'])('fails the URL scan when %s does not complete', async pluginName => { const error = new Error('overflow evaluation timed out') - loadedPlugins = [{name: 'reflow-scan', default: vi.fn().mockRejectedValue(error)}] - actionInput = JSON.stringify(['reflow-scan']) + loadedPlugins = [{name: pluginName, default: vi.fn().mockRejectedValue(error)}] + actionInput = JSON.stringify([pluginName]) clearAll() await expect(findForUrl({url: 'test.com'})).rejects.toBe(error) @@ -202,77 +202,114 @@ describe('findForUrl', () => { expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) }) - it('fails the URL scan when requested reflow could not be loaded', async () => { - loadedPlugins = [] - actionInput = JSON.stringify(['reflow-scan']) - clearAll() - - await expect(findForUrl({url: 'test.com'})).rejects.toThrow( - "The requested 'reflow-scan' plugin could not be loaded; refusing to publish incomplete results.", - ) - - expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() - }) + it.each(['reflow-scan', 'custom-scan'])( + 'fails the URL scan when requested %s could not be loaded', + async pluginName => { + loadedPlugins = [] + actionInput = JSON.stringify([pluginName]) + clearAll() - it('publishes no findings when an earlier plugin fails before requested reflow runs', async () => { - const error = new Error('Custom plugin failed before reflow') - loadedPlugins = [ - { - name: 'custom-scan', - default: vi.fn(async ({addFinding}) => { - await addFinding({ - scannerType: 'custom-scan', - url: 'https://example.com', - problemShort: 'Generic test finding', - }) - throw error - }), - }, - {name: 'reflow-scan', default: vi.fn()}, - ] - actionInput = JSON.stringify(['custom-scan', 'reflow-scan', 'axe', 'accesslint']) - clearAll() - const writeFindings = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}) - - try { - await vi.mocked(core.getInput).withImplementation( - name => { - if (name === 'scans') return actionInput - if (name === 'url_configs') return JSON.stringify([{url: 'https://example.com'}]) - if (name === 'include_screenshots') return 'false' - return '' - }, - async () => { - await expect(find()).rejects.toBe(error) - }, + await expect(findForUrl({url: 'test.com'})).rejects.toThrow( + `Requested plugins could not be loaded: ${pluginName}; refusing to publish incomplete results.`, ) - expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) - expect(loadedPlugins[1].default).not.toHaveBeenCalled() expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() - expect(accesslintAudit).not.toHaveBeenCalled() - expect(writeFindings).not.toHaveBeenCalled() - expect(core.setOutput).not.toHaveBeenCalled() - expect(playwrightMocks.contextClose).toHaveBeenCalledTimes(1) - expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) - } finally { - writeFindings.mockRestore() - } - }) + }, + ) - it('preserves existing partial-result behavior after reflow has completed successfully', async () => { + it.each(['reflow-scan', 'custom-scan-2'])( + 'publishes no findings when an earlier plugin fails before requested %s runs', + async nextPlugin => { + const error = new Error('Custom plugin failed before reflow') + loadedPlugins = [ + { + name: 'custom-scan', + default: vi.fn(async ({addFinding}) => { + await addFinding({ + scannerType: 'custom-scan', + url: 'https://example.com', + problemShort: 'Generic test finding', + }) + throw error + }), + }, + {name: nextPlugin, default: vi.fn()}, + ] + actionInput = JSON.stringify(['custom-scan', nextPlugin, 'axe', 'accesslint']) + clearAll() + const writeFindings = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}) + + try { + await vi.mocked(core.getInput).withImplementation( + name => { + if (name === 'scans') return actionInput + if (name === 'url_configs') return JSON.stringify([{url: 'https://example.com'}]) + if (name === 'include_screenshots') return 'false' + return '' + }, + async () => { + await expect(find()).rejects.toBe(error) + }, + ) + + expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) + expect(loadedPlugins[1].default).not.toHaveBeenCalled() + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + expect(accesslintAudit).not.toHaveBeenCalled() + expect(writeFindings).not.toHaveBeenCalled() + expect(core.setOutput).not.toHaveBeenCalled() + expect(playwrightMocks.contextClose).toHaveBeenCalledTimes(1) + expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) + } finally { + writeFindings.mockRestore() + } + }, + ) + + it('fails if a later plugin throws even after reflow has completed successfully', async () => { + const error = new Error('Later plugin failed') loadedPlugins = [ {name: 'reflow-scan', default: vi.fn()}, - {name: 'custom-scan', default: vi.fn().mockRejectedValue(new Error('Later plugin failed'))}, + {name: 'custom-scan', default: vi.fn().mockRejectedValue(error)}, ] actionInput = JSON.stringify(['reflow-scan', 'custom-scan']) clearAll() - await expect(findForUrl({url: 'https://example.com'})).resolves.toEqual([]) + await expect(findForUrl({url: 'https://example.com'})).rejects.toBe(error) expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) expect(loadedPlugins[1].default).toHaveBeenCalledTimes(1) }) + + it('preserves core-engine error handling after all requested plugins complete', async () => { + loadedPlugins = [{name: 'custom-scan', default: vi.fn()}] + actionInput = JSON.stringify(['custom-scan', 'axe']) + clearAll() + vi.mocked(AxeBuilder.prototype.analyze).mockRejectedValueOnce(new Error('Axe failed')) + + await expect(findForUrl({url: 'https://example.com'})).resolves.toEqual([]) + expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) + }) + + it('reports all missing requested plugins before running any plugin or core engine', async () => { + loadedPlugins = [{name: 'available-scan', default: vi.fn()}] + actionInput = JSON.stringify([ + 'axe', + 'available-scan', + 'missing-scan', + {name: 'npm-scan', package: '@example/scan'}, + 'accesslint', + ]) + clearAll() + + await expect(findForUrl({url: 'https://example.com'})).rejects.toThrow( + 'Requested plugins could not be loaded: missing-scan, npm-scan; refusing to publish incomplete results.', + ) + + expect(loadedPlugins[0].default).not.toHaveBeenCalled() + expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() + expect(accesslintAudit).not.toHaveBeenCalled() + }) }) describe('when no scans list is provided', () => { @@ -376,7 +413,7 @@ describe('findForUrl', () => { }) it('runs plugins when a scans entry is an object-form NPM plugin', async () => { - loadedPlugins = [] + loadedPlugins = [{name: 'alt-text-scan', default: vi.fn()}] actionInput = JSON.stringify([ 'axe', { diff --git a/.github/actions/find/tests/pluginNpmLoader.test.ts b/.github/actions/find/tests/pluginNpmLoader.test.ts index 57a4a083..f2f513b6 100644 --- a/.github/actions/find/tests/pluginNpmLoader.test.ts +++ b/.github/actions/find/tests/pluginNpmLoader.test.ts @@ -19,8 +19,10 @@ const PLUGIN_ROOT = fileURLToPath(new URL('../src/pluginManager/', import.meta.u function mockNpmPlugins(npmPlugins: NpmPluginRequest[]) { vi.spyOn(scansContextProvider, 'getScansContext').mockReturnValue({ scansToPerform: npmPlugins.map(plugin => plugin.name), + pluginsToPerform: npmPlugins.map(plugin => plugin.name), npmPlugins, shouldPerformAxeScan: false, + shouldPerformAccesslintScan: false, shouldRunPlugins: true, }) } diff --git a/PLUGINS.md b/PLUGINS.md index 5d5603e0..e4aa221f 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -5,6 +5,12 @@ The plugin system allows teams to create custom scans/tests to run on their page Some first-party plugins come built-in with the scanner repository and can be enabled via [actions inputs](https://github.com/github/accessibility-scanner/tree/main/action.yml#L48-L50). There are also some [allowlisted](https://github.com/github/accessibility-scanner/blob/7ec4b73ca2fdb471ad6f71a48a88e7b1a84ab3ad/.github/actions/find/src/pluginManager/index.ts#L92) first-party plugins hosted in a different repository; we also have the ability to support loading approved third-party plugins [from NPM](#loading-plugins-from-npm-packages). +## Requested plugin completion + +Every plugin explicitly requested in `scans` must load and complete successfully. If any requested built-in, local, or NPM plugin is unavailable or throws, the find action fails, skips subsequent scans on that page, and attempts context/browser cleanup. It does not publish partial findings or a `findings_file` output for issue filing. A requested NPM plugin skipped by the allowlist or rejected during loading therefore also fails the scan. + +This intentionally replaces the previous behavior of logging plugin errors and returning partial findings, even when an earlier plugin has completed successfully. Plugins must propagate incomplete-check failures instead of catching them and returning normally. The existing error handling for the core engines (Axe and AccessLint) is unchanged; this is not a general runtime watchdog or partial-result recovery system. + ## Built-in reflow scan Enable `reflow-scan` in the `scans` input to check for horizontal overflow at a 320 x 256 viewport. It keeps the existing 1px rounding tolerance and restores the original viewport after checking. If the original viewport is unavailable, it fails without resizing the page. @@ -13,7 +19,7 @@ Viewport resize, overflow evaluation, and viewport restoration each have a **5-s The total plugin wait budget is 50 seconds when all phases run (15 seconds without an overflow finding). These are not hard wall-clock guarantees: timers need a responsive JavaScript event loop, timed-out Playwright operations are not cancelled, and context/browser teardown is outside these budgets. A blocked controller or browser teardown still requires runtime-level recovery. -When requested reflow cannot load, fails, or is prevented from running by an earlier plugin failure, the find action fails rather than publishing partial findings as a successful clean check. No `findings_file` output is produced, so the normal issue-filing flow cannot use incomplete results. Later scans on that page are skipped after a reflow failure, and the caller attempts to close its context and browser. Restoration failures retain the original scan error with cleanup context; timeouts and failed restoration mark the page state as unsafe even if a timed-out operation later settles. +Reflow propagates incomplete checks through the same requested-plugin failure handling as other plugins. Restoration failures retain the original scan error with cleanup context; timeouts and failed restoration mark the page state as unsafe even if a timed-out operation later settles. ## How to create plugins From 776a7b38f316922447af2c0619547d2752092623 Mon Sep 17 00:00:00 2001 From: Helen Hou-Sandi Date: Sat, 7 Nov 2026 13:57:26 -0500 Subject: [PATCH 4/4] refactor: separate caller failure policy from reflow resilience Keep existing log-and-continue caller handling; move configurable plugin failure policy to a separate PR per user direction. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/actions/find/src/findForUrl.ts | 12 -- .../actions/find/src/scansContextProvider.ts | 6 +- .github/actions/find/tests/findForUrl.test.ts | 122 +----------------- .github/actions/find/tests/index.test.ts | 15 +-- .../find/tests/pluginNpmLoader.test.ts | 2 - PLUGINS.md | 10 +- 6 files changed, 12 insertions(+), 155 deletions(-) diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index a9fcf65e..ebbf0b54 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -56,21 +56,11 @@ export async function findForUrl( findings.push({...findingData, screenshotId, originalUrl: url}) } - let pluginsPending = false try { const scansContext = getScansContext() if (scansContext.shouldRunPlugins) { - pluginsPending = true const plugins = await loadPlugins() - const missingPlugins = scansContext.pluginsToPerform.filter( - name => !plugins.some(plugin => plugin.name === name), - ) - if (missingPlugins.length > 0) { - throw new Error( - `Requested plugins could not be loaded: ${missingPlugins.join(', ')}; refusing to publish incomplete results.`, - ) - } for (const plugin of plugins) { if (scansContext.scansToPerform.includes(plugin.name)) { core.info(`Running plugin: ${plugin.name}`) @@ -83,7 +73,6 @@ export async function findForUrl( core.info(`Skipping plugin ${plugin.name} because it is not included in the 'scans' input`) } } - pluginsPending = false } if (scansContext.shouldPerformAxeScan) { @@ -95,7 +84,6 @@ export async function findForUrl( } } catch (e) { core.error(`Error during accessibility scan: ${e}`) - if (pluginsPending) throw e } return findings } finally { diff --git a/.github/actions/find/src/scansContextProvider.ts b/.github/actions/find/src/scansContextProvider.ts index 6ffc34a7..d9596e89 100644 --- a/.github/actions/find/src/scansContextProvider.ts +++ b/.github/actions/find/src/scansContextProvider.ts @@ -3,7 +3,6 @@ import type {NpmPluginRequest} from './pluginManager/types.js' type ScansContext = { scansToPerform: Array - pluginsToPerform: string[] npmPlugins: NpmPluginRequest[] shouldPerformAxeScan: boolean shouldPerformAccesslintScan: boolean @@ -50,11 +49,10 @@ export function getScansContext() { // 'axe' and 'accesslint' are built-in core engines; anything else in the // list is treated as a plugin name (local folder or NPM-published plugin). const coreEngines = ['axe', 'accesslint'] - const pluginsToPerform = scansToPerform.filter(scan => !coreEngines.includes(scan)) + const pluginScans = scansToPerform.filter(scan => !coreEngines.includes(scan)) scansContext = { scansToPerform, - pluginsToPerform, npmPlugins, // - if no 'scans' input is provided, we default to the existing behavior // (only axe scan) for backwards compatability. @@ -62,7 +60,7 @@ export function getScansContext() { // mark it as required shouldPerformAxeScan: !scansInput || scansToPerform.includes('axe'), shouldPerformAccesslintScan: scansToPerform.includes('accesslint'), - shouldRunPlugins: pluginsToPerform.length > 0, + shouldRunPlugins: pluginScans.length > 0, } } diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index c16326ea..cbbdca70 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -1,8 +1,6 @@ import {describe, it, expect, vi} from 'vitest' import * as core from '@actions/core' -import fs from 'node:fs' import {findForUrl} from '../src/findForUrl.js' -import find from '../src/index.js' import {AxeBuilder} from '@axe-core/playwright' import {accesslintAudit} from '@accesslint/playwright' import axe from 'axe-core' @@ -189,127 +187,19 @@ describe('findForUrl', () => { expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) }) - it.each(['reflow-scan', 'custom-scan'])('fails the URL scan when %s does not complete', async pluginName => { + it('preserves caller logging and partial-result behavior when reflow fails', async () => { const error = new Error('overflow evaluation timed out') - loadedPlugins = [{name: pluginName, default: vi.fn().mockRejectedValue(error)}] - actionInput = JSON.stringify([pluginName]) + loadedPlugins = [{name: 'reflow-scan', default: vi.fn().mockRejectedValue(error)}] + actionInput = JSON.stringify(['reflow-scan', 'axe']) clearAll() - await expect(findForUrl({url: 'test.com'})).rejects.toBe(error) + await expect(findForUrl({url: 'test.com'})).resolves.toEqual([]) + expect(core.error).toHaveBeenCalledWith(`Error during accessibility scan: ${error}`) expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() expect(playwrightMocks.contextClose).toHaveBeenCalledTimes(1) expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) }) - - it.each(['reflow-scan', 'custom-scan'])( - 'fails the URL scan when requested %s could not be loaded', - async pluginName => { - loadedPlugins = [] - actionInput = JSON.stringify([pluginName]) - clearAll() - - await expect(findForUrl({url: 'test.com'})).rejects.toThrow( - `Requested plugins could not be loaded: ${pluginName}; refusing to publish incomplete results.`, - ) - - expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() - }, - ) - - it.each(['reflow-scan', 'custom-scan-2'])( - 'publishes no findings when an earlier plugin fails before requested %s runs', - async nextPlugin => { - const error = new Error('Custom plugin failed before reflow') - loadedPlugins = [ - { - name: 'custom-scan', - default: vi.fn(async ({addFinding}) => { - await addFinding({ - scannerType: 'custom-scan', - url: 'https://example.com', - problemShort: 'Generic test finding', - }) - throw error - }), - }, - {name: nextPlugin, default: vi.fn()}, - ] - actionInput = JSON.stringify(['custom-scan', nextPlugin, 'axe', 'accesslint']) - clearAll() - const writeFindings = vi.spyOn(fs, 'writeFileSync').mockImplementation(() => {}) - - try { - await vi.mocked(core.getInput).withImplementation( - name => { - if (name === 'scans') return actionInput - if (name === 'url_configs') return JSON.stringify([{url: 'https://example.com'}]) - if (name === 'include_screenshots') return 'false' - return '' - }, - async () => { - await expect(find()).rejects.toBe(error) - }, - ) - - expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) - expect(loadedPlugins[1].default).not.toHaveBeenCalled() - expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() - expect(accesslintAudit).not.toHaveBeenCalled() - expect(writeFindings).not.toHaveBeenCalled() - expect(core.setOutput).not.toHaveBeenCalled() - expect(playwrightMocks.contextClose).toHaveBeenCalledTimes(1) - expect(playwrightMocks.browserClose).toHaveBeenCalledTimes(1) - } finally { - writeFindings.mockRestore() - } - }, - ) - - it('fails if a later plugin throws even after reflow has completed successfully', async () => { - const error = new Error('Later plugin failed') - loadedPlugins = [ - {name: 'reflow-scan', default: vi.fn()}, - {name: 'custom-scan', default: vi.fn().mockRejectedValue(error)}, - ] - actionInput = JSON.stringify(['reflow-scan', 'custom-scan']) - clearAll() - - await expect(findForUrl({url: 'https://example.com'})).rejects.toBe(error) - - expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) - expect(loadedPlugins[1].default).toHaveBeenCalledTimes(1) - }) - - it('preserves core-engine error handling after all requested plugins complete', async () => { - loadedPlugins = [{name: 'custom-scan', default: vi.fn()}] - actionInput = JSON.stringify(['custom-scan', 'axe']) - clearAll() - vi.mocked(AxeBuilder.prototype.analyze).mockRejectedValueOnce(new Error('Axe failed')) - - await expect(findForUrl({url: 'https://example.com'})).resolves.toEqual([]) - expect(loadedPlugins[0].default).toHaveBeenCalledTimes(1) - }) - - it('reports all missing requested plugins before running any plugin or core engine', async () => { - loadedPlugins = [{name: 'available-scan', default: vi.fn()}] - actionInput = JSON.stringify([ - 'axe', - 'available-scan', - 'missing-scan', - {name: 'npm-scan', package: '@example/scan'}, - 'accesslint', - ]) - clearAll() - - await expect(findForUrl({url: 'https://example.com'})).rejects.toThrow( - 'Requested plugins could not be loaded: missing-scan, npm-scan; refusing to publish incomplete results.', - ) - - expect(loadedPlugins[0].default).not.toHaveBeenCalled() - expect(AxeBuilder.prototype.analyze).not.toHaveBeenCalled() - expect(accesslintAudit).not.toHaveBeenCalled() - }) }) describe('when no scans list is provided', () => { @@ -413,7 +303,7 @@ describe('findForUrl', () => { }) it('runs plugins when a scans entry is an object-form NPM plugin', async () => { - loadedPlugins = [{name: 'alt-text-scan', default: vi.fn()}] + loadedPlugins = [] actionInput = JSON.stringify([ 'axe', { diff --git a/.github/actions/find/tests/index.test.ts b/.github/actions/find/tests/index.test.ts index 4dad5dc2..13cf72f8 100644 --- a/.github/actions/find/tests/index.test.ts +++ b/.github/actions/find/tests/index.test.ts @@ -5,7 +5,6 @@ const mocks = vi.hoisted(() => ({ inputs: {} as Record, findForUrl: vi.fn(), writeFileSync: vi.fn(), - setOutput: vi.fn(), })) vi.mock('@actions/core', () => ({ @@ -13,7 +12,7 @@ vi.mock('@actions/core', () => ({ getMultilineInput: vi.fn(() => []), debug: vi.fn(), info: vi.fn(), - setOutput: mocks.setOutput, + setOutput: vi.fn(), })) vi.mock('node:fs', () => ({ @@ -55,16 +54,4 @@ describe('url_configs', () => { ) expect(mocks.findForUrl).not.toHaveBeenCalled() }) - - it('does not write findings or set the output when a URL scan fails', async () => { - mocks.inputs.url_configs = JSON.stringify([{url: 'https://example.com'}]) - mocks.inputs.include_screenshots = 'false' - const error = new Error('Reflow scan failed') - mocks.findForUrl.mockRejectedValueOnce(error) - - await expect(find()).rejects.toBe(error) - - expect(mocks.writeFileSync).not.toHaveBeenCalled() - expect(mocks.setOutput).not.toHaveBeenCalled() - }) }) diff --git a/.github/actions/find/tests/pluginNpmLoader.test.ts b/.github/actions/find/tests/pluginNpmLoader.test.ts index f2f513b6..57a4a083 100644 --- a/.github/actions/find/tests/pluginNpmLoader.test.ts +++ b/.github/actions/find/tests/pluginNpmLoader.test.ts @@ -19,10 +19,8 @@ const PLUGIN_ROOT = fileURLToPath(new URL('../src/pluginManager/', import.meta.u function mockNpmPlugins(npmPlugins: NpmPluginRequest[]) { vi.spyOn(scansContextProvider, 'getScansContext').mockReturnValue({ scansToPerform: npmPlugins.map(plugin => plugin.name), - pluginsToPerform: npmPlugins.map(plugin => plugin.name), npmPlugins, shouldPerformAxeScan: false, - shouldPerformAccesslintScan: false, shouldRunPlugins: true, }) } diff --git a/PLUGINS.md b/PLUGINS.md index e4aa221f..8b66b070 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -5,12 +5,6 @@ The plugin system allows teams to create custom scans/tests to run on their page Some first-party plugins come built-in with the scanner repository and can be enabled via [actions inputs](https://github.com/github/accessibility-scanner/tree/main/action.yml#L48-L50). There are also some [allowlisted](https://github.com/github/accessibility-scanner/blob/7ec4b73ca2fdb471ad6f71a48a88e7b1a84ab3ad/.github/actions/find/src/pluginManager/index.ts#L92) first-party plugins hosted in a different repository; we also have the ability to support loading approved third-party plugins [from NPM](#loading-plugins-from-npm-packages). -## Requested plugin completion - -Every plugin explicitly requested in `scans` must load and complete successfully. If any requested built-in, local, or NPM plugin is unavailable or throws, the find action fails, skips subsequent scans on that page, and attempts context/browser cleanup. It does not publish partial findings or a `findings_file` output for issue filing. A requested NPM plugin skipped by the allowlist or rejected during loading therefore also fails the scan. - -This intentionally replaces the previous behavior of logging plugin errors and returning partial findings, even when an earlier plugin has completed successfully. Plugins must propagate incomplete-check failures instead of catching them and returning normally. The existing error handling for the core engines (Axe and AccessLint) is unchanged; this is not a general runtime watchdog or partial-result recovery system. - ## Built-in reflow scan Enable `reflow-scan` in the `scans` input to check for horizontal overflow at a 320 x 256 viewport. It keeps the existing 1px rounding tolerance and restores the original viewport after checking. If the original viewport is unavailable, it fails without resizing the page. @@ -19,7 +13,9 @@ Viewport resize, overflow evaluation, and viewport restoration each have a **5-s The total plugin wait budget is 50 seconds when all phases run (15 seconds without an overflow finding). These are not hard wall-clock guarantees: timers need a responsive JavaScript event loop, timed-out Playwright operations are not cancelled, and context/browser teardown is outside these budgets. A blocked controller or browser teardown still requires runtime-level recovery. -Reflow propagates incomplete checks through the same requested-plugin failure handling as other plugins. Restoration failures retain the original scan error with cleanup context; timeouts and failed restoration mark the page state as unsafe even if a timed-out operation later settles. +Reflow propagates incomplete-check errors to its caller. Restoration failures retain the original scan error with cleanup context; timeouts and failed restoration mark the page state as unsafe even if a timed-out operation later settles. The current caller logs plugin errors, skips remaining scans on that page, attempts context/browser cleanup, and returns partial findings before continuing to later URLs. + +**Logging and continuing does not guarantee complete scan coverage or exclude partial findings from issue filing.** The separate configurable caller-policy change will offer opt-in abort behavior to stop incomplete plugin work before findings publication, while retaining log-and-continue as the compatibility default. This plugin change does not alter the scanner's existing error-handling policy. ## How to create plugins