Skip to content

Commit 0fa3e24

Browse files
Support TypeScript plugins in find plugin manager via esbuild (#187)
## Summary Add the ability to load custom and built-in plugins from `index.ts` first (compiled at runtime with `esbuild`) and fall back to `index.js`. ## Changes - Moved `pluginManager.ts` to a module `pluginManager` with an `index.ts` file export, and other files that are internal to the module - this enables unit test mocking - Added runtime TypeScript plugin support in `pluginManager`: - Updated dynamic import helper to support importing `data:` and `file:` URLs directly. - Added tests in `.github/actions/find/tests/pluginManager.test.ts` for: - Added `esbuild` dependency in `.github/actions/find/package.json` and updated lockfile.
2 parents aee0459 + 3243dd5 commit 0fa3e24

File tree

13 files changed

+716
-90
lines changed

13 files changed

+716
-90
lines changed

.github/actions/find/package-lock.json

Lines changed: 458 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.github/actions/find/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,11 @@
1515
"dependencies": {
1616
"@actions/core": "^3.0.0",
1717
"@axe-core/playwright": "^4.11.1",
18+
"esbuild": "^0.28.0",
1819
"playwright": "^1.59.1"
1920
},
2021
"devDependencies": {
2122
"@types/node": "^25.6.0",
2223
"typescript": "^6.0.2"
2324
}
24-
}
25+
}

.github/actions/find/src/dynamicImport.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,12 @@ import {pathToFileURL} from 'url'
1919
//
2020
// - so this looks like a reasonable approach
2121
export function dynamicImport(path: string) {
22+
// - this if condition is for non-file imports.
23+
// - this can be encountered when using esbuild to compile TS plugin files
24+
// at run-time.
25+
if (path.startsWith('data:') || path.startsWith('file:')) {
26+
return import(path)
27+
}
28+
2229
return import(pathToFileURL(path).href)
2330
}

.github/actions/find/src/findForUrl.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import {AxeBuilder} from '@axe-core/playwright'
33
import playwright from 'playwright'
44
import {AuthContext} from './AuthContext.js'
55
import {generateScreenshots} from './generateScreenshots.js'
6-
import {loadPlugins, invokePlugin} from './pluginManager.js'
6+
import {loadPlugins, invokePlugin} from './pluginManager/index.js'
77
import {getScansContext} from './scansContextProvider.js'
88
import * as core from '@actions/core'
99

.github/actions/find/src/pluginManager.ts renamed to .github/actions/find/src/pluginManager/index.ts

Lines changed: 21 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,23 @@
11
import * as fs from 'fs'
22
import * as path from 'path'
33
import {fileURLToPath} from 'url'
4-
import {dynamicImport} from './dynamicImport.js'
5-
import type {Finding} from './types.d.js'
6-
import playwright from 'playwright'
74
import * as core from '@actions/core'
5+
import {loadPluginViaJsFile, loadPluginViaTsFile} from './pluginFileLoaders.js'
6+
import type {Plugin, PluginDefaultParams} from './types.js'
87

98
// Helper to get __dirname equivalent in ES Modules
109
const __filename = fileURLToPath(import.meta.url)
1110
const __dirname = path.dirname(__filename)
1211

13-
type PluginDefaultParams = {
14-
page: playwright.Page
15-
addFinding: (findingData: Finding) => Promise<void>
16-
}
17-
18-
type Plugin = {
19-
name: string
20-
default: (options: PluginDefaultParams) => Promise<void>
21-
}
22-
2312
// Built-in plugin names shipped with the scanner.
2413
// Used to skip duplicates when loading custom plugins.
25-
const BUILT_IN_PLUGINS = ['reflow-scan']
14+
const BUILT_IN_PLUGINS = ['reflow-scan', 'test-js-file-plugin-load']
2615

27-
const plugins: Plugin[] = []
16+
export const plugins: Plugin[] = []
17+
// Required for unit tests.
18+
export function getPlugins() {
19+
return plugins
20+
}
2821
let pluginsLoaded = false
2922

