Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/build-claude-code.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
39 changes: 28 additions & 11 deletions claude-code/.claude/skills/sandbox-playwright/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`).

Expand Down Expand Up @@ -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. |
Expand All @@ -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 :<some-port>" — 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.
9 changes: 7 additions & 2 deletions claude-code/.devcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
80 changes: 75 additions & 5 deletions claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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: {
Expand All @@ -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-<rev>/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
Expand Down