Product design skills

scripts/browser.mjs

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

#!/usr/bin/env node
// Playwright と Chromium を「環境に依存せず」に見つけて起動するための共通ヘルパー。
//
// このスキルは Playwright MCP を前提にしているが、axe.min.js のように大きすぎて
// browser_evaluate に一度に渡せないものは、Bash から Playwright を直接動かす方が確実である
// (SKILL.md 手順4)。その際に run-axe.mjs / focus-walk.mjs から使う。
//
// このパッケージ自体は playwright に依存していない。実行時には以下の順で探す。
//   1. 環境変数 PLAYWRIGHT_MODULE で明示されたモジュール指定子/パス
//   2. import('playwright') / import('playwright-core') (どこかにインストールされていれば)
//   3. Playwright MCP (@playwright/mcp) が持ち込む playwright-core を、npx キャッシュや
//      グローバル node_modules から探す
// Chromium の実行ファイルも、Playwright 同梱のものが無ければ ms-playwright キャッシュから探し、
// それでも無ければ Chrome/Edge のチャネルを試す。

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);

/** 与えられた specifier / パスから chromium を取り出す。失敗したら null。 */
async function tryImportChromium(specifier) {
  try {
    let target = specifier;
    // ディレクトリやファイルパスなら file URL に変換して import する
    if (specifier.includes("/") || specifier.includes("\\")) {
      let entry = specifier;
      if (existsSync(entry) && statSync(entry).isDirectory()) {
        // package のディレクトリを指している場合は require.resolve で解決する
        try {
          entry = require.resolve(specifier);
        } catch {
          entry = join(specifier, "index.js");
        }
      }
      target = pathToFileURL(entry).href;
    }
    const mod = await import(target);
    // CommonJS の Playwright は名前付き export を持たず default にぶら下がる
    return mod.chromium ?? mod.default?.chromium ?? null;
  } catch {
    return null;
  }
}

/** npx キャッシュやグローバル node_modules から playwright(-core) のディレクトリを探す */
function findPlaywrightDirs() {
  const dirs = [];
  const roots = [];

  // npm/pnpm のグローバル 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 キャッシュ (ハッシュ名のサブディレクトリ)
  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;
    // 直下
    for (const n of names) {
      const p = join(root, n);
      if (existsSync(join(p, "package.json"))) dirs.push(p);
    }
    // npx キャッシュは <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;
}

/** chromium オブジェクトを取得する。見つからなければ例外。 */
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(
    "Playwright が見つかりませんでした。以下のいずれかを行ってください。\n" +
      "  - 環境変数 PLAYWRIGHT_MODULE に playwright(-core) のパスを指定する\n" +
      "  - `npm i -g playwright` などでインストールする\n" +
      "  - あるいはこのスクリプトを使わず、Playwright MCP 側で axe を注入する\n" +
      "    (assets/axe.min.js の内容を browser_evaluate で評価する)"
  );
}

/** ms-playwright キャッシュから Chromium 系の実行ファイルを探す (ビルド番号の大きい順) */
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;
  };
  // headless shell を優先し、次に通常の chromium。ビルド番号の大きい順。
  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;
    }
    // 予備: ディレクトリ内を1階層だけ走査して実行ファイル名で拾う
    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;
}

/**
 * Chromium を起動して { browser } を返す。
 * まず Playwright 同梱ブラウザ、次に ms-playwright キャッシュ、最後に Chrome/Edge チャネルを試す。
 * @param {object} [opts] chromium.launch に渡す追加オプション
 */
export async function launchChromium(opts = {}) {
  const chromium = await getChromium();
  const launchOpts = { headless: true, ...opts };

  // 1. 素直に起動 (Playwright 同梱ブラウザ)
  try {
    return await chromium.launch(launchOpts);
  } catch (e1) {
    // 2. ms-playwright キャッシュから実行ファイルを探して再試行
    const cached = findCachedChromium();
    if (cached) {
      try {
        return await chromium.launch({ ...launchOpts, executablePath: cached });
      } catch {
        /* 次へ */
      }
    }
    // 3. システムにインストールされた Chrome / Edge チャネル
    for (const channel of ["chrome", "chromium", "msedge"]) {
      try {
        return await chromium.launch({ ...launchOpts, channel });
      } catch {
        /* 次へ */
      }
    }
    throw new Error(
      "Chromium を起動できませんでした。ブラウザがインストールされていない可能性があります。\n" +
        "  - `npx playwright install chromium` を実行する\n" +
        "  - もしくは環境変数 PLAYWRIGHT_BROWSERS_PATH でキャッシュ場所を指定する\n" +
        `元のエラー: ${e1.message}`
    );
  }
}