End-to-end web app testing. Use when user says "test the app", "run e2e", "smoke test", "regression run", "check the login/onboarding/chat flow", "audit accessibility", "test responsive", or "find bugs in <url>" — even when Playwright is not named. Bootstraps Playwright + axe-core, runs LLM exploration on first run, deterministic replay afterward, emits markdown report + bugs.json + *.spec.ts files with run-diffing.
End-to-end web app testing. Use when user says "test the app", "run e2e", "smoke test", "regression run", "check the login/onboarding/chat flow", "audit accessibility", "test responsive", or "find bugs in <url>" — even when Playwright is not named. Bootstraps Playwright + axe-core, runs LLM exploration on first run, deterministic replay afterward, emits markdown report + bugs.json + *.spec.ts files with run-diffing.
trigger
/test-app
webtest-orch
End-to-end testing orchestrator for web applications. Splits into first-run exploratory (LLM-driven via Playwright MCP) and nth-run deterministic replay (npx playwright test, ~zero LLM tokens). Emits regression specs, normalized bugs.json, markdown + HTML report.
The problem: Claude Code has two independent context limits — text tokens (large)
and inline-image blocks (~50–100 per session). Screenshots returned inline burn
the image budget far faster than the text budget; once exhausted, the user must
/compact even at 20% text-context usage.
Distinction that matters:
❌ Inline image returns to parent context burn the budget. This includes
browser_take_screenshot default output (image returned to caller),
Read on a .png/.jpg/.webp/.gif/.bmp/.svg, markdown report with ![]() shown
to parent.
✅ On-disk artefacts that nobody Reads are FREE. Playwright's failure
screenshots go to test-results/, MCP browser tools may save .pngs to a
cache dir — none of these cost the parent context UNLESS you Read them.
The hard rule, enforced by you (not by frontmatter):
NEVER return screenshots to the parent skill context. ALWAYS dispatch a Task
subagent (general-purpose) for anything that produces or consumes images.
Subagent returns ONLY text — paths, descriptions, verdicts.
This contract was attempted via context: fork frontmatter but Claude Code 2.1.x on Windows does not honor that field, so enforcement is delegated to you reading these instructions. Verified empirically 2026-04-28 (sub-agent isolation works; context: fork does not parse). See ${CLAUDE_SKILL_DIR}/.isolation-verified.
Forbidden in this skill's parent context:
❌ Playwright:browser_take_screenshot (default returns image inline) — wrap it in a Task subagent
❌ Read on *.png/.jpg/.webp/.gif/.bmp/.svg from any path — Task subagent reads, summarizes
❌ Markdown reports with  shown to parent — only print absolute filesystem paths
❌ chrome-devtools:take_screenshot — same Task wrapper rule
Approved patterns:
PATTERN A — text-only browser exploration (default 90% of work)
Playwright:browser_navigate / browser_snapshot (ARIA tree → text)
Playwright:browser_evaluate (DOM scrape → JSON)
axe-core via spawned npx process → JSON violations
console / network listeners → JSON
→ ALL outputs are text. No image budget cost.
PATTERN B — vision genuinely required (max 3-5 times per run)
Task tool, subagent_type: "general-purpose", prompt:
"Read ONE image at <absolute path>. Output: <severity>: <symptom> in <selector> at <viewport>.
One line. No preamble. Do not return the image."
→ subagent burns its own image cap, parent stays clean.
PATTERN C — pixel-diff baseline (deterministic, scriptable)
Spec uses toHaveScreenshot() — Playwright reports diff% as TEXT in JSON output.
Diff > threshold → run Pattern B on the failed image only.
If you ever feel tempted to call browser_take_screenshot from this skill's parent context "just to check" — STOP. That single call costs the user a future /compact. Use browser_snapshot (ARIA tree) instead. If that's not enough, dispatch Pattern B.
If ${CLAUDE_SKILL_DIR}/.isolation-verified is missing, run Step 0 before any browser work.
Step 0 — Image isolation self-test (once per install)
"Read these 3 files with the Read tool and return one short text description per file: ${CLAUDE_SKILL_DIR}/fixtures/iso-test/a.png, ${CLAUDE_SKILL_DIR}/fixtures/iso-test/b.png, ${CLAUDE_SKILL_DIR}/fixtures/iso-test/c.png. Output 3 lines, no preamble."
Verify response is 3 lines of text (no inline images leaked back).
Run tests/auth.setup.ts once → playwright/.auth/user.json
PUBLIC FLOW:
playwright.config.public.ts.tmpl → playwright.config.ts (no setup, no storageState)
Skip auth.setup.ts and fixtures/. Specs import directly from @playwright/test.
Substitute __PROJECT_BASE_URL__ etc. from probe or .env.test
npm i -D @playwright/test @axe-core/playwright dotenv
npx playwright install chromium webkit
3. Scope. Decide what to test:
Specific URL passed by user → that route only
"test the app" → discover from sitemap/git diff HEAD~1 for changed routes
First run → minimal critical-path: home + auth + one main flow
4. Dev server up.python "${CLAUDE_SKILL_DIR}/scripts/with_server.py" --help. Use it; do not read its source unless --help doesn't cover the case.
5a. EXPLORATORY (BOOTSTRAP / new flow in HYBRID): use Playwright MCP with Playwright:browser_snapshot (ARIA tree, text). Walk the flow, generate POM in tests/pages/<Page>.ts, generate spec in tests/specs/<flow>.spec.ts. Generate locators from ARIA tree refs you actually saw — do NOT use generic regex like getByPlaceholder(/john doe|name|имя/i), they cause strict-mode violations on first run. Either use exact strings from the snapshot OR add .first() explicitly. Run the spec once to confirm green.
🔴 SPEC GENERATION CONTRACT — non-negotiable. Even if you skip the template
and write a spec from scratch (when product context is rich), every generated
*.spec.ts MUST contain ALL of these:
Console listeners attached BEFORE page.goto(): consoleErrors[] from
page.on('pageerror') and page.on('console', m => m.type() === 'error').
Network listeners attached BEFORE page.goto(): failedRequests[] from
page.on('response', r => r.status() >= 400 && ...) and page.on('requestfailed').
AxeBuilder scan with withTags(['wcag2a','wcag2aa','wcag21aa','wcag22aa'])
whose violations are pushed into issues[].
issues[] collector pattern — every soft check pushes a structured tag
into issues (e.g. a11y[serious] color-contrast: ..., heading-jump: ...,
touch-target: ..., overflow: ..., html-lang: ...).
Single hard expect(issues).toEqual([]) at the end with
${count} issues found:\n - ... message format so run_suite.py can split
one test failure into one bug record per issue.
Skip ANY of these and the skill's console-audit / a11y / per-issue fingerprinting
features stop working. Use templates/spec.ts.tmpl as the canonical reference —
copy its skeleton into hand-written specs.
5b. REPLAY: npx playwright test --reporter=list,json,html. No Playwright MCP, no LLM browser actions.
6. A11y on each visited page: deterministic @axe-core/playwright (in spec) + qualitative checks via nested subagent if alt-text/heading/focus suspect. See reference/a11y-patterns.md.
7. Console + network. Listeners attach BEFORE page.goto() (mandatory). Pipe captured logs through python "${CLAUDE_SKILL_DIR}/scripts/triage_console.py" --help.
8. Visual. Default toHaveScreenshot() in spec. Diff fired → python "${CLAUDE_SKILL_DIR}/scripts/visual_diff.py" --classify spawns nested subagent on each failed image (text verdict only). Argos opt-in via VISUAL_DIFF=argos.
10. Report.python "${CLAUDE_SKILL_DIR}/scripts/generate_report.py" --run-dir reports/<run-id> [--app-name "My App"]. Reads bugs.json + diff.json from that dir, writes report.md + index.html next to them. Print absolute path to index.html.
Decision tree
detect_state.py → JSON
├─ no tests/ and no config → BOOTSTRAP (full first run)
├─ tests/ + specs cover scope → REPLAY (npx playwright test)
└─ tests/ + new flow requested → HYBRID (replay existing + explore new)
MCP usage rules
State "using Playwright MCP" explicitly the first time you reach for browser tools in a session. Without this, Claude often shells npx playwright instead.
Always use fully-qualified names: Playwright:browser_navigate, Playwright:browser_snapshot, NOT bare browser_navigate.
Default to Playwright:browser_snapshot (ARIA tree → text). Use Playwright:browser_take_screenshot ONLY when:
Pixel-diff baseline establishment, AND
Output destination is filesystem (screenshot_path argument), NOT inline.
Use chrome-devtools:performance_start_trace ONLY for diagnostic flows (slow LCP, CORS, memory leak, sourcemap-resolved stacks). Don't use it for general exploration — its tokens are higher than Playwright MCP.
Anti-patterns — DO NOT GENERATE
❌ await page.waitForTimeout(2000) — use web-first assertions
❌ page.locator('div.btn-primary > span:nth-child(2)') — use getByRole
❌ UI login per test — use storageState fixture
❌ Reading source of black-box scripts before trying --help
❌ Embedding credentials in spec files — read from process.env (loaded via .env.test)
❌ Generating tests outside tests/specs/ — CI globs may miss them
❌ Returning screenshots inline to parent context — see Image budget protection
❌ expect.poll without timeout — flaky
❌ nth(0) / first() / last() without scoping via .filter()
❌ One-mega-test that does login + onboarding + checkout — split per flow