Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/actions/find/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion .github/actions/find/src/pluginManager/pluginNpmLoader.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
10 changes: 9 additions & 1 deletion .github/actions/find/tests/findForUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})

Expand Down
47 changes: 47 additions & 0 deletions .github/actions/find/tests/pluginNpmLoader.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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})
}
})
})
17 changes: 14 additions & 3 deletions .github/actions/find/tests/pluginNpmLoader.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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({
Expand All @@ -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',
},
)
Expand All @@ -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'},
)
})

Expand Down
3 changes: 2 additions & 1 deletion PLUGINS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand All @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.'
Expand Down
Loading