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 ce30524b..825f8c59 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 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'], {stdio: 'inherit'}) + execFileSync('npm', ['install', spec, '--prefix', pluginRoot, '--no-save', '--no-package-lock', '--ignore-scripts'], { + cwd: pluginRoot, + stdio: 'inherit', + }) } // Install and import a single NPM-published plugin 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 new file mode 100644 index 00000000..b04c311b --- /dev/null +++ b/.github/actions/find/tests/pluginNpmLoader.integration.test.ts @@ -0,0 +1,47 @@ +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 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 () => { + 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(PLUGIN_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') + expect( + fs.existsSync( + path.join(PLUGIN_NODE_MODULES, '@github', 'accessibility-scanner-alt-text-plugin', 'package.json'), + ), + ).toBe(true) + } 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}) + fs.rmSync(PLUGIN_NODE_MODULES, {recursive: true, force: true}) + } + }) +}) diff --git a/.github/actions/find/tests/pluginNpmLoader.test.ts b/.github/actions/find/tests/pluginNpmLoader.test.ts index e96f2e44..57a4a083 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 PLUGIN_ROOT = fileURLToPath(new URL('../src/pluginManager/', import.meta.url)) function mockNpmPlugins(npmPlugins: NpmPluginRequest[]) { vi.spyOn(scansContextProvider, 'getScansContext').mockReturnValue({ @@ -35,8 +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: PLUGIN_ROOT, stdio: 'inherit', }, ) @@ -49,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'], - {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.'