Product design skills

scripts/browser.mjs

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

#!/usr/bin/env node
// Common helper for finding and launching Playwright and Chromium in an "environment-independent" way.
//
// This skill assumes Playwright MCP, but for something too large to pass to browser_evaluate
// in one go, like axe.min.js, it is more reliable to run Playwright directly from Bash
// (SKILL.md Step 4). It is used from run-axe.mjs / focus-walk.mjs in that case.
//
// This package itself does not depend on playwright. At runtime it looks in the following order.
//   1. The module specifier/path given explicitly via the PLAYWRIGHT_MODULE environment variable
//   2. import('playwright') / import('playwright-core') (if installed anywhere)
//   3. The playwright-core brought in by Playwright MCP (@playwright/mcp), searched for in the
//      npx cache or the global node_modules
// For the Chromium executable too, if none is bundled with Playwright, it searches the
// ms-playwright cache, and failing that, tries the Chrome/Edge channels.

import { existsSync, readdirSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { createRequire } from "node:module";
import { pathToFileURL } from "node:url";

const require = createRequire(import.meta.url);

/** Extract chromium from the given specifier / path. Returns null on failure. */
async function tryImportChromium(specifier) {
  try {
    let target = specifier;
    // If it's a directory or file path, convert it to a file URL before importing
    if (specifier.includes("/") || specifier.includes("\\")) {
      let entry = specifier;
      if (existsSync(entry) && statSync(entry).isDirectory()) {
        // If it points to a package directory, resolve it with require.resolve
        try {
          entry = require.resolve(specifier);
        } catch {
          entry = join(specifier, "index.js");
        }
      }
      target = pathToFileURL(entry).href;
    }
    const mod = await import(target);
    // CommonJS Playwright has no named exports and hangs off default instead
    return mod.chromium ?? mod.default?.chromium ?? null;
  } catch {
    return null;
  }
}

/** Search the npx cache and global node_modules for a playwright(-core) directory */
function findPlaywrightDirs() {
  const dirs = [];
  const roots = [];

  // npm/pnpm global root
  for (const env of ["npm_config_prefix", "PNPM_HOME"]) {
    if (process.env[env]) roots.push(join(process.env[env], "lib", "node_modules"), join(process.env[env], "node_modules"));
  }
  roots.push(join(homedir(), ".npm", "_npx")); // npx cache (subdirectories named by hash)
  roots.push("/usr/local/lib/node_modules", "/usr/lib/node_modules", "/opt/homebrew/lib/node_modules");

  const names = ["playwright", "playwright-core"];
  for (const root of roots) {
    if (!existsSync(root)) continue;
    // Directly under root
    for (const n of names) {
      const p = join(root, n);
      if (existsSync(join(p, "package.json"))) dirs.push(p);
    }
    // The npx cache is <root>/<hash>/node_modules/<name>
    let sub = [];
    try {
      sub = readdirSync(root, { withFileTypes: true }).filter((e) => e.isDirectory());
    } catch {
      sub = [];
    }
    for (const e of sub) {
      for (const n of names) {
        const p = join(root, e.name, "node_modules", n);
        if (existsSync(join(p, "package.json"))) dirs.push(p);
      }
    }
  }
  return dirs;
}

/** Obtain the chromium object. Throws if not found. */
export async function getChromium() {
  const attempts = [];
  if (process.env.PLAYWRIGHT_MODULE) attempts.push(process.env.PLAYWRIGHT_MODULE);
  attempts.push("playwright", "playwright-core");
  attempts.push(...findPlaywrightDirs());

  for (const a of attempts) {
    const chromium = await tryImportChromium(a);
    if (chromium) return chromium;
  }
  throw new Error(
    "Could not find Playwright. Do one of the following.\n" +
      "  - Set the PLAYWRIGHT_MODULE environment variable to the path of playwright(-core)\n" +
      "  - Install it with `npm i -g playwright` or similar\n" +
      "  - Or, instead of using this script, inject axe from the Playwright MCP side\n" +
      "    (evaluate the contents of assets/axe.min.js with browser_evaluate)"
  );
}

/** Search the ms-playwright cache for a Chromium-family executable (largest build number first) */
function findCachedChromium() {
  const base =
    process.env.PLAYWRIGHT_BROWSERS_PATH ||
    (platform() === "win32"
      ? join(homedir(), "AppData", "Local", "ms-playwright")
      : platform() === "darwin"
        ? join(homedir(), "Library", "Caches", "ms-playwright")
        : join(homedir(), ".cache", "ms-playwright"));
  if (!existsSync(base)) return null;

  let entries = [];
  try {
    entries = readdirSync(base, { withFileTypes: true }).filter((e) => e.isDirectory());
  } catch {
    return null;
  }
  const buildNo = (name) => {
    const m = name.match(/-(\d+)$/);
    return m ? parseInt(m[1], 10) : 0;
  };
  // Prefer the headless shell, then regular chromium. Largest build number first.
  const prefer = (name) => (name.startsWith("chromium_headless_shell") ? 2 : name.startsWith("chromium") ? 1 : 0);
  const dirs = entries
    .filter((e) => e.name.startsWith("chromium"))
    .sort((a, b) => prefer(b.name) - prefer(a.name) || buildNo(b.name) - buildNo(a.name))
    .map((e) => join(base, e.name));

  const candidates =
    platform() === "win32"
      ? ["chrome-headless-shell-win/chrome-headless-shell.exe", "chrome-win/chrome.exe"]
      : platform() === "darwin"
        ? [
            "chrome-headless-shell-mac-arm64/chrome-headless-shell",
            "chrome-headless-shell-mac-x64/chrome-headless-shell",
            "chrome-mac/Chromium.app/Contents/MacOS/Chromium",
          ]
        : [
            "chrome-headless-shell-linux/chrome-headless-shell",
            "chrome-linux/chrome",
            "chrome-linux/headless_shell",
          ];

  for (const dir of dirs) {
    for (const rel of candidates) {
      const p = join(dir, ...rel.split("/"));
      if (existsSync(p)) return p;
    }
    // Fallback: scan one level into the directory and pick up by executable name
    try {
      for (const sub of readdirSync(dir, { withFileTypes: true })) {
        if (!sub.isDirectory()) continue;
        for (const bin of ["chrome-headless-shell", "headless_shell", "chrome"]) {
          const p = join(dir, sub.name, bin);
          if (existsSync(p)) return p;
        }
      }
    } catch {
      /* ignore */
    }
  }
  return null;
}

/**
 * Launch Chromium and return { browser }.
 * Tries the browser bundled with Playwright first, then the ms-playwright cache, and finally
 * the Chrome/Edge channels.
 * @param {object} [opts] Additional options passed to chromium.launch
 */
export async function launchChromium(opts = {}) {
  const chromium = await getChromium();
  const launchOpts = { headless: true, ...opts };

  // 1. Launch directly (browser bundled with Playwright)
  try {
    return await chromium.launch(launchOpts);
  } catch (e1) {
    // 2. Look for an executable in the ms-playwright cache and retry
    const cached = findCachedChromium();
    if (cached) {
      try {
        return await chromium.launch({ ...launchOpts, executablePath: cached });
      } catch {
        /* next */
      }
    }
    // 3. A Chrome / Edge channel installed on the system
    for (const channel of ["chrome", "chromium", "msedge"]) {
      try {
        return await chromium.launch({ ...launchOpts, channel });
      } catch {
        /* next */
      }
    }
    throw new Error(
      "Could not launch Chromium. A browser may not be installed.\n" +
        "  - Run `npx playwright install chromium`\n" +
        "  - Or set the cache location with the PLAYWRIGHT_BROWSERS_PATH environment variable\n" +
        `Original error: ${e1.message}`
    );
  }
}