3023
export async function loadPlugins() {
@@ -58,7 +51,7 @@ export function clearCache() {
5851
export async function loadBuiltInPlugins() {
5952
core.info('Loading built-in plugins')
6053

61-
const pluginsPath = path.join(__dirname, '../../../scanner-plugins/')
54+
const pluginsPath = path.join(__dirname, '../../../../scanner-plugins/')
6255
await loadPluginsFromPath({pluginsPath})
6356
}
6457

@@ -91,12 +84,21 @@ export async function loadPluginsFromPath({
9184
skipBuiltInPlugins?: string[]
9285
}) {
9386
try {
94-
const res = fs.readdirSync(pluginsPath)
95-
for (const pluginFolder of res) {
87+
const folders = fs.readdirSync(pluginsPath)
88+
for (const pluginFolder of folders) {
9689
const pluginFolderPath = path.join(pluginsPath, pluginFolder)
9790

9891
if (fs.existsSync(pluginFolderPath) && fs.lstatSync(pluginFolderPath).isDirectory()) {
99-
const plugin = await dynamicImport(path.join(pluginsPath, pluginFolder, 'index.js'))
92+
let plugin = await loadPluginViaTsFile(pluginFolderPath)
93+
94+
if (!plugin) {
95+
plugin = await loadPluginViaJsFile(pluginFolderPath)
96+
}
97+
98+
if (!plugin) {
99+
core.info(`Skipping plugin without index.ts or index.js file: ${pluginFolder}`)
100+
continue
101+
}
100102

101103
if (skipBuiltInPlugins?.includes(plugin.name)) {
102104
core.info(`Skipping built-in plugin: ${plugin.name}`)
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as fs from 'fs'
2+
import * as path from 'path'
3+
import * as esbuild from 'esbuild'
4+
import {dynamicImport} from '../dynamicImport.js'
5+
import * as core from '@actions/core'
6+
import type {Plugin} from './types.js'
7+
8+
// - these functions had to be moved into a separate file
9+
// because vitest will not mock the implementation of functions
10+
// that are in the same file as the function under test.
11+
// - https://vitest.dev/guide/mocking/modules.html#mocking-modules-pitfalls
12+
export async function loadPluginViaTsFile(pluginFolderPath: string): Promise<Plugin | undefined> {
13+
const pluginEntryPath = path.join(pluginFolderPath, 'index.ts')
14+
if (!fs.existsSync(pluginEntryPath)) {
15+
core.info(`No index.ts found for plugin at path: ${pluginFolderPath}`)
16+
return
17+
}
18+
19+
try {
20+
core.info(`index.ts found for plugin at path: ${pluginFolderPath}`)
21+
const esbuildResult = await esbuild.build({
22+
entryPoints: [pluginEntryPath],
23+
write: false,
24+
bundle: true,
25+
format: 'esm',
26+
platform: 'node',
27+
target: 'node24',
28+
sourcemap: 'inline',
29+
})
30+
31+
const outputFileContents = esbuildResult.outputFiles[0]?.text
32+
if (!outputFileContents) {
33+
core.info(`esbuild produced no output for plugin: ${pluginEntryPath}`)
34+
return
35+
}
36+
37+
const base64CompiledPlugin = Buffer.from(outputFileContents).toString('base64')
38+
return dynamicImport(`data:text/javascript;base64,${base64CompiledPlugin}`)
39+
} catch {
40+
core.warning(`Error loading plugin at path: ${pluginEntryPath}`)
41+
}
42+
}
43+
44+
export async function loadPluginViaJsFile(pluginFolderPath: string): Promise<Plugin | undefined> {
45+
const pluginEntryPath = path.join(pluginFolderPath, 'index.js')
46+
if (!fs.existsSync(pluginEntryPath)) {
47+
core.info(`No index.js found for plugin at path: ${pluginFolderPath}`)
48+
return
49+
}
50+
51+
core.info(`index.js found for plugin at path: ${pluginFolderPath}`)
52+
return dynamicImport(pluginEntryPath)
53+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import type {Finding} from '../types.d.js'
2+
import playwright from 'playwright'
3+
4+
export type PluginDefaultParams = {
5+
page: playwright.Page
6+
addFinding: (findingData: Finding) => Promise<void>
7+
}
8+
9+
export type Plugin = {
10+
name: string
11+
default: (options: PluginDefaultParams) => Promise<void>
12+
}

.github/actions/find/tests/findForUrl.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import * as core from '@actions/core'
33
import {findForUrl} from '../src/findForUrl.js'
44
import {AxeBuilder} from '@axe-core/playwright'
55
import axe from 'axe-core'
6-
import * as pluginManager from '../src/pluginManager.js'
6+
import * as pluginManager from '../src/pluginManager/index.js'
7+
import type {Plugin} from '../src/pluginManager/types.js'
78
import {clearCache} from '../src/scansContextProvider.js'
89

910
vi.mock('@actions/core', {spy: true})
@@ -33,7 +34,7 @@ vi.mock('@axe-core/playwright', () => {
3334
})
3435

3536
let actionInput: string = ''
36-
let loadedPlugins: pluginManager.Plugin[] = []
37+
let loadedPlugins: Plugin[] = []
3738

3839
function clearAll() {
3940
clearCache()

0 commit comments

Comments
 (0)