diff --git a/.github/actions/find/package-lock.json b/.github/actions/find/package-lock.json index ed3c97d..e575dac 100644 --- a/.github/actions/find/package-lock.json +++ b/.github/actions/find/package-lock.json @@ -11,6 +11,7 @@ "dependencies": { "@actions/core": "^3.0.0", "@axe-core/playwright": "^4.11.1", + "esbuild": "^0.28.0", "playwright": "^1.59.1" }, "devDependencies": { @@ -65,6 +66,422 @@ "playwright-core": ">= 1.0.0" } }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@types/node": { "version": "25.6.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", @@ -84,6 +501,47 @@ "node": ">=4" } }, + "node_modules/esbuild": { + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" + } + }, "node_modules/fsevents": { "version": "2.3.2", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", diff --git a/.github/actions/find/package.json b/.github/actions/find/package.json index 8cfa5e4..b460138 100644 --- a/.github/actions/find/package.json +++ b/.github/actions/find/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/.github/actions/find/src/dynamicImport.ts b/.github/actions/find/src/dynamicImport.ts index d8e1744..493aa02 100644 --- a/.github/actions/find/src/dynamicImport.ts +++ b/.github/actions/find/src/dynamicImport.ts @@ -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) } diff --git a/.github/actions/find/src/findForUrl.ts b/.github/actions/find/src/findForUrl.ts index eded5d1..e651779 100644 --- a/.github/actions/find/src/findForUrl.ts +++ b/.github/actions/find/src/findForUrl.ts @@ -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' diff --git a/.github/actions/find/src/pluginManager.ts b/.github/actions/find/src/pluginManager/index.ts similarity index 79% rename from .github/actions/find/src/pluginManager.ts rename to .github/actions/find/src/pluginManager/index.ts index bec7ddf..c6ec5d7 100644 --- a/.github/actions/find/src/pluginManager.ts +++ b/.github/actions/find/src/pluginManager/index.ts @@ -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 -} - -type Plugin = { - name: string - default: (options: PluginDefaultParams) => Promise -} - // 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() { @@ -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}) } @@ -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}`) diff --git a/.github/actions/find/src/pluginManager/pluginFileLoaders.ts b/.github/actions/find/src/pluginManager/pluginFileLoaders.ts new file mode 100644 index 0000000..88ee334 --- /dev/null +++ b/.github/actions/find/src/pluginManager/pluginFileLoaders.ts @@ -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 { + 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 { + 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) +} diff --git a/.github/actions/find/src/pluginManager/types.ts b/.github/actions/find/src/pluginManager/types.ts new file mode 100644 index 0000000..8fbb315 --- /dev/null +++ b/.github/actions/find/src/pluginManager/types.ts @@ -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 +} + +export type Plugin = { + name: string + default: (options: PluginDefaultParams) => Promise +} diff --git a/.github/actions/find/tests/findForUrl.test.ts b/.github/actions/find/tests/findForUrl.test.ts index 69679ff..85299c5 100644 --- a/.github/actions/find/tests/findForUrl.test.ts +++ b/.github/actions/find/tests/findForUrl.test.ts @@ -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}) @@ -33,7 +34,7 @@ vi.mock('@axe-core/playwright', () => { }) let actionInput: string = '' -let loadedPlugins: pluginManager.Plugin[] = [] +let loadedPlugins: Plugin[] = [] function clearAll() { clearCache() diff --git a/.github/actions/find/tests/pluginManager.test.ts b/.github/actions/find/tests/pluginManager.test.ts index 3aced15..92cd041 100644 --- a/.github/actions/find/tests/pluginManager.test.ts +++ b/.github/actions/find/tests/pluginManager.test.ts @@ -1,92 +1,169 @@ import {describe, it, expect, vi, beforeEach} from 'vitest' import * as fs from 'fs' +import * as esbuild from 'esbuild' import * as dynamicImportModule from '../src/dynamicImport.js' -import * as pluginManager from '../src/pluginManager.js' +import * as pluginManager from '../src/pluginManager/index.js' import * as core from '@actions/core' +import * as pluginLoaders from '../src/pluginManager/pluginFileLoaders.js' // - enable spying on fs // https://vitest.dev/guide/browser/#limitations vi.mock('fs', {spy: true}) -vi.mock('../src/pluginManager.js', {spy: true}) +vi.mock('esbuild', {spy: true}) +vi.mock('../src/pluginManager/index.js', {spy: true}) vi.mock('@actions/core', {spy: true}) -describe('loadPlugins', () => { - let dynamicImportCallCount = 0 - vi.spyOn(dynamicImportModule, 'dynamicImport').mockImplementation(() => { - dynamicImportCallCount++ - return Promise.resolve({name: `plugin-${dynamicImportCallCount}`, default: vi.fn()}) - }) - beforeEach(() => { - dynamicImportCallCount = 0 - // @ts-expect-error - we don't need the full fs readdirsync - // method signature here - vi.spyOn(fs, 'readdirSync').mockImplementation(() => { - return ['folder-a', 'folder-b'] +describe('pluginManager', () => { + describe('loadPlugins', () => { + let dynamicImportCallCount = 0 + vi.spyOn(dynamicImportModule, 'dynamicImport').mockImplementation(() => { + dynamicImportCallCount++ + return Promise.resolve({name: `plugin-${dynamicImportCallCount}`, default: vi.fn()}) }) - vi.spyOn(fs, 'lstatSync').mockImplementation(() => { - return { - isDirectory: () => true, - } as unknown as fs.Stats + beforeEach(() => { + dynamicImportCallCount = 0 + vi.spyOn(fs, 'readdirSync').mockImplementation(() => { + return ['folder-a', 'folder-b'] + }) + vi.spyOn(fs, 'lstatSync').mockImplementation(() => { + return { + isDirectory: () => true, + } as unknown as fs.Stats + }) + vi.spyOn(fs, 'existsSync').mockImplementation(() => true) + vi.spyOn(esbuild, 'build').mockResolvedValue({ + outputFiles: [{text: 'export const name = "compiled-plugin"; export default async function run() {}'}], + } as unknown as esbuild.BuildResult) }) - vi.spyOn(fs, 'existsSync').mockImplementation(() => true) - }) - describe('when plugins are not loaded', () => { - it('loads them', async () => { - pluginManager.clearCache() - const plugins = await pluginManager.loadPlugins() - expect(dynamicImportModule.dynamicImport).toHaveBeenCalledTimes(4) - expect(plugins.length).toBe(4) + describe('when plugins are not loaded', () => { + it('loads them', async () => { + pluginManager.clearCache() + const plugins = await pluginManager.loadPlugins() + expect(dynamicImportModule.dynamicImport).toHaveBeenCalledTimes(4) + expect(plugins.length).toBe(4) + }) }) - }) - describe('when plugins are already loaded', () => { - it('caches them and doesnt load them again', async () => { - pluginManager.clearCache() - await pluginManager.loadPlugins() - await pluginManager.loadPlugins() - expect(pluginManager.loadBuiltInPlugins).toHaveBeenCalledTimes(0) - expect(pluginManager.loadCustomPlugins).toHaveBeenCalledTimes(0) + describe('when plugins are already loaded', () => { + it('caches them and doesnt load them again', async () => { + pluginManager.clearCache() + await pluginManager.loadPlugins() + await pluginManager.loadPlugins() + expect(pluginManager.loadBuiltInPlugins).toHaveBeenCalledTimes(0) + expect(pluginManager.loadCustomPlugins).toHaveBeenCalledTimes(0) + }) }) - }) - describe('when there is an error loading plugins', () => { - beforeEach(() => { - vi.spyOn(fs, 'readdirSync').mockImplementation(() => { - throw new Error('test error') + describe('when there is an error loading plugins', () => { + beforeEach(() => { + vi.spyOn(fs, 'readdirSync').mockImplementation(() => { + throw new Error('test error') + }) + }) + + it('Aborts loading all plugins', async () => { + pluginManager.clearCache() + const logSpy = vi.spyOn(core, 'error').mockImplementation(() => {}) + const plugins = await pluginManager.loadPlugins() + expect(plugins.length).toBe(0) + expect(logSpy).toHaveBeenCalledWith(pluginManager.abortError) }) }) - it('Aborts loading all plugins', async () => { - pluginManager.clearCache() - const logSpy = vi.spyOn(core, 'error').mockImplementation(() => {}) - const plugins = await pluginManager.loadPlugins() - expect(plugins.length).toBe(0) - expect(logSpy).toHaveBeenCalledWith(pluginManager.abortError) + describe('when a custom plugin folder matches a built-in plugin name', () => { + beforeEach(() => { + vi.spyOn(fs, 'readdirSync').mockImplementation(() => { + return ['reflow-scan'] + }) + vi.spyOn(dynamicImportModule, 'dynamicImport').mockImplementation(() => { + return Promise.resolve({name: 'reflow-scan', default: vi.fn()}) + }) + }) + + it('skips the built-in name in custom plugins and only loads it once', async () => { + pluginManager.clearCache() + const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) + const plugins = await pluginManager.loadPlugins() + // Built-in loads it, custom skips the folder by name + expect(plugins.length).toBe(1) + expect(plugins[0].name).toBe('reflow-scan') + expect(infoSpy).toHaveBeenCalledWith('Skipping built-in plugin: reflow-scan') + }) }) }) - describe('when a custom plugin folder matches a built-in plugin name', () => { + describe('loadPluginsFromPath', () => { beforeEach(() => { - // @ts-expect-error - we don't need the full fs readdirsync - // method signature here + vi.clearAllMocks() + }) + + function sharedSetup() { + vi.spyOn(core, 'info').mockImplementation(() => {}) vi.spyOn(fs, 'readdirSync').mockImplementation(() => { - return ['reflow-scan'] + return ['folder-a'] + }) + vi.spyOn(fs, 'existsSync').mockImplementation(() => true) + vi.spyOn(fs, 'lstatSync').mockImplementation(() => { + return { + isDirectory: () => true, + } as unknown as fs.Stats + }) + } + + describe('when a plugin folder does not have index.ts or index.js file', () => { + it('skips the plugin', async () => { + sharedSetup() + vi.spyOn(pluginLoaders, 'loadPluginViaTsFile').mockImplementation(() => Promise.resolve(undefined)) + vi.spyOn(pluginLoaders, 'loadPluginViaJsFile').mockImplementation(() => Promise.resolve(undefined)) + + pluginManager.clearCache() + await pluginManager.loadPluginsFromPath({pluginsPath: 'fake-path'}) + + expect(pluginManager.getPlugins().length).toBe(0) + expect(pluginLoaders.loadPluginViaJsFile).toHaveBeenCalledOnce() + expect(pluginLoaders.loadPluginViaTsFile).toHaveBeenCalledOnce() + expect(core.info.mock.calls[0][0]).toBe('Skipping plugin without index.ts or index.js file: folder-a') }) - vi.spyOn(dynamicImportModule, 'dynamicImport').mockImplementation(() => { - return Promise.resolve({name: 'reflow-scan', default: vi.fn()}) + }) + + describe('when a plugin folder has an index.ts file', () => { + it('loads the plugin via the ts file loader', async () => { + sharedSetup() + pluginManager.clearCache() + + vi.spyOn(pluginLoaders, 'loadPluginViaTsFile').mockImplementation(() => + Promise.resolve({name: 'test-plugin', default: vi.fn()}), + ) + vi.spyOn(pluginLoaders, 'loadPluginViaJsFile').mockImplementation(() => Promise.resolve(undefined)) + + await pluginManager.loadPluginsFromPath({pluginsPath: 'fake-path'}) + + expect(pluginManager.getPlugins().length).toBe(1) + expect(pluginLoaders.loadPluginViaJsFile).not.toHaveBeenCalled() + expect(pluginLoaders.loadPluginViaTsFile).toHaveBeenCalledOnce() + expect(core.info.mock.calls[0][0]).toBe('Found plugin: test-plugin') }) }) - it('skips the built-in name in custom plugins and only loads it once', async () => { - pluginManager.clearCache() - const infoSpy = vi.spyOn(core, 'info').mockImplementation(() => {}) - const plugins = await pluginManager.loadPlugins() - // Built-in loads it, custom skips the folder by name - expect(plugins.length).toBe(1) - expect(plugins[0].name).toBe('reflow-scan') - expect(infoSpy).toHaveBeenCalledWith('Skipping built-in plugin: reflow-scan') + describe('when a plugin folder has an index.js file only', () => { + it('loads the plugin via the js file loader', async () => { + sharedSetup() + pluginManager.clearCache() + + vi.spyOn(pluginLoaders, 'loadPluginViaTsFile').mockImplementation(() => Promise.resolve(undefined)) + vi.spyOn(pluginLoaders, 'loadPluginViaJsFile').mockImplementation(() => + Promise.resolve({name: 'test-plugin', default: vi.fn()}), + ) + + await pluginManager.loadPluginsFromPath({pluginsPath: 'fake-path'}) + + expect(pluginManager.getPlugins().length).toBe(1) + expect(pluginLoaders.loadPluginViaJsFile).toHaveBeenCalledOnce() + expect(pluginLoaders.loadPluginViaTsFile).toHaveBeenCalledOnce() + expect(core.info.mock.calls[0][0]).toBe('Found plugin: test-plugin') + }) }) }) }) diff --git a/.github/scanner-plugins/reflow-scan/index.js b/.github/scanner-plugins/reflow-scan/index.ts similarity index 91% rename from .github/scanner-plugins/reflow-scan/index.js rename to .github/scanner-plugins/reflow-scan/index.ts index 077835f..fa4fa5d 100644 --- a/.github/scanner-plugins/reflow-scan/index.js +++ b/.github/scanner-plugins/reflow-scan/index.ts @@ -1,9 +1,9 @@ -export default async function reflowScan({page, addFinding} = {}) { +export default async function reflowScan({ page, addFinding } = {}) { const originalViewport = page.viewportSize() const url = page.url() // Check for horizontal scrolling at 320x256 viewport try { - await page.setViewportSize({width: 320, height: 256}) + await page.setViewportSize({ width: 320, height: 256 }) const scrollWidth = await page.evaluate(() => document.documentElement.scrollWidth) const clientWidth = await page.evaluate(() => document.documentElement.clientWidth) diff --git a/.github/scanner-plugins/test-js-file-plugin-loading/index.js b/.github/scanner-plugins/test-js-file-plugin-loading/index.js new file mode 100644 index 0000000..4f119df --- /dev/null +++ b/.github/scanner-plugins/test-js-file-plugin-loading/index.js @@ -0,0 +1,8 @@ +// - this exist as a test to verify that loading plugins +// via js files still works and there are no regressions + +export default async function TestJsFilePluginLoad({ page, addFinding } = {}) { + console.log('testing plugin load using js file') +} + +export const name = 'test-js-file-plugin-load' diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4b8247d..8ae8479 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }} strategy: matrix: - site: ["sites/site-with-errors"] + site: ['sites/site-with-errors'] steps: - name: Checkout uses: actions/checkout@v6 @@ -36,7 +36,7 @@ jobs: - name: Setup Ruby uses: ruby/setup-ruby@4c56a21280b36d862b5fc31348f463d60bdc55d5 with: - ruby-version: "3.4" + ruby-version: '3.4' bundler-cache: true working-directory: ${{ matrix.site }} @@ -88,6 +88,13 @@ jobs: path: ${{ steps.cache_key.outputs.cache_key }} token: ${{ secrets.GITHUB_TOKEN }} + # - if we need to do the same to file/fix in the future + # we can add separate steps + - name: Install 'find' dependencies + shell: bash + working-directory: .github/actions/find + run: npm ci + - name: Validate scan results (${{ matrix.site }}) run: | npm ci diff --git a/PLUGINS.md b/PLUGINS.md index 545bde8..85c99ed 100644 --- a/PLUGINS.md +++ b/PLUGINS.md @@ -6,7 +6,7 @@ Some plugins come built-in with the scanner and can be enabled via [actions inpu ## How plugins work -Plugins are dynamically loaded by the scanner when it runs. The scanner will look into the `./.github` folder in your repo (where you run the workflow from) and search for a `scanner-plugins` folder. If it finds it, it will assume each folder under that is a plugin, and attempt to load the `index.js` file inside it. Once loaded, the scanner will invoke the exported default function from the `index.js` file. +Plugins are dynamically loaded by the scanner when it runs. The scanner will look into the `./.github` folder in your repo (where you run the workflow from) and search for a `scanner-plugins` folder. If it finds it, it will assume each folder under that is a plugin, and attempt to load the `index.ts` (first) or `index.js` (second) file inside it. Once loaded, the scanner will invoke the exported default function from the `index.js/index.ts` file. ### Default function API @@ -27,9 +27,9 @@ A async function (you must use `await` or `.then` when invoking this function) t As mentioned above, plugins need to exist under `./.github/scanner-plugins`. For a plugin to work, it needs to meet the following criteria: - Each separate plugin should be contained in it's own directory in `./.github/scanner-plugins`. For example, `./.github/scanner-plugins/plugin-1` would be 1 plugin loaded by the scanner. -- Each plugin should have one `index.js` file inside its folder. -- The `index.js` file must export a `name` field. This is the name used to pass to the `scans` input. So if the plugin exports a name value of `my-custom-plugin` and we pass the following to the scanner action inputs: `scans: ['my-custom-plugin']`, it would cause the scanner to only run that plugin. -- The `index.js` file must export a default function. This is the function that the scanner uses to run the plugin. This can be an async function. +- Each plugin should have one `index.ts` OR `index.js` file inside its folder. +- The `index.ts/index.js` file must export a `name` field. This is the name used to pass to the `scans` input. So if the plugin exports a name value of `my-custom-plugin` and we pass the following to the scanner action inputs: `scans: ['my-custom-plugin']`, it would cause the scanner to only run that plugin. +- The `index.ts/index.js` file must export a default function. This is the function that the scanner uses to run the plugin. This can be an async function. ## Things to look out for