# Playwright E2e In Docker Sandbox

> Playwright e2e inside the Docker sandbox (credential-free, zero runtime egress)

- Skill: `okayus/playwright-e2e-in-docker-sandbox` (Agent Skill, multi-file: 2 files)
- Install (CLI): `npx skillmds@latest add okayus/playwright-e2e-in-docker-sandbox`
- Raw SKILL.md: https://api.skillmd.com/api/skills/okayus/playwright-e2e-in-docker-sandbox/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: okayus (https://skillmd.com/u/okayus)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/okayus/playwright-e2e-in-docker-sandbox

---


# Playwright e2e inside the Docker sandbox (credential-free, zero runtime egress)

If you develop in a network-isolated Docker sandbox (egress firewall, no host browser, no
Cloudflare login — the [`claude-code-docker-sandbox`](../claude-code-docker-sandbox/SKILL.md)
setup), a Playwright suite can still run **in-container with zero runtime egress**. The
enabling idea is that sandbox skill's core rule: **the firewall is the runtime
*entrypoint*, so anything whose network need is only at build time can be baked into the
image and never touches the runtime allowlist.** A browser is exactly that — bake it.

This skill assumes the e2e suite itself is already wired per
[`cloudflare-workers-e2e-playwright`](../cloudflare-workers-e2e-playwright/SKILL.md)
(build-artifact serving, `--persist-to`, auth seams, 3-spec scope). What lives here is
only the sandbox-specific layer: the bake, and the two traps that only bite when
`wrangler dev` runs **credential-free**.

## When to use this skill

- e2e must run inside the sandbox container: there's no host browser, and the egress
  firewall blocks the Playwright CDN, so a runtime `playwright install` can't work
- `pnpm e2e` in-container fails with `browser not found`
- A **credential-free** `wrangler dev` (sandbox, or a logged-out host) logs `Ready` and
  accepts TCP, but **every request hangs with no response** — Traps 1-2 below
- The request **after** an early-rejected POST (401/403 answered before the body was read)
  is a 500 `Network connection lost` and `wrangler dev` exits — not a sandbox trap: see
  Trap 3 of [`cloudflare-workers-e2e-playwright`](../cloudflare-workers-e2e-playwright/SKILL.md)
- You're deciding whether in-container e2e needs new egress-allowlist entries (it needs
  **zero** — that's the point)

Do **not** use for:
- Wiring the e2e suite itself (the CSP/HMR trap, `--persist-to`, WebAuthn/OAuth seams,
  test scope) — that's [`cloudflare-workers-e2e-playwright`](../cloudflare-workers-e2e-playwright/SKILL.md)
- Non-sandboxed environments — a plain `playwright install chromium` on the host is fine

## Deliverables (completion criteria)

- [ ] Browser **baked at image-build time** via the `INSTALL_PLAYWRIGHT` build arg — zero
      runtime egress, no `init-firewall.sh` change
- [ ] `ARG PLAYWRIGHT_VERSION` **exactly equals** `@playwright/test` in `package.json`,
      and both are bumped together (rebuild after)
- [ ] Chromium `--no-sandbox` gated on the `DEVCONTAINER` env marker — host runs keep the
      real browser sandbox
- [ ] The built e2e config **strips the rate-limit binding** (`unsafe` *and* `ratelimits` — the
      v4 key no longer hangs, see Trap 1, but stripping keeps e2e independent of the limiter)
- [ ] The `.dev.vars` copy that `@cloudflare/vite-plugin` 1.x writes into `dist/<worker>/` is
      neutralised — deleted by `prepare-config`, or every key overridden with `--var`
- [ ] `wrangler dev` binds `--ip 127.0.0.1` (not `localhost`), and `ORIGIN` + Playwright
      `baseURL` use the same literal host

## Bake the browser at image-build time

`docker compose build` runs **before** `init-firewall.sh` (the runtime entrypoint), so the
build has open network — fetch Chromium and its OS libs there. At runtime the CDN is
blocked, but nothing needs it: e2e drives a **local** `wrangler dev` on `127.0.0.1`, and
with the OAuth round-trip replaced by the seeded-session seam there is **no external
egress at all** during the run.

Opt in via two knobs (the full Dockerfile block is the canonical copy in
[references/in-container-playwright-bake.md](references/in-container-playwright-bake.md) —
don't duplicate it into your own notes; drift between copies is exactly how the version-pin
trap happens):

```yaml
# docker-compose.yml — then: docker compose down && docker compose build && docker compose up -d
build:
  args:
    INSTALL_PLAYWRIGHT: "true"   # adds ~0.4 GB to the image
```

Key rules the block implements (the "five details" in the reference):
`PLAYWRIGHT_BROWSERS_PATH=/ms-playwright` + `chmod a+rX` (root installs, `node` reads),
`apt-get update` before `--with-deps`, `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` at runtime,
and the **exact version pin in two places**: the runtime CDN is blocked, so an
`ARG PLAYWRIGHT_VERSION` ≠ `@playwright/test` drift cannot self-heal — it surfaces as
`browser not found` until you bump both and rebuild.

The pin is also a **compatibility** choice, not just a drift guard: `playwright@1.59.1`'s
`install chromium` **hangs forever right after the download completes on a `node:24`
base** (no error, no `#N DONE`; `node:22` is fine, `1.62.1` on `node:24` is fine —
verified 2026-08-22 in matatabetai). If the bake step sits at `100%` with no further
output, test the pin × base combination standalone before blaming the CDN:
`docker run --rm -e DEBUG=pw:install node:24 npx -y playwright@<ver> install chromium`.

