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 ce30524..825f8c5 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 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 new file mode 100644 index 0000000..b04c311 --- /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 e96f2e4..57a4a08 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/.github/workflows/test.yml b/.github/workflows/test.yml index 7624ff0..dd3536b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -34,7 +34,7 @@ jobs: uses: actions/checkout@v7 - name: Setup Ruby - uses: ruby/setup-ruby@003a5c4d8d6321bd302e38f6f0ec593f77f06600 + uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b with: ruby-version: '3.4' bundler-cache: true 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.'