diff --git a/.github/dependabot.yml b/.github/dependabot.yml index dfd0e30..c749a69 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,4 +1,4 @@ -# Set update schedule for GitHub Actions +# Set update schedule for GitHub Actions and npm dependencies version: 2 updates: @@ -8,3 +8,8 @@ updates: schedule: # Check for updates to GitHub Actions every week interval: "weekly" + + - package-ecosystem: "npm" + directory: "/runner" + schedule: + interval: "weekly" diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3077481 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,33 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + schedule: + # Weekly scan to catch newly published CVEs against the existing codebase + - cron: "0 8 * * 1" + +permissions: + contents: read + +jobs: + codeql: + name: CodeQL SAST + runs-on: ubuntu-latest + permissions: + contents: read + security-events: write # Required to upload SARIF results + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: github/codeql-action/init@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 + with: + languages: javascript-typescript + queries: security-extended + + - uses: github/codeql-action/autobuild@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 + + - uses: github/codeql-action/analyze@03e4368ac7daa2bd82b3e85262f3bf87ee112f57 # v3.36.0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 35afff7..363233b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: steps: - name: Determine version bump from merged PR id: bump - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | // Find the PR whose merge commit matches this push @@ -54,7 +54,7 @@ jobs: - name: Get latest release tag id: latest - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: script: | try { @@ -76,10 +76,10 @@ jobs: LATEST_TAG: ${{ steps.latest.outputs.tag }} BUMP_TYPE: ${{ steps.bump.outputs.type }} run: | - # Validate and normalise the tag — fall back to v0.0.0 if unexpected format + # Validate the tag — fail if unexpected format if [[ ! "$LATEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then - echo "Tag '${LATEST_TAG}' does not match vMAJOR.MINOR.PATCH — falling back to v0.0.0" - LATEST_TAG="v0.0.0" + echo "Error: Tag '${LATEST_TAG}' does not match vMAJOR.MINOR.PATCH — refusing to release." + exit 1 fi TAG="${LATEST_TAG#v}" @@ -104,7 +104,7 @@ jobs: echo "tag=${NEW_TAG}" >> "$GITHUB_OUTPUT" - name: Create GitHub release - uses: actions/github-script@v9 + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 env: NEW_TAG: ${{ steps.version.outputs.tag }} with: diff --git a/.github/workflows/self-test.yml b/.github/workflows/self-test.yml index 7c7a6e9..01bfa65 100644 --- a/.github/workflows/self-test.yml +++ b/.github/workflows/self-test.yml @@ -1,5 +1,4 @@ name: Self-test - on: workflow_dispatch: inputs: @@ -28,6 +27,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + with: + version: "11" + - name: Run accessibility check id: a11y # The test URL may have existing violations; that means the action ran correctly, diff --git a/.gitignore b/.gitignore index b947077..0a85e07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,19 @@ node_modules/ dist/ + +# Private keys and certificates — prevent accidental commits +*.pem +*.key +*.p12 +*.pfx +*.cer +*.crt +id_rsa +id_rsa.pub +id_ed25519 +id_ed25519.pub + +# Environment files — use .env.example for documentation +.env +.env.local +.env.*.local diff --git a/README.md b/README.md index b41aa6b..9847e04 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,26 @@ Run [axe-core](https://github.com/dequelabs/axe-core#axe-core) accessibility tes On average, [57%](https://www.deque.com/automated-accessibility-coverage-report/) of accessibility issues can be detected with automated testing, and fixing them as early as possible saves time and effort. Use this action to quick-start your accessibility journey and catch regressions before they reach production. Add 4 lines to your testing workflow, and get a detailed report of accessibility violations with links to the exact failing elements and rules documentation. +## Requirements + +pnpm (≥ 9) must be available in the job before the action runs — the action's internal Playwright runner is a pnpm project. If your workflow doesn't already set it up: + +```yaml +- uses: pnpm/action-setup@v6 + with: + version: "11" +``` + +If your workflow already uses pnpm (e.g. to build your app), no extra step is needed. + ## Quick start ```yaml # In your workflow — after your app is built and started: +- uses: pnpm/action-setup@v6 + with: + version: "11" + - uses: leekeh/axtion@92dac76ca6d585203df89b544d037392c325f9d8 # v 0.0.1 with: base-url: http://localhost:3000 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..1779030 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,12 @@ +# Security Policy + +## Reporting a Vulnerability + +Please **do not** open a public GitHub issue for security vulnerabilities. + +To report a vulnerability, reach out privately: + +- **Email:** hello@leekeh.com +- **Bluesky DM:** [@leekeh.com](https://bsky.app/profile/leekeh.com) + +Reports are taken seriously and will be responded to in a timely manner. You will receive an acknowledgement and be kept informed of progress toward a fix. diff --git a/action.yml b/action.yml index 40222ba..22c4563 100644 --- a/action.yml +++ b/action.yml @@ -91,156 +91,9 @@ inputs: outputs: violations-found: description: "'true' if accessibility violations were found" - value: ${{ steps.run-tests.outcome == 'failure' && 'true' || 'false' }} report-url: description: "URL to the Actions run where artifacts are available (empty if generate-report is false or tests passed)" - value: ${{ steps.upload-reports.outputs.run-url }} runs: - using: "composite" - steps: - - name: Set up Node.js - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: "24" - - - name: Set up pnpm - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 - - - name: Install dependencies - shell: bash - env: - A11Y_BROWSER: ${{ inputs.browser }} - run: | - node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "📦 Installing dependencies" - pnpm install --frozen-lockfile --dir "${{ github.action_path }}/runner" - BROWSER="$A11Y_BROWSER" - case "$BROWSER" in - chromium|firefox|webkit) ;; - *) echo "::error::Invalid browser '${BROWSER}'. Must be chromium, firefox, or webkit."; exit 1 ;; - esac - pnpm --dir "${{ github.action_path }}/runner" exec playwright install "$BROWSER" - - - name: Prepare configuration - id: prepare-routes - shell: bash - env: - ROUTES_INPUT: ${{ inputs.routes }} - ROUTES_FILE_INPUT: ${{ inputs.routes-file }} - BASE_URL: ${{ inputs.base-url }} - A11Y_BROWSER: ${{ inputs.browser }} - A11Y_WORKERS: ${{ inputs.workers }} - A11Y_WAIT_STRATEGY: ${{ inputs.wait-strategy }} - A11Y_RULES: ${{ inputs.rules }} - A11Y_RULESETS: ${{ inputs.rulesets }} - A11Y_DISABLED_RULES: ${{ inputs.disabled-rules }} - A11Y_EXCLUSIONS: ${{ inputs.exclusions }} - run: | - node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "⚙️ Preparing configuration" - - MANIFEST_PATH="${RUNNER_TEMP}/a11y-routes.json" - echo "manifest-path=${MANIFEST_PATH}" >> "$GITHUB_OUTPUT" - - if [ -n "${ROUTES_FILE_INPUT}" ]; then - # Guard against path traversal: reject paths that escape GITHUB_WORKSPACE - ROUTES_FILE_REAL="$(realpath -m "${ROUTES_FILE_INPUT}")" - WORKSPACE_REAL="$(realpath "${GITHUB_WORKSPACE}")" - if [[ "${ROUTES_FILE_REAL}" != "${WORKSPACE_REAL}" && "${ROUTES_FILE_REAL}" != "${WORKSPACE_REAL}/"* ]]; then - echo "::error::routes-file '${ROUTES_FILE_INPUT}' must be a path within GITHUB_WORKSPACE." - exit 1 - fi - cp "${ROUTES_FILE_INPUT}" "${MANIFEST_PATH}" - elif [ -n "${ROUTES_INPUT}" ]; then - printf '%s' "${ROUTES_INPUT}" > "${MANIFEST_PATH}" - else - echo "::error::Either the 'routes' or 'routes-file' input is required." - exit 1 - fi - - MANIFEST_PATH="$MANIFEST_PATH" node --experimental-strip-types "${{ github.action_path }}/runner/print-config.ts" - node --experimental-strip-types "${{ github.action_path }}/runner/print-resolved-rules.ts" - - - name: Run tests - id: run-tests - shell: bash - continue-on-error: true - env: - BASE_URL: ${{ inputs.base-url }} - ROUTES_FILE: ${{ steps.prepare-routes.outputs.manifest-path }} - A11Y_BROWSER: ${{ inputs.browser }} - A11Y_WORKERS: ${{ inputs.workers }} - A11Y_EXCLUSIONS: ${{ inputs.exclusions }} - A11Y_WAIT_STRATEGY: ${{ inputs.wait-strategy }} - A11Y_GENERATE_REPORT: ${{ inputs.generate-report }} - A11Y_RULES: ${{ inputs.rules }} - A11Y_RULESETS: ${{ inputs.rulesets }} - A11Y_DISABLED_RULES: ${{ inputs.disabled-rules }} - A11Y_REPORT_DIR: a11y-reports - A11Y_RESULTS_FILE: ${{ runner.temp }}/a11y-results.json - run: | - node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "🧪 Running tests" - cd "$GITHUB_WORKSPACE" - "${{ github.action_path }}/runner/node_modules/.bin/playwright" test \ - --config="${{ github.action_path }}/runner/playwright.config.ts" - PLAYWRIGHT_EXIT=$? - - node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "📊 Results" - A11Y_PLAYWRIGHT_EXIT="$PLAYWRIGHT_EXIT" node --experimental-strip-types "${{ github.action_path }}/runner/print-results.ts" - exit "$PLAYWRIGHT_EXIT" - - - name: Check for report files - id: check-reports - if: steps.run-tests.outcome == 'failure' && inputs.generate-report == 'true' - shell: bash - env: - REPORT_DIR: ${{ github.workspace }}/a11y-reports - run: | - node --experimental-strip-types "${{ github.action_path }}/runner/print-header.ts" "📊 Creating output" - if [ -d "$REPORT_DIR" ] && [ -n "$(ls -A "$REPORT_DIR" 2>/dev/null)" ]; then - echo "has-reports=true" >> "$GITHUB_OUTPUT" - else - echo "has-reports=false" >> "$GITHUB_OUTPUT" - fi - - - name: Stage upload action - if: steps.check-reports.outputs.has-reports == 'true' - shell: bash - run: | - # `uses: ./path` in composite actions resolves relative to GITHUB_WORKSPACE - # (the consumer's repo), not to this action's own directory. We stage the - # sub-action files there so the runner can find them. - dst="$GITHUB_WORKSPACE/.axtion-upload" - mkdir -p "$dst" - cp "${{ github.action_path }}/runner/upload-action/action.yml" "$dst/" - cp "${{ github.action_path }}/runner/upload-action/upload.ts" "$dst/" - ln -sfn "${{ github.action_path }}/runner/node_modules" "$dst/node_modules" - - - name: Upload reports - id: upload-reports - if: steps.check-reports.outputs.has-reports == 'true' - uses: ./.axtion-upload - env: - NODE_OPTIONS: --experimental-strip-types - REPORT_DIR: ${{ github.workspace }}/a11y-reports - ARTIFACT_URLS_FILE: ${{ runner.temp }}/a11y-artifact-urls.json - - - name: Post PR comment - if: inputs.post-comment == 'true' && github.event_name == 'pull_request' - shell: bash - env: - GH_TOKEN: ${{ inputs.github-token }} - GITHUB_PR_NUMBER: ${{ github.event.pull_request.number }} - A11Y_OUTCOME: ${{ steps.run-tests.outcome }} - REPORT_URL: ${{ steps.upload-reports.outputs.run-url }} - ARTIFACT_URLS_FILE: ${{ runner.temp }}/a11y-artifact-urls.json - REPORT_DIR: ${{ github.workspace }}/a11y-reports - RESULTS_FILE: ${{ runner.temp }}/a11y-results.json - CUSTOM_TEMPLATE_SUCCESS: ${{ inputs.comment-template-success }} - CUSTOM_TEMPLATE_FAILURE: ${{ inputs.comment-template-failure }} - ACTION_PATH: ${{ github.action_path }} - run: node --experimental-strip-types "${{ github.action_path }}/runner/post-comment.ts" - - - name: Fail if violations found - if: steps.run-tests.outcome == 'failure' - shell: bash - run: exit 1 + using: "node24" + main: "runner/boot.cjs" diff --git a/runner/a11y.spec.ts b/runner/a11y.spec.ts index 3b2661b..59b147b 100644 --- a/runner/a11y.spec.ts +++ b/runner/a11y.spec.ts @@ -6,18 +6,18 @@ import { type ReadinessStrategy, type WaitUntilStrategy, } from "./normalize-routes.ts"; -import * as fs from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; // ── Load routes manifest ───────────────────────────────────────────────────── const routesFile = process.env.ROUTES_FILE; -if (!routesFile || !fs.existsSync(routesFile)) { +if (!routesFile || !existsSync(routesFile)) { throw new Error( `ROUTES_FILE env var must point to a valid routes manifest. Got: ${routesFile}`, ); } -const rawManifest = JSON.parse(fs.readFileSync(routesFile, "utf-8")); +const rawManifest = JSON.parse(readFileSync(routesFile, "utf-8")); const routes = normalizeRoutes(rawManifest); // ── Global default wait strategy ───────────────────────────────────────────── @@ -56,7 +56,7 @@ if (routes.length === 0) { function buildTestName(entry: RouteEntry): string { if (entry.name) return entry.name; const [pathPart, query] = entry.path.split("?"); - let name = pathPart.replace(/^\//, "").replaceAll("/", " / ") || "home"; + let name = pathPart.replace(/^\//, "").replaceAll("/", " / ") || "index"; if (query) name += ` (${query})`; return name; } @@ -72,17 +72,15 @@ function getWaitUntil( fallback: WaitUntilStrategy, ): WaitUntilStrategy { if (!readiness) return fallback; - if ( - readiness.type === "networkidle" || - readiness.type === "load" || - readiness.type === "domcontentloaded" - ) { - return readiness.type; - } + const type = readiness.type; // For selector/js strategies, navigate with "load" first, then apply the // custom wait. Don't combine with networkidle to avoid long timeouts on // apps that keep background polling active. - return "load"; + if (type === "js" || type === "selector") { + return "load"; + } + + return type; } /** @@ -116,11 +114,7 @@ async function applyReadiness( const timeout = readiness.timeout ?? 30_000; try { // Poll until the expression is truthy. - // Security note: this expression is evaluated by Playwright inside the browser - // page context — not in the Node.js runner process. It is sandboxed by the - // browser engine and has no access to the runner filesystem, environment - // variables, or secrets. The caller fully controls the routes manifest, so - // this is intentional and the risk is scoped to the browser sandbox. + // Expression is evaluated in the browser sandbox and has no access to the runner environment. await page.waitForFunction(readiness.expression, null, { timeout }); // Then wait for the event loop to drain (hydration, deferred renders, etc.) await page.evaluate( diff --git a/runner/boot.cjs b/runner/boot.cjs new file mode 100644 index 0000000..b156723 --- /dev/null +++ b/runner/boot.cjs @@ -0,0 +1,34 @@ +/** + * Install dependencies and run the main orchestrator for the a11y check action. + */ +const { execFileSync } = require("node:child_process"); +const { join } = require("node:path"); + +// ─── 1. Install runner dependencies ────────────────────────────────────────── + +execFileSync( + process.execPath, + ["--experimental-strip-types", join(__dirname, "print-header.ts"), "📦 Installing dependencies"], + { stdio: "inherit", env: process.env }, +); + +try { + execFileSync("pnpm", ["install", "--frozen-lockfile"], { + stdio: "inherit", + cwd: __dirname, + }); +} catch (err) { + process.exit(err.status ?? 1); +} + +// ─── 2. Run the orchestrator ────────────────────────────────────────────────── + +try { + execFileSync( + process.execPath, + ["--experimental-strip-types", join(__dirname, "main.ts")], + { stdio: "inherit", env: process.env }, + ); +} catch (err) { + process.exit(err.status ?? 1); +} diff --git a/runner/check-a11y.ts b/runner/check-a11y.ts index 831828f..4cff6f8 100644 --- a/runner/check-a11y.ts +++ b/runner/check-a11y.ts @@ -5,23 +5,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; const generateReport = process.env.A11Y_GENERATE_REPORT !== "false"; const reportDir = process.env.A11Y_REPORT_DIR ?? "a11y-reports"; - -function parseJsonArray(envVar: string | undefined): string[] { - if (!envVar) return []; - try { - const parsed = JSON.parse(envVar); - if (Array.isArray(parsed)) - return parsed.filter((x): x is string => typeof x === "string"); - } catch { - // ignore malformed input - } - return []; -} - -// Parse caller-supplied exclusions from env (JSON array of CSS selectors) -const extraExclusions = parseJsonArray(process.env.A11Y_EXCLUSIONS ?? "[]"); - -// Parse axe-core rule filtering options +const exclusions = parseJsonArray(process.env.A11Y_EXCLUSIONS ?? "[]"); const rules = parseJsonArray(process.env.A11Y_RULES); const rulesets = parseJsonArray(process.env.A11Y_RULESETS); const disabledRules = parseJsonArray(process.env.A11Y_DISABLED_RULES); @@ -34,7 +18,7 @@ const disabledRules = parseJsonArray(process.env.A11Y_DISABLED_RULES); export async function checkA11y(page: Page, testName: string): Promise { let builder = new AxeBuilder({ page }); - for (const selector of extraExclusions) { + for (const selector of exclusions) { builder = builder.exclude([selector]); } @@ -76,3 +60,16 @@ function toSafeName(name: string): string { .replace(/^_|_$/g, "") || "report" ); } + + +function parseJsonArray(envVar: string | undefined): string[] { + if (!envVar) return []; + try { + const parsed = JSON.parse(envVar); + if (Array.isArray(parsed)) + return parsed.filter((x): x is string => typeof x === "string"); + } catch { + // ignore malformed input + } + return []; +} diff --git a/runner/main.ts b/runner/main.ts new file mode 100644 index 0000000..e3ec482 --- /dev/null +++ b/runner/main.ts @@ -0,0 +1,239 @@ +/** + * Orchestrates the full accessibility check workflow. + * + * Runs as the sole entry point of the node24 JavaScript action via boot.cjs. + * Being a JavaScript action (not a composite action step) gives us direct + * access to ACTIONS_RUNTIME_TOKEN, which @actions/artifact requires to upload + * unarchived, browser-viewable per-route artifacts. + */ +import * as core from "@actions/core"; +import { DefaultArtifactClient } from "@actions/artifact"; +import { execFileSync, spawnSync } from "node:child_process"; +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// ─── Inputs ────────────────────────────────────────────────────────────────── + +const baseUrl = core.getInput("base-url", { required: true }); +const routesInput = core.getInput("routes"); +const routesFileInput = core.getInput("routes-file"); +const browser = core.getInput("browser") || "chromium"; +const workers = core.getInput("workers") || "2"; +const waitStrategy = core.getInput("wait-strategy") || "networkidle"; +const exclusions = core.getInput("exclusions") || "[]"; +const rules = core.getInput("rules"); +const rulesets = core.getInput("rulesets"); +const disabledRules = core.getInput("disabled-rules"); +const generateReport = core.getInput("generate-report") !== "false"; +const shouldPostComment = core.getInput("post-comment") !== "false"; +const githubToken = core.getInput("github-token"); +const commentTemplateSuccess = core.getInput("comment-template-success"); +const commentTemplateFailure = core.getInput("comment-template-failure"); + +// ─── Paths ─────────────────────────────────────────────────────────────────── + +const GITHUB_WORKSPACE = process.env.GITHUB_WORKSPACE!; +const RUNNER_TEMP = process.env.RUNNER_TEMP!; +const GITHUB_RUN_ID = process.env.GITHUB_RUN_ID!; +const GITHUB_REPOSITORY = process.env.GITHUB_REPOSITORY!; +const GITHUB_EVENT_NAME = process.env.GITHUB_EVENT_NAME; +const GITHUB_EVENT_PATH = process.env.GITHUB_EVENT_PATH; + +const reportDir = path.join(GITHUB_WORKSPACE, "a11y-reports"); +const resultsFile = path.join(RUNNER_TEMP, "a11y-results.json"); +const artifactUrlsFile = path.join(RUNNER_TEMP, "a11y-artifact-urls.json"); +const manifestPath = path.join(RUNNER_TEMP, "a11y-routes.json"); +const actionDir = path.resolve(__dirname, ".."); + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +/** Spawn a TypeScript runner script via --experimental-strip-types. */ +function runTs( + script: string, + args: string[] = [], + env: Record = {}, +): void { + execFileSync( + process.execPath, + ["--experimental-strip-types", path.join(__dirname, script), ...args], + { stdio: "inherit", env: { ...process.env, ...env } }, + ); +} + +function header(msg: string): void { + runTs("print-header.ts", [msg]); +} + +// ─── 1. Install Playwright browser ─────────────────────────────────────────── + +// Validate browser choice before attempting the playwright install. +const VALID_BROWSERS = ["chromium", "firefox", "webkit"] as const; +if (!(VALID_BROWSERS as readonly string[]).includes(browser)) { + core.setFailed( + `Invalid browser '${browser}'. Must be chromium, firefox, or webkit.`, + ); + process.exit(1); +} + +header("🎧 Installing Playwright browser"); +execFileSync("pnpm", ["exec", "playwright", "install", browser], { + stdio: "inherit", + cwd: __dirname, +}); + +// ─── 2. Prepare configuration ───────────────────────────────────────────────── + +header("⚙️ Preparing configuration"); + +if (routesFileInput) { + // Guard against path traversal: the resolved path must stay within GITHUB_WORKSPACE. + const resolved = path.normalize(path.resolve(routesFileInput)); + const workspace = path.normalize(path.resolve(GITHUB_WORKSPACE)); + if (resolved !== workspace && !resolved.startsWith(workspace + path.sep)) { + core.setFailed( + `routes-file '${routesFileInput}' must be a path within GITHUB_WORKSPACE.`, + ); + process.exit(1); + } + writeFileSync(manifestPath, readFileSync(resolved)); +} else if (routesInput) { + writeFileSync(manifestPath, routesInput); +} else { + core.setFailed("Either the 'routes' or 'routes-file' input is required."); + process.exit(1); +} + +const sharedEnv: Record = { + BASE_URL: baseUrl, + A11Y_BROWSER: browser, + A11Y_WORKERS: workers, + A11Y_WAIT_STRATEGY: waitStrategy, + A11Y_EXCLUSIONS: exclusions, + ...(rules ? { A11Y_RULES: rules } : {}), + ...(rulesets ? { A11Y_RULESETS: rulesets } : {}), + ...(disabledRules ? { A11Y_DISABLED_RULES: disabledRules } : {}), +}; + +runTs("print-config.ts", [], { ...sharedEnv, MANIFEST_PATH: manifestPath }); +runTs("print-resolved-rules.ts", [], sharedEnv); + +// ─── 3. Run tests ───────────────────────────────────────────────────────────── + +header("🧪 Running tests"); + +// Playwright handles TypeScript via its own esbuild transform, so we do NOT +// set NODE_OPTIONS=--experimental-strip-types for this subprocess. +const testResult = spawnSync( + path.join(__dirname, "node_modules/.bin/playwright"), + ["test", "--config", path.join(__dirname, "playwright.config.ts")], + { + stdio: "inherit", + cwd: GITHUB_WORKSPACE, + env: { + ...process.env, + ...sharedEnv, + ROUTES_FILE: manifestPath, + A11Y_GENERATE_REPORT: generateReport ? "true" : "false", + A11Y_REPORT_DIR: "a11y-reports", + A11Y_RESULTS_FILE: resultsFile, + }, + }, +); + +const playwrightFailed = (testResult.status ?? 1) !== 0; + +header("📊 Results"); +runTs("print-results.ts", [], { + A11Y_PLAYWRIGHT_EXIT: String(testResult.status ?? 1), + A11Y_RESULTS_FILE: resultsFile, +}); + +// ─── 4. Upload per-route HTML reports ───────────────────────────────────────── + +let runUrl: string | undefined; + +if (playwrightFailed && generateReport && existsSync(reportDir)) { + const htmlFiles = readdirSync(reportDir).filter((f) => f.endsWith(".html")); + + if (htmlFiles.length > 0) { + header("📊 Creating output"); + + const [owner, repo] = GITHUB_REPOSITORY.split("/"); + const client = new DefaultArtifactClient(); + const urlMap: Record = {}; + + // @actions/artifact writes "Artifact name is valid!" etc. to stdout for + // every file. Suppress those lines to keep the log readable. + const origWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { + if (typeof chunk === "string" && / is valid!/.test(chunk)) return true; + return (origWrite as unknown as (...a: unknown[]) => boolean)( + chunk, + ...rest, + ); + }) ; + + for (const file of htmlFiles) { + const { id } = await client.uploadArtifact( + file, + [path.join(reportDir, file)], + reportDir, + { skipArchive: true }, + ); + if (id !== undefined) { + urlMap[file] = + `https://github.com/${owner}/${repo}/actions/runs/${GITHUB_RUN_ID}/artifacts/${id}`; + origWrite(` · ${file} → ${urlMap[file]}\n`); + } + } + + process.stdout.write = origWrite as typeof process.stdout.write; + writeFileSync(artifactUrlsFile, JSON.stringify(urlMap)); + runUrl = `https://github.com/${owner}/${repo}/actions/runs/${GITHUB_RUN_ID}`; + } +} + +// ─── 5. Set outputs ─────────────────────────────────────────────────────────── + +core.setOutput("violations-found", playwrightFailed ? "true" : "false"); +core.setOutput("report-url", runUrl ?? ""); + +// ─── 6. Post PR comment ─────────────────────────────────────────────────────── + +if (shouldPostComment && GITHUB_EVENT_NAME === "pull_request") { + const event = GITHUB_EVENT_PATH + ? (JSON.parse(readFileSync(GITHUB_EVENT_PATH, "utf-8")) as Record< + string, + unknown + >) + : {}; + const prNumber = String( + (event.pull_request as Record | undefined)?.number ?? "", + ); + + try { + runTs("post-comment.ts", [], { + GH_TOKEN: githubToken || undefined, + GITHUB_PR_NUMBER: prNumber, + A11Y_OUTCOME: playwrightFailed ? "failure" : "success", + REPORT_URL: runUrl ?? "", + ARTIFACT_URLS_FILE: artifactUrlsFile, + REPORT_DIR: reportDir, + RESULTS_FILE: resultsFile, + CUSTOM_TEMPLATE_SUCCESS: commentTemplateSuccess || undefined, + CUSTOM_TEMPLATE_FAILURE: commentTemplateFailure || undefined, + ACTION_PATH: actionDir, + }); + } catch (err) { + core.warning(`Failed to post PR comment: ${err}`); + } +} + +// ─── 7. Fail if violations found ───────────────────────────────────────────── + +if (playwrightFailed) { + process.exit(1); +} diff --git a/runner/package.json b/runner/package.json index 9020924..cc1aafd 100644 --- a/runner/package.json +++ b/runner/package.json @@ -9,10 +9,10 @@ }, "dependencies": { "@actions/artifact": "^6.2.0", + "@actions/core": "^1.11.1", "@axe-core/playwright": "^4.11.3", "@playwright/test": "^1.60.0", "axe-core": "^4.11.4", "axe-html-reporter": "^2.2.11" - }, - "packageManager": "pnpm@11.1.3" + } } \ No newline at end of file diff --git a/runner/pnpm-lock.yaml b/runner/pnpm-lock.yaml index 454ac8e..84c6b41 100644 --- a/runner/pnpm-lock.yaml +++ b/runner/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@actions/artifact': specifier: ^6.2.0 version: 6.2.1 + '@actions/core': + specifier: ^1.11.1 + version: 1.11.1 '@axe-core/playwright': specifier: ^4.11.3 version: 4.11.3(playwright-core@1.60.0) @@ -33,21 +36,33 @@ packages: '@actions/artifact@6.2.1': resolution: {integrity: sha512-sJGH0mhEbEjBCw7o6SaLhUU66u27aFW8HTfkIb5Tk2/Wy0caUDc+oYQEgnuFN7a0HCpAbQyK0U6U7XUJDgDWrw==} + '@actions/core@1.11.1': + resolution: {integrity: sha512-hXJCSrkwfA46Vd9Z3q4cpEpHB1rL5NG04+/rbqW9d3+CSvtB1tYe8UTpAlixa1vj0m/ULglfEK2UKxMGxCxv5A==} + '@actions/core@3.0.1': resolution: {integrity: sha512-a6d/Nwahm9fliVGRhdhofo40HjHQasUPusmc7vBfyky+7Z+P2A1J68zyFVaNcEclc/Se+eO595oAr5nwEIoIUA==} + '@actions/exec@1.1.1': + resolution: {integrity: sha512-+sCcHHbVdk93a0XT19ECtO/gIXoxvdsgQLzb2fE2/5sIZmWQuluYyjPQtrtTHdU1YzTZ7bAPN4sITq2xi1679w==} + '@actions/exec@3.0.0': resolution: {integrity: sha512-6xH/puSoNBXb72VPlZVm7vQ+svQpFyA96qdDBvhB8eNZOE8LtPf9L4oAsfzK/crCL8YZ+19fKYVnM63Sl+Xzlw==} '@actions/github@9.1.1': resolution: {integrity: sha512-tL5JbYOBZHc0ngEnCsaDcryUizIUIlQyIMwy1Wkx93H5HzbBJ7TbiPx2PnFjBwZW0Vh05JmfFZhecE6gglYegA==} + '@actions/http-client@2.2.3': + resolution: {integrity: sha512-mx8hyJi/hjFvbPokCg4uRd4ZX78t+YyRPtnKWwIl+RzNaVuFpQHfmlGVfsKEJN8LwTCvL+DfVgAM04XaHkm6bA==} + '@actions/http-client@3.0.2': resolution: {integrity: sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==} '@actions/http-client@4.0.1': resolution: {integrity: sha512-+Nvd1ImaOZBSoPbsUtEhv+1z99H12xzncCkz0a3RuehINE81FZSe2QTj3uvAPTcJX/SCzUQHQ0D1GrPMbrPitg==} + '@actions/io@1.1.3': + resolution: {integrity: sha512-wi9JjgKLYS7U/z8PPbco+PvTb/nRWjeoFlJ1Qer83k/3C5PHQi28hiVdeE2kHXmIL99mQFawx8qt/JPjZilJ8Q==} + '@actions/io@3.0.2': resolution: {integrity: sha512-nRBchcMM+QK1pdjO7/idu86rbJI5YHUKCvKs0KxnSYbVe3F51UfGxuZX4Qy/fWlp6l7gWFwIkrOzN+oUK03kfw==} @@ -117,6 +132,10 @@ packages: '@bufbuild/protoplugin@2.12.0': resolution: {integrity: sha512-ORlDITp8AFUXzIhLRoMCG+ud+D3MPKWb5HQXBoskMMnjeyEjE1H1qLonVNPyOr8lkx3xSfYUo8a0dvOZJVAzow==} + '@fastify/busboy@2.1.1': + resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} + engines: {node: '>=14'} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -627,6 +646,10 @@ packages: undici-types@7.24.6: resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici@5.29.0: + resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} + engines: {node: '>=14.0'} + undici@6.25.0: resolution: {integrity: sha512-ZgpWDC5gmNiuY9CnLVXEH8rl50xhRCuLNA97fAUnKi8RRuV4E6KG31pDTsLVUKnohJE0I3XDrTeEydAXRw47xg==} engines: {node: '>=18.17'} @@ -685,11 +708,20 @@ snapshots: - react-native-b4a - supports-color + '@actions/core@1.11.1': + dependencies: + '@actions/exec': 1.1.1 + '@actions/http-client': 2.2.3 + '@actions/core@3.0.1': dependencies: '@actions/exec': 3.0.0 '@actions/http-client': 4.0.1 + '@actions/exec@1.1.1': + dependencies: + '@actions/io': 1.1.3 + '@actions/exec@3.0.0': dependencies: '@actions/io': 3.0.2 @@ -704,6 +736,11 @@ snapshots: '@octokit/request-error': 7.1.0 undici: 6.25.0 + '@actions/http-client@2.2.3': + dependencies: + tunnel: 0.0.6 + undici: 5.29.0 + '@actions/http-client@3.0.2': dependencies: tunnel: 0.0.6 @@ -714,6 +751,8 @@ snapshots: tunnel: 0.0.6 undici: 6.25.0 + '@actions/io@1.1.3': {} + '@actions/io@3.0.2': {} '@axe-core/playwright@4.11.3(playwright-core@1.60.0)': @@ -844,6 +883,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@fastify/busboy@2.1.1': {} + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -1362,6 +1403,10 @@ snapshots: undici-types@7.24.6: {} + undici@5.29.0: + dependencies: + '@fastify/busboy': 2.1.1 + undici@6.25.0: {} universal-user-agent@7.0.3: {} diff --git a/runner/print-config.ts b/runner/print-config.ts index 2825d04..1ba3774 100644 --- a/runner/print-config.ts +++ b/runner/print-config.ts @@ -2,8 +2,9 @@ * Print the configuration for the accessibility check. */ import { readFileSync } from "node:fs"; +import { styleText } from "node:util"; -const bold = (s: string) => `\x1b[1m${s}\x1b[0m`; +const bold = (s: string) => styleText("bold", s); const row = (label: string, value: string) => console.log(` ${bold(label.padEnd(14))} ${value}`); @@ -41,6 +42,6 @@ if (exclusions && exclusions !== "[]") row("Exclusions", exclusions); console.log(""); for (const route of routes) { - console.log(` \x1b[1;35m\u00b7\x1b[0m ${route}`); + console.log(` ${styleText(["bold", "magenta"], "\u00b7")} ${route}`); } console.log(""); diff --git a/runner/print-header.ts b/runner/print-header.ts index 0d10770..e21265b 100644 --- a/runner/print-header.ts +++ b/runner/print-header.ts @@ -3,8 +3,9 @@ * The label text is passed as a command-line argument. * Example usage: `node --experimental-strip-types print-header.ts "📦 Label text"` */ +import { styleText } from "node:util"; const label = process.argv[2] ?? ""; const bar = "\u2500".repeat(56); -console.log(`\n\x1b[1;35m\u250c${bar}\u2510\x1b[0m`); -console.log(`\x1b[1;35m\u2502\x1b[0m ${label}`); -console.log(`\x1b[1;35m\u2514${bar}\u2518\x1b[0m\n`); +console.log(`\n${styleText(["bold", "magenta"], `\u250c${bar}\u2510`)}`); +console.log(`${styleText(["bold", "magenta"], "\u2502")} ${label}`); +console.log(`${styleText(["bold", "magenta"], `\u2514${bar}\u2518`)}\n`); diff --git a/runner/print-resolved-rules.ts b/runner/print-resolved-rules.ts index a201fc7..4ea66b4 100644 --- a/runner/print-resolved-rules.ts +++ b/runner/print-resolved-rules.ts @@ -2,8 +2,9 @@ * Resolves the applied rules and prints them to stdout. */ import axe from "axe-core"; +import { styleText } from "node:util"; -const parseArr = (s: string | undefined): string[] | null => { +function parseArr(s: string | undefined): string[] | null { if (!s) return null; try { @@ -14,7 +15,7 @@ const parseArr = (s: string | undefined): string[] | null => { } catch { return null; } -}; +} const withRules = parseArr(process.env.A11Y_RULES); const withTags = parseArr(process.env.A11Y_RULESETS); @@ -25,17 +26,17 @@ let rules = axe.getRules(activeTags); if (withRules) rules = rules.filter((r) => withRules.includes(r.ruleId)); rules = rules.filter((r) => !disableRules.includes(r.ruleId)); -console.log(" \x1b[1mResolved rules\x1b[0m\n"); +console.log(` ${styleText("bold", "Resolved rules")}\n`); if (!rules.length) { console.log(" (no rules match the configuration)\n"); process.exit(0); } -const w = Math.max(...rules.map((r) => r.ruleId.length)); +const width = Math.max(...rules.map((r) => r.ruleId.length)); for (const r of rules.toSorted((a, b) => a.ruleId.localeCompare(b.ruleId))) { console.log( - ` \x1b[1;35m\u00b7\x1b[0m ${r.ruleId.padEnd(w)} ${r.description}`, + ` ${styleText(["bold", "magenta"], "\u00b7")} ${r.ruleId.padEnd(width)} ${r.description}`, ); } console.log(""); diff --git a/runner/print-results.ts b/runner/print-results.ts index 1b286f2..e2e4c34 100644 --- a/runner/print-results.ts +++ b/runner/print-results.ts @@ -1,18 +1,18 @@ /** * Prints the results of the accessibility tests to stdout. */ -import { readFileSync, existsSync } from 'node:fs'; +import { readFileSync, existsSync } from "node:fs"; -const exitCode = Number(process.env.A11Y_PLAYWRIGHT_EXIT ?? '0'); -const resultsFile = process.env.A11Y_RESULTS_FILE ?? ''; +const exitCode = Number(process.env.A11Y_PLAYWRIGHT_EXIT ?? "0"); +const resultsFile = process.env.A11Y_RESULTS_FILE ?? ""; if (exitCode === 0) { - console.log(' \u2705 No accessibility issues detected\n\n'); + console.log(" \u2705 No accessibility issues detected\n\n"); } else { - console.log(' \u274c The following tests failed:\n\n'); + console.log(" \u274c The following tests failed:\n\n"); if (resultsFile && existsSync(resultsFile)) { try { - const data = JSON.parse(readFileSync(resultsFile, 'utf-8')); + const data = JSON.parse(readFileSync(resultsFile, "utf-8")); const lines: string[] = []; const walk = (suites: any[]) => { @@ -21,8 +21,12 @@ if (exitCode === 0) { for (const spec of suite.specs ?? []) { for (const test of spec.tests ?? []) { for (const result of test.results ?? []) { - if (result.status === 'failed' || result.status === 'timedOut') { - const suffix = result.status === 'timedOut' ? ' (timed out)' : ''; + if ( + result.status === "failed" || + result.status === "timedOut" + ) { + const suffix = + result.status === "timedOut" ? " (timed out)" : ""; lines.push(` \u00b7 ${spec.title}${suffix}`); } } @@ -37,5 +41,5 @@ if (exitCode === 0) { } } catch {} } - console.log('\n'); + console.log("\n"); } diff --git a/runner/upload-action/action.yml b/runner/upload-action/action.yml deleted file mode 100644 index 766faff..0000000 --- a/runner/upload-action/action.yml +++ /dev/null @@ -1,5 +0,0 @@ -name: "Upload A11y Reports (internal)" -description: "Internal sub-action: uploads per-route HTML reports as separate unarchived artifacts so each can be viewed directly in the browser." -runs: - using: "node24" - main: "upload.ts" diff --git a/runner/upload-action/upload.ts b/runner/upload-action/upload.ts deleted file mode 100644 index 5ca1b46..0000000 --- a/runner/upload-action/upload.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Uploads each HTML report as a separate unarchived artifact so each route - * can be viewed directly in the browser without downloading a zip. - * - * Reads from env (set by the parent composite action step): - * REPORT_DIR – directory containing the per-route HTML reports - * ARTIFACT_URLS_FILE – path to write the { filename → url } JSON map to - * - * Writes to GITHUB_OUTPUT: - * run-url – URL of the workflow run - */ -import { readdirSync, writeFileSync, appendFileSync } from "node:fs"; -import path from "node:path"; -import { DefaultArtifactClient } from "@actions/artifact"; - -const reportDir = process.env.REPORT_DIR!; -const outputFile = process.env.ARTIFACT_URLS_FILE!; -const githubOutput = process.env.GITHUB_OUTPUT!; -const runId = process.env.GITHUB_RUN_ID!; -const [owner, repo] = (process.env.GITHUB_REPOSITORY ?? "/").split("/"); - -const client = new DefaultArtifactClient(); -const files = readdirSync(reportDir).filter((f) => f.endsWith(".html")); -const urlMap: Record = {}; - -// Suppress the "…is valid!" informational messages @actions/artifact writes -// to stdout for every upload; keep everything else (errors, actual output). -const origWrite = process.stdout.write.bind(process.stdout); -process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { - if (typeof chunk === "string" && / is valid!/.test(chunk)) return true; - return (origWrite as unknown as (...a: unknown[]) => boolean)(chunk, ...rest); -}) as unknown as typeof process.stdout.write; - -for (const file of files) { - const filePath = path.join(reportDir, file); - const { id } = await client.uploadArtifact(file, [filePath], reportDir, { - skipArchive: true, - }); - if (id !== undefined) { - urlMap[file] = `https://github.com/${owner}/${repo}/actions/runs/${runId}/artifacts/${id}`; - origWrite(` · ${file} → ${urlMap[file]}\n`); - } -} - -process.stdout.write = origWrite as typeof process.stdout.write; - -writeFileSync(outputFile, JSON.stringify(urlMap)); - -const runUrl = `https://github.com/${owner}/${repo}/actions/runs/${runId}`; -appendFileSync(githubOutput, `run-url=${runUrl}\n`);