diff --git a/.github/workflows/build-claude-code.yml b/.github/workflows/build-claude-code.yml index b15cbd6..379cd3f 100644 --- a/.github/workflows/build-claude-code.yml +++ b/.github/workflows/build-claude-code.yml @@ -76,11 +76,11 @@ jobs: matrix: include: - image-suffix: claude-code - verify-command: "bun --version || true && claude --version && mise --version && fish --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp" + verify-command: "bun --version || true && claude --version && mise --version && fish --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test \"$AGENT_BROWSER_EXECUTABLE_PATH\" = /usr/bin/chromium" runner: ubuntu-24.04 arch: amd64 - image-suffix: claude-code - verify-command: "bun --version || true && claude --version && mise --version && fish --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp" + verify-command: "bun --version || true && claude --version && mise --version && fish --version && rtk --version && ralphex --version && test -x /usr/local/bin/patch-playwright-mcp && test \"$AGENT_BROWSER_EXECUTABLE_PATH\" = /usr/bin/chromium" runner: ubuntu-24.04-arm arch: arm64 - image-suffix: claude-code-sandbox diff --git a/claude-code/.claude/skills/sandbox-playwright/SKILL.md b/claude-code/.claude/skills/sandbox-playwright/SKILL.md index fdc905b..03e6f01 100644 --- a/claude-code/.claude/skills/sandbox-playwright/SKILL.md +++ b/claude-code/.claude/skills/sandbox-playwright/SKILL.md @@ -9,31 +9,39 @@ metadata: # Playwright in This Sandbox -Playwright is reachable through **two independent paths**: +Playwright is reachable through **multiple independent paths**, any combination of which a project may use: - **MCP plugin** (`playwright@claude-plugins-official`) — interactive, ad-hoc browser work. - **`@playwright/test` CLI** — the project's scripted E2E suite. +- **`@vitest/browser-playwright`** — vitest browser mode (used by `@storybook/addon-vitest` for storybook interaction tests, by `@vitest/browser` for browser-mode unit tests). +- **Direct `playwright-core` / `playwright`** — custom scripts that import `chromium` and call `chromium.launch(...)` themselves (CI scrapers, codegen). Don't install anything. Don't spawn a dev server — the user keeps one running on a port they chose. -## Two paths — pick the right one +## Pick the right path | Goal | Use | How | |---|---|---| | Ad-hoc: "does this button work?", "take a screenshot" | **MCP plugin** | `ToolSearch` → `browser_navigate` → `browser_snapshot` / `browser_click` | -| Reproducible test suite | **Project CLI** | `bun run test:e2e` (whatever the script in the project's `package.json` is) | +| Reproducible E2E suite | **`@playwright/test` CLI** | `bun run test:e2e` (whatever the script in the project's `package.json` is) | +| Storybook interaction tests / vitest browser tests | **`@vitest/browser-playwright`** | The project's `vitest`/`storybook` test script (e.g. `bun run test`, `bun run test-storybook`). Config lives in `vitest.config.ts`, NOT `playwright.config.ts`. | +| Custom script with `chromium.launch(...)` | **Direct** | Run the script as the project does. Requires `executablePath` wired at the call site (image-set env var). | ## Discovery first — never hardcode Every concrete fact in this section must come from the project at invocation time, not from this skill's prose. Stale skills are worse than missing ones. -1. **Find the Playwright config.** RTK rewrites `find` and rejects compound predicates (`-not`, `! -path`), so use plain `find` and filter with `grep` — works whether RTK is in the loop or not. +1. **Find the relevant config.** RTK rewrites `find` and rejects compound predicates (`-not`, `! -path`, also `\( … -o … \)` grouping), so use plain single-predicate `find` and filter with `grep` — works whether RTK is in the loop or not. ```sh + # For E2E ("run the e2e suite"): find . -maxdepth 6 -name 'playwright.config.*' | grep -v node_modules + # For storybook / vitest browser tests: + find . -maxdepth 6 -name 'vitest.config.*' | grep -v node_modules ``` - - 0 matches → tell the user there's no e2e suite. Stop. - - 1 match → use it. - - 2+ matches → ask which one (a monorepo can ship one config per service, e.g. one for the React app, one for the marketing site). + - 0 `playwright.config.*` matches AND the user asked for E2E → tell them there's no Playwright E2E suite. Stop. + - 0 `vitest.config.*` matches with `test.browser.enabled = true` AND the user asked for storybook/vitest browser tests → tell them browser-mode isn't configured. Stop. + - 2+ matches of the same kind → ask which one (a monorepo can ship one config per service, e.g. one for the React app, one for the marketing site). + - Note: a plain unit-test `vitest.config.*` (no `test.browser.enabled`) doesn't drive Playwright — read the file to confirm browser mode is on before assuming. 2. **Read the config.** Note `testDir`, the entries in `projects[]` (with their `testMatch` / `testIgnore` patterns — they are usually regexes, not filename allowlists), and `webServer` (its `url` and `reuseExistingServer`). @@ -83,6 +91,7 @@ All tools are registered with the prefix `mcp__plugin_playwright_playwright__`. | `bun add @playwright/test` or any "reinstall Playwright" attempt | Already installed. Reinstalling risks an unintended version bump and violates the "no unauthorized installs" rule in the project's CLAUDE.md. | | Spawn your own `bun run dev` | The user keeps one running; Playwright's `webServer` reuses it (`reuseExistingServer: true`). A duplicate port-collides. | | Hardcode a port (`5173`, `5179`, etc.) in URLs, examples, or docs | Discover it per Step 3. Different projects use different env-var conventions (`APP_PORT`, `PORT`, `VITE_PORT`, …) and the user may override the default. | +| Run `npx playwright install` when a Vitest/Storybook test fails with `Executable doesn't exist at /home/node/.cache/ms-playwright/...` | The image deliberately ships system Chromium at `/usr/bin/chromium` and sets `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1`. The fix is to wire `launchOptions.executablePath` in `vitest.config.ts` (`provider: playwright({ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } })`), not to download a Playwright-managed binary. | | Call `mcp__plugin_playwright_playwright__browser_*` without `ToolSearch` first | Deferred tools — raw invocation returns `InputValidationError` because the parameter schema hasn't been loaded. | | Navigate to non-localhost, non-GitHub, non-npm URLs and expect them to work | The devcontainer firewall (see `.claude/rules/devcontainer.md`) blocks arbitrary external domains. Localhost is fine. | | Chain actions without re-observing | Page state is implicit. Re-`browser_snapshot` or `browser_wait_for` between interactions. | @@ -92,14 +101,22 @@ All tools are registered with the prefix `mcp__plugin_playwright_playwright__`. - "Playwright isn't available in this sandbox / devcontainer" - "I need to install Playwright first" +- "Let me run `npx playwright install`" (the bundled binary is deliberately not used; wire `launchOptions.executablePath` instead) - "Let me start a dev server on :" — you don't know the user's port - "I'll use `WebFetch` to check the rendered page instead" All mean: do the discovery in §"Discovery first", then follow the interactive or scripted workflow above. If the dev server isn't running, ask the user to start it — don't start one yourself. -## How the MCP plugin is wired here +## How Playwright is wired here -For maintainers reading this: the upstream devcontainer image (`gatezh/devcontainers` claude-code, both `default` and `sandbox` targets) ships system chromium at `/usr/bin/chromium` and sets `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` + `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium`. Playwright doesn't read that env var on its own — it's a project convention — so: +For maintainers reading this: the upstream devcontainer image (`gatezh/devcontainers` claude-code, both `default` and `sandbox` targets) ships system chromium at `/usr/bin/chromium` and sets: -- The project's `playwright.config.ts` files wire it via `launchOptions.executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` so `playwright test` (or whatever the project's test script wraps) finds the binary. -- `@playwright/mcp` defaults to the `chrome` channel (Google Chrome stable at `/opt/google/chrome/chrome`), which isn't installed. The bundled `.mcp.json` is rewritten by `/usr/local/bin/patch-playwright-mcp` (baked into the image) to launch with `--browser chromium --executable-path /usr/bin/chromium --no-sandbox --headless`. It runs from `init-plugins.sh` (postCreate) AND from `postStartCommand` so plugin auto-updates between sessions don't leave fresh cache dirs unpatched. See gatezh/devcontainers#85, #87. +- `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` — keeps `npm install` from downloading bundled browsers. +- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium` — project convention; Playwright doesn't read it automatically, so each consumer of `@playwright/test`, `@vitest/browser-playwright`, or direct `playwright-core` use must pass it explicitly through `launchOptions.executablePath`. +- `AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium` (default target only) — `agent-browser` is a Rust CLI with its own env-var family; without this it would auto-detect or attempt a Chrome-for-Testing download on first use. + +Beyond those env vars: + +- The project's `playwright.config.ts` files wire `launchOptions.executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` so `playwright test` finds the binary. +- The project's `vitest.config.ts` (if it uses `@vitest/browser-playwright`, which `@storybook/addon-vitest` does) wires `provider: playwright({ launchOptions: { executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH } })`. +- `@playwright/mcp` defaults to the `chrome` channel (Google Chrome stable at `/opt/google/chrome/chrome`), which isn't installed. The bundled `.mcp.json` is rewritten by `/usr/local/bin/patch-playwright-mcp` (baked into the image) to launch with `--browser chromium --executable-path /usr/bin/chromium --no-sandbox --headless`. It runs from `init-plugins.sh` (postCreate) AND from `postStartCommand` so plugin auto-updates between sessions don't leave fresh cache dirs unpatched. See gatezh/devcontainers#85, #87, #91. diff --git a/claude-code/.devcontainer/Dockerfile b/claude-code/.devcontainer/Dockerfile index 8336e39..54ea1ba 100644 --- a/claude-code/.devcontainer/Dockerfile +++ b/claude-code/.devcontainer/Dockerfile @@ -187,11 +187,16 @@ USER node # Avoids version coupling between @playwright/mcp (alpha playwright-core builds) # and cached browser binaries. Projects using @playwright/test must point their # playwright.config.ts at process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH. +# AGENT_BROWSER_EXECUTABLE_PATH points agent-browser at the same binary — +# agent-browser is a Rust CLI with its own env-var convention, NOT Playwright, +# so PLAYWRIGHT_* vars are silently ignored. Without this, agent-browser would +# auto-detect or attempt a Chrome-for-Testing download on first use. ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ - PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium + PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium \ + AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium # agent-browser: headless browser automation for AI agents. -# Uses the apt-installed chromium above. +# Uses the apt-installed chromium above (via AGENT_BROWSER_EXECUTABLE_PATH). # Installed as the node user (see claude-code install above for rationale). ARG AGENT_BROWSER_VERSION=latest RUN npm install -g agent-browser@${AGENT_BROWSER_VERSION} diff --git a/claude-code/README.md b/claude-code/README.md index b10e57b..51567b3 100644 --- a/claude-code/README.md +++ b/claude-code/README.md @@ -120,9 +120,9 @@ The sandbox firewall blocks vendor doc sites, so Claude Code can't `WebFetch` or Copy `.claude/skills/sandbox-fetch-docs/` into your project's `.claude/skills/` directory so Claude Code picks it up automatically. -### Optional: Claude Code skill for Playwright +### Recommended: Claude Code skill for Playwright -Both image variants ship system chromium and the `/usr/local/bin/patch-playwright-mcp` binary, so Playwright MCP and `@playwright/test` work out of the box once a project's `playwright.config.ts` wires `launchOptions.executablePath` (see [Playwright Strategy](#playwright-strategy)). The image includes a [sandbox-playwright](.claude/skills/sandbox-playwright/SKILL.md) skill that teaches Claude Code how to drive both paths — discovery-driven, with no hardcoded project ports, service names, or test layouts. +Both image variants ship system chromium and the `/usr/local/bin/patch-playwright-mcp` binary, so Playwright MCP and `@playwright/test` (and `@vitest/browser-playwright`, and direct `playwright-core` calls) work out of the box once each project entry point wires `launchOptions.executablePath` — see [Playwright Strategy](#playwright-strategy) for the full per-consumer wiring matrix. The image includes a [sandbox-playwright](.claude/skills/sandbox-playwright/SKILL.md) skill that teaches Claude Code how to drive these paths — discovery-driven, with no hardcoded project ports, service names, or test layouts. Copy `.claude/skills/sandbox-playwright/` into your project's `.claude/skills/` directory so Claude Code picks it up automatically. @@ -240,14 +240,27 @@ The Dockerfile sets: ```bash PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium +AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium # default target only ``` `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH` is a project convention — Playwright -does **not** read it automatically. Each project's `playwright.config.ts` -must honor it: +does **not** read it automatically. Every Playwright entry point in your +project must honor it. The image's job ends at "the env var points at the +binary"; consumer projects do the wiring. + +| Consumer | Where to wire | What to add | +|---|---|---| +| `@playwright/mcp` | nothing (image-baked) | `/usr/local/bin/patch-playwright-mcp` rewrites every cached `.mcp.json` to use the system chromium. See [Playwright MCP plugin](#playwright-mcp-plugin) below. | +| `@playwright/test` | `playwright.config.ts` | `use.launchOptions.executablePath` | +| `@vitest/browser-playwright` (incl. `@storybook/addon-vitest`, `@vitest/browser`) | `vitest.config.ts` | `playwright({ launchOptions: { executablePath } })` | +| Direct `playwright-core` / `playwright` use in scripts | every `chromium.launch(...)` site | `chromium.launch({ executablePath })` | +| `agent-browser` (default target only) | nothing | Image sets `AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/chromium`. Independent of the `PLAYWRIGHT_*` convention — agent-browser is a Rust CLI with its own env-var family. | + +### `@playwright/test` — `playwright.config.ts` ```ts -// playwright.config.ts +import { defineConfig } from "@playwright/test"; + export default defineConfig({ use: { launchOptions: { @@ -257,6 +270,63 @@ export default defineConfig({ }); ``` +In a monorepo, repeat this in every `playwright.config.ts` (one per service +that has a Playwright suite). The same env-var value works for all. + +### `@vitest/browser-playwright` — `vitest.config.ts` + +The provider's options match the Playwright `LaunchOptions` interface +verbatim under the `launchOptions` key (see the `PlaywrightProviderOptions` +interface in `@vitest/browser-playwright`): + +```ts +import { defineConfig, mergeConfig } from "vitest/config"; +import { playwright } from "@vitest/browser-playwright"; +import viteConfig from "./vite.config"; + +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + browser: { + enabled: true, + headless: true, + provider: playwright({ + launchOptions: { + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + }, + }), + instances: [{ browser: "chromium" }], + }, + }, + }), +); +``` + +For non-Vite projects, drop the `mergeConfig(viteConfig, …)` wrapper — +`defineConfig({ test: { browser: { … } } })` works directly. + +**Symptom when this is missing:** `Executable doesn't exist at +/home/node/.cache/ms-playwright/chromium_headless_shell-/chrome-linux/headless_shell` +followed by a "Please run `npx playwright install`" message. **Don't follow +that hint** — wire `launchOptions.executablePath` instead. Running `npx +playwright install` would re-download the bundled binary the image +deliberately avoids. + +### Direct `playwright-core` / `playwright` use + +For CI scripts, custom E2E that doesn't use `@playwright/test`, or codegen +sites that import `chromium` directly: + +```ts +import { chromium } from "playwright-core"; // or "playwright" + +const browser = await chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + headless: true, +}); +``` + ### Playwright MCP plugin The `playwright@claude-plugins-official` plugin's `@playwright/mcp` defaults