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
458 changes: 458 additions & 0 deletions .github/actions/find/package-lock.json

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion .github/actions/find/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
"dependencies": {
"@actions/core": "^3.0.0",
"@axe-core/playwright": "^4.11.1",
"esbuild": "^0.28.0",
"playwright": "^1.59.1"
},
"devDependencies": {
"@types/node": "^25.6.0",
"typescript": "^6.0.2"
}
}
}
7 changes: 7 additions & 0 deletions .github/actions/find/src/dynamicImport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,12 @@ import {pathToFileURL} from 'url'
//
// - so this looks like a reasonable approach
export function dynamicImport(path: string) {
// - this if condition is for non-file imports.
// - this can be encountered when using esbuild to compile TS plugin files
// at run-time.
if (path.startsWith('data:') || path.startsWith('file:')) {
return import(path)
}

return import(pathToFileURL(path).href)
}
2 changes: 1 addition & 1 deletion .github/actions/find/src/findForUrl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {AxeBuilder} from '@axe-core/playwright'
import playwright from 'playwright'
import {AuthContext} from './AuthContext.js'
import {generateScreenshots} from './generateScreenshots.js'
import {loadPlugins, invokePlugin} from './pluginManager.js'
import {loadPlugins, invokePlugin} from './pluginManager/index.js'
import {getScansContext} from './scansContextProvider.js'
import * as core from '@actions/core'

Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,23 @@
import * as fs from 'fs'
import * as path from 'path'
import {fileURLToPath} from 'url'
import {dynamicImport} from './dynamicImport.js'
import type {Finding} from './types.d.js'
import playwright from 'playwright'
import * as core from '@actions/core'
import {loadPluginViaJsFile, loadPluginViaTsFile} from './pluginFileLoaders.js'
import type {Plugin, PluginDefaultParams} from './types.js'

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

type PluginDefaultParams = {
page: playwright.Page
addFinding: (findingData: Finding) => Promise<void>
}

type Plugin = {
name: string
default: (options: PluginDefaultParams) => Promise<void>
}

// Built-in plugin names shipped with the scanner.
// Used to skip duplicates when loading custom plugins.
const BUILT_IN_PLUGINS = ['reflow-scan']
const BUILT_IN_PLUGINS = ['reflow-scan', 'test-js-file-plugin-load']

const plugins: Plugin[] = []
export const plugins: Plugin[] = []
// Required for unit tests.
export function getPlugins() {
return plugins
}
let pluginsLoaded = false

export async function loadPlugins() {
Expand Down Expand Up @@ -58,7 +51,7 @@ export function clearCache() {
export async function loadBuiltInPlugins() {
core.info('Loading built-in plugins')

const pluginsPath = path.join(__dirname, '../../../scanner-plugins/')
const pluginsPath = path.join(__dirname, '../../../../scanner-plugins/')
await loadPluginsFromPath({pluginsPath})
}

Expand Down Expand Up @@ -91,12 +84,21 @@ export async function loadPluginsFromPath({
skipBuiltInPlugins?: string[]
}) {
try {
const res = fs.readdirSync(pluginsPath)
for (const pluginFolder of res) {
const folders = fs.readdirSync(pluginsPath)
for (const pluginFolder of folders) {
const pluginFolderPath = path.join(pluginsPath, pluginFolder)

if (fs.existsSync(pluginFolderPath) && fs.lstatSync(pluginFolderPath).isDirectory()) {
const plugin = await dynamicImport(path.join(pluginsPath, pluginFolder, 'index.js'))
let plugin = await loadPluginViaTsFile(pluginFolderPath)

if (!plugin) {
plugin = await loadPluginViaJsFile(pluginFolderPath)
}

if (!plugin) {
core.info(`Skipping plugin without index.ts or index.js file: ${pluginFolder}`)
continue
}

if (skipBuiltInPlugins?.includes(plugin.name)) {
core.info(`Skipping built-in plugin: ${plugin.name}`)
Expand Down
53 changes: 53 additions & 0 deletions .github/actions/find/src/pluginManager/pluginFileLoaders.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import * as fs from 'fs'
import * as path from 'path'
import * as esbuild from 'esbuild'
import {dynamicImport} from '../dynamicImport.js'
import * as core from '@actions/core'
import type {Plugin} from './types.js'

// - these functions had to be moved into a separate file
// because vitest will not mock the implementation of functions
// that are in the same file as the function under test.
// - https://vitest.dev/guide/mocking/modules.html#mocking-modules-pitfalls
export async function loadPluginViaTsFile(pluginFolderPath: string): Promise<Plugin | undefined> {
const pluginEntryPath = path.join(pluginFolderPath, 'index.ts')
if (!fs.existsSync(pluginEntryPath)) {
core.info(`No index.ts found for plugin at path: ${pluginFolderPath}`)
return
}

try {
core.info(`index.ts found for plugin at path: ${pluginFolderPath}`)
const esbuildResult = await esbuild.build({
entryPoints: [pluginEntryPath],
write: false,
bundle: true,
format: 'esm',
platform: 'node',
target: 'node24',
sourcemap: 'inline',
})

const outputFileContents = esbuildResult.outputFiles[0]?.text
if (!outputFileContents) {
core.info(`esbuild produced no output for plugin: ${pluginEntryPath}`)
return
}

const base64CompiledPlugin = Buffer.from(outputFileContents).toString('base64')
return dynamicImport(`data:text/javascript;base64,${base64CompiledPlugin}`)
} catch {
core.warning(`Error loading plugin at path: ${pluginEntryPath}`)
}
}

export async function loadPluginViaJsFile(pluginFolderPath: string): Promise<Plugin | undefined> {
const pluginEntryPath = path.join(pluginFolderPath, 'index.js')
if (!fs.existsSync(pluginEntryPath)) {
core.info(`No index.js found for plugin at path: ${pluginFolderPath}`)
return
}

core.info(`index.js found for plugin at path: ${pluginFolderPath}`)
return dynamicImport(pluginEntryPath)
}
12 changes: 12 additions & 0 deletions .github/actions/find/src/pluginManager/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type {Finding} from '../types.d.js'
import playwright from 'playwright'

export type PluginDefaultParams = {
page: playwright.Page
addFinding: (findingData: Finding) => Promise<void>
}

export type Plugin = {
name: string
default: (options: PluginDefaultParams) => Promise<void>
}
5 changes: 3 additions & 2 deletions .github/actions/find/tests/findForUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import * as core from '@actions/core'
import {findForUrl} from '../src/findForUrl.js'
import {AxeBuilder} from '@axe-core/playwright'
import axe from 'axe-core'
import * as pluginManager from '../src/pluginManager.js'
import * as pluginManager from '../src/pluginManager/index.js'
import type {Plugin} from '../src/pluginManager/types.js'
import {clearCache} from '../src/scansContextProvider.js'

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

let actionInput: string = ''
let loadedPlugins: pluginManager.Plugin[] = []
let loadedPlugins: Plugin[] = []

function clearAll() {
clearCache()
Expand Down
Loading
Loading