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 ce30524b..b2fe9c24 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 00000000..4cc30766 --- /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 e96f2e44..1139a019 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 b8bca81c..a950a2d5 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 b2fe9c24..825f8c59 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 b5efa490..23ee53d1 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 4cc30766..9da181d9 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 1139a019..57a4a083 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 31f66db7..0ed1262b 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 8caf4f9b..718a223b 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 02cc58c0..7de2f5d8 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 9da181d9..b04c311b 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}) } }) })