Product design skills

scripts/run-axe.mjs

A supporting file of the a11y-check-page skill, shown as source.

#!/usr/bin/env node
// Uses the bundled axe.min.js to run axe-core on the page at the given URL and outputs the result as JSON.
//
// This skill's basic approach is browser operation via Playwright MCP, but axe.min.js
// (roughly 570KB) can be too large to pass to browser_evaluate in one go. Use this as a
// reliable alternative in that case (SKILL.md Step 4 / references/playwright-workflow.md
// "Running axe-core").
//
// The target tags and output format are kept aligned with the playwright-workflow.md snippet.
// No CDN is used; the bundled assets/axe.min.js is always loaded (supply-chain precaution).
//
// Usage:
//   node run-axe.mjs <url> [options]
//
// Options:
//   --width <px>       Viewport width (default: 1280)
//   --height <px>      Viewport height (default: 900)
//   --wait <state>     goto wait condition load|domcontentloaded|networkidle (default: networkidle)
//   --nodes <n>        Max number of nodes to output per rule (default: 10)
//   --out <file>       File to write the result to (default: stdout)
//   --help             Show this help
//
// To target a screen in a changed state (e.g. a modal opened), create that state on the
// Playwright MCP side first, then inject axe. This script is suited to the initial display,
// or a state reproducible from the URL alone.

import { readFile, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { launchChromium } from "./browser.mjs";

const AXE_PATH = fileURLToPath(new URL("../assets/axe.min.js", import.meta.url));

const USAGE = `Usage:
  node run-axe.mjs <url> [options]

Options:
  --width <px>    Viewport width (default: 1280)
  --height <px>   Viewport height (default: 900)
  --wait <state>  load|domcontentloaded|networkidle (default: networkidle)
  --nodes <n>     Max number of nodes per rule (default: 10)
  --out <file>    File to write the result to (default: stdout)
  --help          Show this help

Target tags: wcag2a wcag2aa wcag21a wcag21aa wcag22aa best-practice
`;

/** axe run within the page. Same output shape as the playwright-workflow.md snippet. */
function axeRunner(maxNodes) {
  return async (limit) => {
    const r = await axe.run(document, {
      runOnly: {
        type: "tag",
        values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa", "best-practice"],
      },
      resultTypes: ["violations", "incomplete"],
    });
    const node = (n) => ({ target: n.target, html: n.html.slice(0, 300), failureSummary: n.failureSummary });
    return {
      version: axe.version,
      url: location.href,
      violations: r.violations.map((v) => ({
        id: v.id,
        impact: v.impact,
        help: v.help,
        tags: v.tags,
        total: v.nodes.length,
        nodes: v.nodes.slice(0, limit).map(node),
      })),
      incomplete: r.incomplete.map((v) => ({
        id: v.id,
        help: v.help,
        total: v.nodes.length,
        nodes: v.nodes.slice(0, limit).map((n) => ({ target: n.target, html: n.html.slice(0, 200) })),
      })),
    };
  };
}

function parseArgs(argv) {
  const opts = { width: 1280, height: 900, wait: "networkidle", nodes: 10, out: null, url: null };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === "--help" || a === "-h") return { help: true };
    else if (a === "--width") opts.width = parseInt(argv[++i], 10);
    else if (a === "--height") opts.height = parseInt(argv[++i], 10);
    else if (a === "--wait") opts.wait = argv[++i];
    else if (a === "--nodes") opts.nodes = parseInt(argv[++i], 10);
    else if (a === "--out") opts.out = argv[++i];
    else if (a.startsWith("--")) throw new Error(`Unknown option: ${a}`);
    else if (!opts.url) opts.url = a;
    else throw new Error(`Extra argument: ${a}`);
  }
  return opts;
}

async function main(argv) {
  let opts;
  try {
    opts = parseArgs(argv);
  } catch (e) {
    process.stderr.write(`${e.message}\n\n${USAGE}`);
    return 1;
  }
  if (opts.help || !opts.url) {
    process.stdout.write(USAGE);
    return opts.help ? 0 : 1;
  }
  if (!existsSync(AXE_PATH)) {
    process.stderr.write(
      `assets/axe.min.js not found: ${AXE_PATH}\n` +
        "The build (pnpm build) may not have been run, or the skill may not be laid out correctly.\n"
    );
    return 1;
  }

  const axeSource = await readFile(AXE_PATH, "utf8");
  const browser = await launchChromium();
  try {
    const page = await browser.newPage({ viewport: { width: opts.width, height: opts.height } });
    await page.goto(opts.url, { waitUntil: opts.wait });
    await page.evaluate(axeSource); // Define axe on the page
    const result = await page.evaluate(axeRunner(opts.nodes), opts.nodes);
    result.viewport = { width: opts.width, height: opts.height };

    const json = JSON.stringify(result, null, 2);
    if (opts.out) {
      await writeFile(opts.out, json + "\n", "utf8");
      process.stderr.write(
        `Wrote to ${opts.out} (violations: ${result.violations.length}, incomplete: ${result.incomplete.length})\n`
      );
    } else {
      process.stdout.write(json + "\n");
    }
    return 0;
  } finally {
    await browser.close();
  }
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main(process.argv.slice(2)).then(
    (code) => {
      process.exitCode = code;
    },
    (e) => {
      process.stderr.write(`${e.message}\n`);
      process.exitCode = 1;
    }
  );
}