**The variant both production users actually run** (matatabetai 2026-08-22, kokemusu
2026-09-02, node:24 / playwright 1.62.1, ~3 min build): the Chromium apt libraries as an
explicit list in the root apt layer (plus `fonts-noto-cjk` — a Japanese UI renders tofu in
failure screenshots without it), then `npx -y playwright@${PLAYWRIGHT_VERSION} install
chromium` **as `node`** after the Claude install, so the browser lands in
`/home/node/.cache/ms-playwright` (an image layer — never mount a volume there) and no
`PLAYWRIGHT_BROWSERS_PATH` / `chmod` dance is needed. The exact block is in the reference
next to the canonical one; both keep `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` and the pin.

Gate Chromium's own sandbox off **only in-container** (the container has `NET_ADMIN` but
not `SYS_ADMIN`, so the setuid sandbox can't initialize):

```typescript
// playwright.config.ts — the sandbox Dockerfile sets ENV DEVCONTAINER=true
launchOptions: process.env.DEVCONTAINER ? { args: ["--no-sandbox"] } : {},
```

This is the same build-time-vs-runtime-network pattern as the Rust/Haskell toolchains in
`claude-code-docker-sandbox`: compiler at build time = no allowlist entry; deps at runtime
= allowlist needed.

## Trap 1: the rate-limit binding hangs every local request

Symptom: `wrangler dev` logs `Ready` and the port accepts TCP, but **every request hangs**
with no response; the startup log says `connected to remote resource`.

Wrangler 3.x wires rate limiting via the `unsafe` binding form, which `wrangler dev` cannot
simulate: the dev-time proxy to the **remote** Cloudflare resource never completes its
handshake in a credential-free environment and blocks the whole pipeline. **The v4
top-level `ratelimits` key does not do this** — it is simulated locally (verified 2026-09-02
in kokemusu, wrangler 4.125.0, credential-free container, under both `vite dev` and
`wrangler dev --config dist/…`: a 40-request burst against a 30/60 s limiter answered
29×200 + 11×429, no hang, no `connected to remote resource` line). Fix for the 3.x form,
and still the recommendation for v4: **strip both keys** from the built config before
serving it for e2e — the limiter is fail-open, rate limiting is out of e2e scope (verify it
via [`cloudflare-workers-bot-scan-defense`](../cloudflare-workers-bot-scan-defense/SKILL.md)),
and a stripped binding means a burst of e2e requests can never 429 your own golden path:

```typescript
// e2e/prepare-config.ts — post-build, editing dist/<bundle>/wrangler.json
const cfg = JSON.parse(readFileSync(CONFIG, "utf8"));
delete cfg.unsafe;      // wrangler 3.x form (observed to hang credential-free)
delete cfg.ratelimits;  // wrangler 4.x form: simulated locally since ≤ 4.125 (verified
                        // 2026-09-02); stripped anyway so e2e never depends on the limiter
```

While you are in that file: `@cloudflare/vite-plugin` 1.x **copies `.dev.vars` into
`dist/<worker>/`** at build time, and wrangler reads `.dev.vars` from the directory of the
config it was given — so the developer's `DEV_CSP=1` / `ORIGIN=…:5273` silently override
whatever `vars` the config carries (verified 2026-09-02: relaxed CSP and 403s on every POST
until it was removed). Either `rmSync("dist/<worker>/.dev.vars")` here and write the e2e
values into `cfg.vars`, or pass every key with `--var` (CLI `--var` outranks the copy —
verified with `--var RP_ID:vartest` showing up in `login/begin`). The main skill's Trap 2
has the full write-up.

## Trap 2: bind `--ip 127.0.0.1`, not `localhost`

Symptom: `wrangler dev` is "Ready", TCP connects, but the Worker **never returns a byte** —
no CSP error, no SQLite error, just a stall. Bound to `localhost` (the `dev.ip` default),
routing stalls on IPv4/IPv6 resolution in the container (`localhost` → both `127.0.0.1`
and `::1`). Fix: use `127.0.0.1` literally **end to end** — the bind, `ORIGIN`, and
Playwright `baseURL` must all agree (a host/`ORIGIN` mismatch also 403s mutations via the
CSRF check):

```jsonc
"e2e:server": "… wrangler dev --config dist/<bundle>/wrangler.json --persist-to .wrangler/state --ip 127.0.0.1 --port 5399"
```

Also pin `dev.ip` in the built config (`prepare-config.ts`:
`cfg.dev = { ...cfg.dev, ip: "127.0.0.1" }`) so a stray flag-less `wrangler dev` stays
consistent. When e2e hangs at connect-but-no-response, these two traps are the suspects —
rule out the bind address first (cheaper to check).

## Scope boundary — what this skill does NOT cover

- The e2e suite wiring itself (build-artifact serving, `--persist-to` state trap,
  WebAuthn / OAuth seams, 3-spec scope) — [`cloudflare-workers-e2e-playwright`](../cloudflare-workers-e2e-playwright/SKILL.md)
- The sandbox environment itself (firewall, compose files, allowlist tuning) —
  [`claude-code-docker-sandbox`](../claude-code-docker-sandbox/SKILL.md)
- Verifying rate-limiting behavior (deliberately stripped here) —
  [`cloudflare-workers-bot-scan-defense`](../cloudflare-workers-bot-scan-defense/SKILL.md)

## References

- [references/in-container-playwright-bake.md](references/in-container-playwright-bake.md) —
  the canonical Dockerfile block, the five details that matter, bake verification, and
  both traps in full diagnostic detail

