diff --git a/.devcontainer/README.md b/.devcontainer/README.md index 4e77fde8ef5..b5e2aa64994 100644 --- a/.devcontainer/README.md +++ b/.devcontainer/README.md @@ -176,6 +176,69 @@ iptables -L OUTPUT -n --line-numbers # ルールを確認 - 一時的に切りたいときは `devcontainer.json` の `postStartCommand` 行をコメントアウト (非推奨)。次回起動から firewall 無しになる。 +## Playwright MCP (devpod 内外どちらでも動く headless 構成) + +リポジトリの `.mcp.json` は `playwright` MCP サーバを **committed 済み**で、devpod 内でも +ホストでも**追加設定なしで headless 動作**する。実体は薄いラッパー +`tools/mcp/playwright-mcp.mjs` を経由する: + +```json +"playwright": { + "type": "stdio", + "command": "node", + "args": ["tools/mcp/playwright-mcp.mjs"], + "env": {} +} +``` + +### なぜ素の `npx @playwright/mcp@latest` ではダメか (Issue #999) + +- `@playwright/mcp@latest` は既定で **headful** 起動する → **display の無い devpod + コンテナではブラウザ起動に失敗**する。 +- `--browser chromium --headless` を足しても、その MCP 版がバンドルする playwright-core は + **自分専用の新しい chromium リビジョン**を要求し、未導入なら**起動時に + `cdn.playwright.dev` からダウンロード**しようとする。このホストは **egress allowlist + で `cdn.playwright.dev` を遮断**しているため**ダウンロードに失敗**する + (「Playwright was just installed or updated. Please run npx playwright install」で停止)。 + +### ラッパーが何をするか + +`tools/mcp/playwright-mcp.mjs` は、**リポジトリの `playwright`(root `node_modules`)が +イメージビルド時(firewall 適用前)に既に導入済みの chromium** の実行ファイルパスを +`require('playwright').chromium.executablePath()` で解決し、それを +`@playwright/mcp` に `--executable-path --headless` で渡す。 + +- **devpod 内**: ビルド時導入済み chromium を使うので**追加ダウンロード不要**→ 動く。 +- **ホスト**: 同じ解決でホストの chromium を使う(未導入でもホストは egress 制限が + 無いので通常どおり取得される)→ 動く。 +- 解決できない場合は `--executable-path` を付けず、MCP 既定挙動へフォールバックする。 + +疎通確認(コンテナ内、root): + +```bash +node --test tools/mcp/playwright-mcp.test.mjs # ラッパーの単体テスト +claude mcp list # playwright が ✓ connected +``` + +### ホストで headful に見たい場合 (opt-in・committed 設定は変えない) + +headful はホスト固有なので committed `.mcp.json` には入れない。ホストで +**HTTP server mode の Playwright MCP** を起動し、コンテナ側は **local scope** で +上書きする(local が project scope より優先されるので `.mcp.json` は変更不要): + +```bash +# ホスト側 (iTerm2)。ヘルパースクリプトが用意してある: +tools/host-playwright-mcp.sh +# コンテナ側で一度だけ local scope を張る: +claude mcp add --scope local --transport http playwright http://host.docker.internal:8931/mcp +# /mcp で Reconnect +``` + +`--host 0.0.0.0` 必須・`--allowed-hosts` はポート込みカンマ区切り 1 引数など、 +ハマりどころは `tools/host-playwright-mcp.sh` の先頭コメントに集約してある。 +ホスト Chrome を直接操作する一般的な背景は `.claude/rules/scratch-gui/e2e-test.md` +(→ memory `reference_host_playwright_mcp.md`)も参照。 + ## tmux ### 設定の構成 diff --git a/.mcp.json b/.mcp.json index 5f403b3855d..8a3e93f879c 100644 --- a/.mcp.json +++ b/.mcp.json @@ -11,10 +11,9 @@ }, "playwright": { "type": "stdio", - "command": "npx", + "command": "node", "args": [ - "-y", - "@playwright/mcp@latest" + "tools/mcp/playwright-mcp.mjs" ], "env": {} }, diff --git a/tools/mcp/playwright-mcp.mjs b/tools/mcp/playwright-mcp.mjs new file mode 100644 index 00000000000..1cddfdd1af7 --- /dev/null +++ b/tools/mcp/playwright-mcp.mjs @@ -0,0 +1,122 @@ +#!/usr/bin/env node +/** + * Playwright MCP launcher — committed, works both inside devpod and on the host. + * + * なぜ薄いラッパーが要るか(Issue #999): + * `.mcp.json` から素の `npx -y @playwright/mcp@latest` を起動すると、その版が + * バンドルする playwright-core が要求する **新しい chromium リビジョンを起動時に + * ダウンロード**しようとする。devpod コンテナは egress allowlist で + * `cdn.playwright.dev` を遮断しているためダウンロードに失敗し、ブラウザが起動しない + * (headful 既定なので display 不足でも失敗する)。 + * + * そこで、リポジトリの `playwright`(root node_modules)が **イメージビルド時に + * 既に導入済みの chromium** の実行ファイルパスを解決し、`--executable-path` で + * その既存 chromium を headless 起動する。これで: + * - devpod 内: 追加ダウンロード不要(ビルド時導入分を使う)→ 動く + * - ホスト: 同じ解決で host の chromium を使う(無ければ host は egress 制限が + * 無いので通常どおり取得できる)→ 動く + * 単一の committable 設定で両環境が動作する。 + * + * ホストで headful に見たい場合は committed 設定を変えず local-scope の HTTP MCP で + * opt-in する(`tools/host-playwright-mcp.sh` と `.devcontainer/README.md` の + * 「ホストで headful に見たい場合」参照)。 + * + * `.mcp.json` からは `command: "node", args: ["tools/mcp/playwright-mcp.mjs"]` で呼ぶ。 + * stdio transport はこのプロセスの stdin/stdout を子 (`npx` → MCP) にそのまま + * 引き継ぐ(`stdio: 'inherit'`)ため、JSON-RPC バイト列はラッパーの JS を経由しない。 + */ + +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; + +const DEFAULT_MCP_SPEC = '@playwright/mcp@latest'; + +/** + * npx に渡す引数配列を組み立てる(純粋関数)。 + * @param {object} opts - オプション + * @param {?string} opts.chromiumPath - 使う chromium 実行ファイルパス(null なら付けない) + * @param {string[]} [opts.extraArgs] - 末尾へ透過する追加引数 + * @param {string} [opts.mcpSpec] - `@playwright/mcp` の npm 指定(既定は latest) + * @returns {string[]} `npx` に渡す引数配列 + */ +export function buildMcpArgs({ chromiumPath, extraArgs = [], mcpSpec = DEFAULT_MCP_SPEC }) { + const args = ['-y', mcpSpec, '--headless']; + if (chromiumPath) { + args.push('--executable-path', chromiumPath); + } + args.push(...extraArgs); + return args; +} + +/** + * リポジトリの playwright が導入済みの chromium 実行ファイルパスを解決する(純粋関数)。 + * 解決できない/未導入なら null を返す(呼び出し側は `--executable-path` を付けず、 + * MCP 既定のダウンロード挙動へフォールバックする)。 + * @param {() => object} loadPlaywright - playwright モジュールを返す関数(テスト用に注入) + * @returns {?string} chromium 実行ファイルの絶対パス、または null + */ +export function resolveChromiumExecutable(loadPlaywright) { + try { + const playwright = loadPlaywright(); + const p = playwright?.chromium?.executablePath?.(); + return typeof p === 'string' && p.length > 0 ? p : null; + } catch { + return null; + } +} + +/** + * このファイルの位置を起点に `playwright` を require で解決する。 + * worktree では root の node_modules が main へ symlink されているため、 + * script 位置(tools/mcp/)から上方向に辿ってリポジトリ root の playwright に届く。 + * @returns {object} playwright モジュール + */ +function loadRepoPlaywright() { + const require = createRequire(import.meta.url); + return require('playwright'); +} + +/** + * エントリポイント。chromium を解決して MCP を起動し、子プロセスの終了コードで終わる。 + * @returns {void} + */ +function main() { + const log = (m) => process.stderr.write(`[playwright-mcp] ${m}\n`); + const chromiumPath = resolveChromiumExecutable(loadRepoPlaywright); + if (chromiumPath) { + log(`using repo chromium: ${chromiumPath}`); + } else { + log('repo chromium not resolved; falling back to @playwright/mcp default browser'); + } + + const args = buildMcpArgs({ chromiumPath, extraArgs: process.argv.slice(2) }); + // stdio: 'inherit' で stdin/stdout/stderr を子へ透過(JSON-RPC はここを通らない)。 + const child = spawn('npx', args, { stdio: 'inherit' }); + + const forward = (sig) => { + try { + child.kill(sig); + } catch { + /* 子が既に終了している場合は無視 */ + } + }; + process.on('SIGINT', () => forward('SIGINT')); + process.on('SIGTERM', () => forward('SIGTERM')); + + child.on('error', (e) => { + log(`failed to start npx: ${e.message}`); + process.exit(1); + }); + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exit(code ?? 0); + }); +} + +// テスト(import)時は main を実行しない。直接起動されたときだけ実行する。 +if (process.argv[1] && import.meta.url === `file://${process.argv[1]}`) { + main(); +} diff --git a/tools/mcp/playwright-mcp.test.mjs b/tools/mcp/playwright-mcp.test.mjs new file mode 100644 index 00000000000..a3518082b40 --- /dev/null +++ b/tools/mcp/playwright-mcp.test.mjs @@ -0,0 +1,75 @@ +/** + * tools/mcp/playwright-mcp.mjs の純粋ロジックのユニットテスト。 + * + * `node --test tools/mcp/playwright-mcp.test.mjs` で実行する(外部依存なし)。 + */ + +import { test } from 'node:test'; +import assert from 'node:assert'; + +import { buildMcpArgs, resolveChromiumExecutable } from './playwright-mcp.mjs'; + +test('buildMcpArgs: chromiumPath があれば --executable-path を付ける', () => { + const args = buildMcpArgs({ chromiumPath: '/browsers/chrome' }); + assert.deepStrictEqual(args, [ + '-y', + '@playwright/mcp@latest', + '--headless', + '--executable-path', + '/browsers/chrome', + ]); +}); + +test('buildMcpArgs: chromiumPath が無ければ --executable-path を付けない(既定 download 挙動へフォールバック)', () => { + const args = buildMcpArgs({ chromiumPath: null }); + assert.deepStrictEqual(args, ['-y', '@playwright/mcp@latest', '--headless']); +}); + +test('buildMcpArgs: 呼び出し側の追加引数を末尾に透過する', () => { + const args = buildMcpArgs({ + chromiumPath: '/browsers/chrome', + extraArgs: ['--viewport-size', '1280x720'], + }); + assert.deepStrictEqual(args, [ + '-y', + '@playwright/mcp@latest', + '--headless', + '--executable-path', + '/browsers/chrome', + '--viewport-size', + '1280x720', + ]); +}); + +test('buildMcpArgs: mcpSpec を上書きできる(ピン止め用)', () => { + const args = buildMcpArgs({ chromiumPath: null, mcpSpec: '@playwright/mcp@0.0.78' }); + assert.deepStrictEqual(args, ['-y', '@playwright/mcp@0.0.78', '--headless']); +}); + +test('resolveChromiumExecutable: playwright が解決でき executablePath があればそれを返す', () => { + const loadPlaywright = () => ({ chromium: { executablePath: () => '/browsers/chrome-1217' } }); + assert.strictEqual(resolveChromiumExecutable(loadPlaywright), '/browsers/chrome-1217'); +}); + +test('resolveChromiumExecutable: playwright 解決が throw したら null(フォールバック)', () => { + const loadPlaywright = () => { + throw new Error('Cannot find module playwright'); + }; + assert.strictEqual(resolveChromiumExecutable(loadPlaywright), null); +}); + +test('resolveChromiumExecutable: executablePath が空文字なら null', () => { + const loadPlaywright = () => ({ chromium: { executablePath: () => '' } }); + assert.strictEqual(resolveChromiumExecutable(loadPlaywright), null); +}); + +test('resolveChromiumExecutable: executablePath 自体が throw しても null', () => { + const loadPlaywright = () => ({ + chromium: { + executablePath: () => { + throw new Error('no browser'); + }, + }, + }); + assert.strictEqual(resolveChromiumExecutable(loadPlaywright), null); +});