From 52213cbf7cafb5288d8ff0923cec3709fc6d4b9a Mon Sep 17 00:00:00 2001 From: Taarik <147209483+taarikashenafi@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:13:57 -0500 Subject: [PATCH 1/3] Fix NPM plugin module resolution Install runtime NPM plugins from the Find action root so bare ESM imports resolve the same node_modules tree from source and compiled loaders. Add a cross-workspace integration test against the released alt-text plugin. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b9119b6-5862-4597-b7d9-ad1522e125f4 --- .../find/src/pluginManager/pluginNpmLoader.ts | 8 +++- .../tests/pluginNpmLoader.integration.test.ts | 40 +++++++++++++++++++ .../find/tests/pluginNpmLoader.test.ts | 5 ++- 3 files changed, 51 insertions(+), 2 deletions(-) create mode 100644 .github/actions/find/tests/pluginNpmLoader.integration.test.ts diff --git a/.github/actions/find/src/pluginManager/pluginNpmLoader.ts b/.github/actions/find/src/pluginManager/pluginNpmLoader.ts index ce30524..b2fe9c2 100644 --- a/.github/actions/find/src/pluginManager/pluginNpmLoader.ts +++ b/.github/actions/find/src/pluginManager/pluginNpmLoader.ts @@ -1,10 +1,16 @@ import {execFileSync} from 'child_process' +import {fileURLToPath} from 'url' import * as core from '@actions/core' import type {NpmPluginRequest, Plugin} from './types.js' +const actionRoot = fileURLToPath(new URL('../..', import.meta.url)) + // Install the package at runtime. export function installNpmPackage(spec: string) { - execFileSync('npm', ['install', spec, '--no-save', '--no-package-lock', '--ignore-scripts'], {stdio: 'inherit'}) + execFileSync('npm', ['install', spec, '--no-save', '--no-package-lock', '--ignore-scripts'], { + cwd: actionRoot, + stdio: 'inherit', + }) } // Install and import a single NPM-published plugin diff --git a/.github/actions/find/tests/pluginNpmLoader.integration.test.ts b/.github/actions/find/tests/pluginNpmLoader.integration.test.ts new file mode 100644 index 0000000..4cc3076 --- /dev/null +++ b/.github/actions/find/tests/pluginNpmLoader.integration.test.ts @@ -0,0 +1,40 @@ +import * as fs from 'fs' +import * as os from 'os' +import * as path from 'path' +import {fileURLToPath} from 'url' +import {describe, expect, it} from 'vitest' + +import {loadPluginViaNpm} from '../src/pluginManager/pluginNpmLoader.js' + +const ACTION_ROOT = fileURLToPath(new URL('..', import.meta.url)) + +describe('npmPluginLoader integration', () => { + it('installs and loads a released plugin outside the consumer workspace', {timeout: 120_000}, async () => { + const originalCwd = process.cwd() + const originalMinimumReleaseAge = process.env.npm_config_min_release_age + const consumerWorkspace = fs.mkdtempSync(path.join(os.tmpdir(), 'accessibility-scanner-consumer-')) + + try { + process.chdir(consumerWorkspace) + process.env.npm_config_min_release_age = '0' + expect(process.cwd()).not.toBe(ACTION_ROOT) + + const plugin = await loadPluginViaNpm({ + name: 'alt-text-scan', + package: '@github/accessibility-scanner-alt-text-plugin', + version: '1.1.0', + }) + + expect(plugin?.name).toBe('alt-text-scan') + expect(plugin?.default).toBeTypeOf('function') + } finally { + process.chdir(originalCwd) + if (originalMinimumReleaseAge === undefined) { + delete process.env.npm_config_min_release_age + } else { + process.env.npm_config_min_release_age = originalMinimumReleaseAge + } + fs.rmSync(consumerWorkspace, {recursive: true, force: true}) + } + }) +}) diff --git a/.github/actions/find/tests/pluginNpmLoader.test.ts b/.github/actions/find/tests/pluginNpmLoader.test.ts index e96f2e4..1139a01 100644 --- a/.github/actions/find/tests/pluginNpmLoader.test.ts +++ b/.github/actions/find/tests/pluginNpmLoader.test.ts @@ -1,6 +1,7 @@ import {describe, it, expect, vi, beforeEach} from 'vitest' import * as childProcess from 'child_process' +import {fileURLToPath} from 'url' import * as core from '@actions/core' import * as pluginManager from '../src/pluginManager/index.js' import * as npmPluginLoader from '../src/pluginManager/pluginNpmLoader.js' @@ -13,6 +14,7 @@ vi.mock('../src/pluginManager/pluginNpmLoader.js', {spy: true}) vi.mock('../src/scansContextProvider.js', {spy: true}) const ALLOWED = '@github/accessibility-scanner-alt-text-plugin' +const ACTION_ROOT = fileURLToPath(new URL('..', import.meta.url)) function mockNpmPlugins(npmPlugins: NpmPluginRequest[]) { vi.spyOn(scansContextProvider, 'getScansContext').mockReturnValue({ @@ -37,6 +39,7 @@ describe('npmPluginLoader', () => { 'npm', ['install', 'some-pkg@1.0.0', '--no-save', '--no-package-lock', '--ignore-scripts'], { + cwd: ACTION_ROOT, stdio: 'inherit', }, ) @@ -50,7 +53,7 @@ describe('npmPluginLoader', () => { expect(execSpy).toHaveBeenCalledWith( 'npm', ['install', 'nonexistent-pkg-xyz@2.3.4', '--no-save', '--no-package-lock', '--ignore-scripts'], - {stdio: 'inherit'}, + {cwd: ACTION_ROOT, stdio: 'inherit'}, ) }) From 3b28cd71684ca901b786c27112330bdf4d873c3d Mon Sep 17 00:00:00 2001 From: Taarik <147209483+taarikashenafi@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:40:12 -0500 Subject: [PATCH 2/3] Isolate runtime NPM plugin dependencies Install published plugins beside the active source or compiled loader so ESM resolution works without re-resolving the Find action's locked dependencies. Align scanner docs and tests with the released alt-text plugin workflow syntax. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b9119b6-5862-4597-b7d9-ad1522e125f4 --- .github/actions/find/README.md | 4 +++- .../find/src/pluginManager/pluginNpmLoader.ts | 6 +++--- .github/actions/find/tests/findForUrl.test.ts | 10 +++++++++- .../tests/pluginNpmLoader.integration.test.ts | 9 +++++++-- .../actions/find/tests/pluginNpmLoader.test.ts | 18 +++++++++++++----- PLUGINS.md | 3 ++- README.md | 2 +- action.yml | 2 +- 8 files changed, 39 insertions(+), 15 deletions(-) diff --git a/.github/actions/find/README.md b/.github/actions/find/README.md index b8bca81..a950a2d 100644 --- a/.github/actions/find/README.md +++ b/.github/actions/find/README.md @@ -37,7 +37,9 @@ configuration option. #### `scans` -**Optional** Stringified JSON array of scans (string) to perform. If not provided, only Axe will be performed. +**Optional** 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. See [the plugin docs](../../../PLUGINS.md#loading-plugins-from-npm-packages) for an example. ### Outputs diff --git a/.github/actions/find/src/pluginManager/pluginNpmLoader.ts b/.github/actions/find/src/pluginManager/pluginNpmLoader.ts index b2fe9c2..825f8c5 100644 --- a/.github/actions/find/src/pluginManager/pluginNpmLoader.ts +++ b/.github/actions/find/src/pluginManager/pluginNpmLoader.ts @@ -3,12 +3,12 @@ import {fileURLToPath} from 'url' import * as core from '@actions/core' import type {NpmPluginRequest, Plugin} from './types.js' -const actionRoot = fileURLToPath(new URL('../..', import.meta.url)) +const pluginRoot = fileURLToPath(new URL('.', import.meta.url)) // Install the package at runtime. export function installNpmPackage(spec: string) { - execFileSync('npm', ['install', spec, '--no-save', '--no-package-lock', '--ignore-scripts'], { - cwd: actionRoot, + execFileSync('npm', ['install', spec, '--prefix', pluginRoot, '--no-save', '--no-package-lock', '--ignore-scripts'], { + cwd: pluginRoot, stdio: 'inherit', }) } diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index b5efa49..23ee53d 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -163,11 +163,19 @@ describe('findForUrl', () => { it('runs plugins when a scans entry is an object-form NPM plugin', async () => { loadedPlugins = [] - actionInput = JSON.stringify([{name: 'alt-text-scan', package: '@github/accessibility-scanner-alt-text-plugin'}]) + actionInput = JSON.stringify([ + 'axe', + { + name: 'alt-text-scan', + package: '@github/accessibility-scanner-alt-text-plugin', + version: '1.1.0', + }, + ]) clearAll() await findForUrl('test.com') expect(pluginManager.loadPlugins).toHaveBeenCalledTimes(1) + expect(AxeBuilder.prototype.analyze).toHaveBeenCalledTimes(1) }) }) diff --git a/.github/actions/find/tests/pluginNpmLoader.integration.test.ts b/.github/actions/find/tests/pluginNpmLoader.integration.test.ts index 4cc3076..9da181d 100644 --- a/.github/actions/find/tests/pluginNpmLoader.integration.test.ts +++ b/.github/actions/find/tests/pluginNpmLoader.integration.test.ts @@ -6,7 +6,7 @@ import {describe, expect, it} from 'vitest' import {loadPluginViaNpm} from '../src/pluginManager/pluginNpmLoader.js' -const ACTION_ROOT = fileURLToPath(new URL('..', import.meta.url)) +const PLUGIN_ROOT = fileURLToPath(new URL('../src/pluginManager/', import.meta.url)) describe('npmPluginLoader integration', () => { it('installs and loads a released plugin outside the consumer workspace', {timeout: 120_000}, async () => { @@ -17,7 +17,7 @@ describe('npmPluginLoader integration', () => { try { process.chdir(consumerWorkspace) process.env.npm_config_min_release_age = '0' - expect(process.cwd()).not.toBe(ACTION_ROOT) + expect(process.cwd()).not.toBe(PLUGIN_ROOT) const plugin = await loadPluginViaNpm({ name: 'alt-text-scan', @@ -27,6 +27,11 @@ describe('npmPluginLoader integration', () => { expect(plugin?.name).toBe('alt-text-scan') expect(plugin?.default).toBeTypeOf('function') + expect( + fs.existsSync( + path.join(PLUGIN_ROOT, 'node_modules', '@github', 'accessibility-scanner-alt-text-plugin', 'package.json'), + ), + ).toBe(true) } finally { process.chdir(originalCwd) if (originalMinimumReleaseAge === undefined) { diff --git a/.github/actions/find/tests/pluginNpmLoader.test.ts b/.github/actions/find/tests/pluginNpmLoader.test.ts index 1139a01..57a4a08 100644 --- a/.github/actions/find/tests/pluginNpmLoader.test.ts +++ b/.github/actions/find/tests/pluginNpmLoader.test.ts @@ -14,7 +14,7 @@ vi.mock('../src/pluginManager/pluginNpmLoader.js', {spy: true}) vi.mock('../src/scansContextProvider.js', {spy: true}) const ALLOWED = '@github/accessibility-scanner-alt-text-plugin' -const ACTION_ROOT = fileURLToPath(new URL('..', import.meta.url)) +const PLUGIN_ROOT = fileURLToPath(new URL('../src/pluginManager/', import.meta.url)) function mockNpmPlugins(npmPlugins: NpmPluginRequest[]) { vi.spyOn(scansContextProvider, 'getScansContext').mockReturnValue({ @@ -37,9 +37,9 @@ describe('npmPluginLoader', () => { npmPluginLoader.installNpmPackage('some-pkg@1.0.0') expect(execSpy).toHaveBeenCalledWith( 'npm', - ['install', 'some-pkg@1.0.0', '--no-save', '--no-package-lock', '--ignore-scripts'], + ['install', 'some-pkg@1.0.0', '--prefix', PLUGIN_ROOT, '--no-save', '--no-package-lock', '--ignore-scripts'], { - cwd: ACTION_ROOT, + cwd: PLUGIN_ROOT, stdio: 'inherit', }, ) @@ -52,8 +52,16 @@ describe('npmPluginLoader', () => { await npmPluginLoader.loadPluginViaNpm({name: 'p', package: 'nonexistent-pkg-xyz', version: '2.3.4'}) expect(execSpy).toHaveBeenCalledWith( 'npm', - ['install', 'nonexistent-pkg-xyz@2.3.4', '--no-save', '--no-package-lock', '--ignore-scripts'], - {cwd: ACTION_ROOT, stdio: 'inherit'}, + [ + 'install', + 'nonexistent-pkg-xyz@2.3.4', + '--prefix', + PLUGIN_ROOT, + '--no-save', + '--no-package-lock', + '--ignore-scripts', + ], + {cwd: PLUGIN_ROOT, stdio: 'inherit'}, ) }) diff --git a/PLUGINS.md b/PLUGINS.md index 31f66db..0ed1262 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -46,6 +46,7 @@ jobs: ## Loading plugins from NPM packages In addition to local plugins under `./.github/scanner-plugins`, the scanner can install and load plugins published as NPM packages. This avoids having to vendor a plugin's source into your repo. +NPM package loading requires scanner v3.4.1 or later. To use an NPM plugin, pass an object (instead of a plain string) in the `scans` input with the following fields: @@ -64,7 +65,7 @@ jobs: - uses: github/accessibility-scanner@v3 with: scans: | - ["axe", {"name": "alt-text-scan", "package": "@github/accessibility-scanner-alt-text-plugin", "version": "1.0.0"}] + ["axe", {"name": "alt-text-scan", "package": "@github/accessibility-scanner-alt-text-plugin", "version": "1.1.0"}] ``` Notes: diff --git a/README.md b/README.md index 8caf4f9..718a223 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ The [Alt Text Plugin](https://github.com/github/accessibility-scanner-alt-text-p ```yaml scans: | - ["axe", {"name": "alt-text-scan", "package": "@github/accessibility-scanner-alt-text-plugin", "version": "1.0.0"}] + ["axe", {"name": "alt-text-scan", "package": "@github/accessibility-scanner-alt-text-plugin", "version": "1.1.0"}] ``` See the [plugin README](https://github.com/github/accessibility-scanner-alt-text-plugin#getting-started) for the current release version, full rule list, and setup instructions. diff --git a/action.yml b/action.yml index 02cc58c..7de2f5d 100644 --- a/action.yml +++ b/action.yml @@ -64,7 +64,7 @@ inputs: description: 'Playwright colorScheme setting: https://playwright.dev/docs/api/class-browser#browser-new-context-option-color-scheme' required: false scans: - description: 'Stringified JSON array of scans to perform. If not provided, only Axe will be performed' + 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 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.' From 79266f44b1071192369a64c79ebee4efa37fc669 Mon Sep 17 00:00:00 2001 From: Taarik <147209483+taarikashenafi@users.noreply.github.com> Date: Tue, 28 Jul 2026 18:55:09 -0500 Subject: [PATCH 3/3] Clean up NPM loader integration test Remove the source-local node_modules tree created by the published package integration test so every run leaves the checkout clean. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8b9119b6-5862-4597-b7d9-ad1522e125f4 --- .../actions/find/tests/pluginNpmLoader.integration.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/find/tests/pluginNpmLoader.integration.test.ts b/.github/actions/find/tests/pluginNpmLoader.integration.test.ts index 9da181d..b04c311 100644 --- a/.github/actions/find/tests/pluginNpmLoader.integration.test.ts +++ b/.github/actions/find/tests/pluginNpmLoader.integration.test.ts @@ -7,6 +7,7 @@ import {describe, expect, it} from 'vitest' import {loadPluginViaNpm} from '../src/pluginManager/pluginNpmLoader.js' const PLUGIN_ROOT = fileURLToPath(new URL('../src/pluginManager/', import.meta.url)) +const PLUGIN_NODE_MODULES = path.join(PLUGIN_ROOT, 'node_modules') describe('npmPluginLoader integration', () => { it('installs and loads a released plugin outside the consumer workspace', {timeout: 120_000}, async () => { @@ -29,7 +30,7 @@ describe('npmPluginLoader integration', () => { expect(plugin?.default).toBeTypeOf('function') expect( fs.existsSync( - path.join(PLUGIN_ROOT, 'node_modules', '@github', 'accessibility-scanner-alt-text-plugin', 'package.json'), + path.join(PLUGIN_NODE_MODULES, '@github', 'accessibility-scanner-alt-text-plugin', 'package.json'), ), ).toBe(true) } finally { @@ -40,6 +41,7 @@ describe('npmPluginLoader integration', () => { process.env.npm_config_min_release_age = originalMinimumReleaseAge } fs.rmSync(consumerWorkspace, {recursive: true, force: true}) + fs.rmSync(PLUGIN_NODE_MODULES, {recursive: true, force: true}) } }) })