Skip to main content

playwright-e2e-patterns

Playwright E2E patterns — devcontainer setup (fonts, system libs), selector pitfalls, API-based testing

Zur Installation springen

Quellinformationen

Repository
blas1n/claude-skills
Letzte Quellaktivität
3. September 2026 um 04:04
Erkannte Sprache von SKILL.md
Englisch
Sterne
2
Forks
0

Installationsoptionen

Standardmäßig ist der Prompt ausgewählt, der zuerst die Quelle prüft. Sie können zu einem direkten Befehl wechseln oder eine lokale Kopie herunterladen.

Quelldateien prüfen

Lesen Sie SKILL.md und alle von SkillsMP angezeigten Begleitdateien, bevor Sie sich für eine Installation entscheiden.

SKILL.md wird angezeigt

SKILL.md
Quellanweisungen · Schreibgeschützte Vorschau
name
playwright-e2e-patterns
description
Playwright E2E patterns — devcontainer setup (fonts, system libs), selector pitfalls, API-based testing
version
1.0.0
triggers
[{"pattern":"writing Playwright E2E tests, elements report hidden in Docker, or strict mode selector violations"}]
category
test
# Playwright E2E Patterns ## 1. Devcontainer Environment Setup ### Missing Fonts = All Text "Hidden" Minimal Docker images have zero fonts. Chromium can't render text → zero dimensions → `toBeVisible()` fails on ALL text. **Diagnosis**: `fc-list` returns empty. **Fix (Dockerfile)**: ```dockerfile RUN apt-get update && apt-get install -y --no-install-recommends \ fonts-dejavu-core fontconfig && rm -rf /var/lib/apt/lists/* ``` **Fix (no root)**: Install via micromamba: ```bash export MAMBA_ROOT_PREFIX=$HOME/.mamba micromamba create -n pw-deps -c conda-forge -y fonts-conda-forge font-ttf-dejavu-sans-mono fontconfig mkdir -p ~/.fonts && ln -sf $MAMBA_ROOT_PREFIX/envs/pw-deps/fonts/* ~/.fonts/ ``` Set in `playwright.config.ts`: ```typescript use: { launchOptions: { env: { ...process.env, FONTCONFIG_PATH: resolve(homedir(), '.config/fontconfig'), FONTCONFIG_FILE: resolve(homedir(), '.config/fontconfig/fonts.conf'), }, }, }, ``` ### Missing System Libraries Chromium needs `libnss3`, `libgbm`, etc. For libs not in conda-forge, create minimal stub `.so` files. --- ## 1b. Auth Mock with Custom Auth SDKs ### Problem: `addInitScript` sets wrong localStorage key Custom auth SDKs (e.g., BSVibeAuth) use their own localStorage keys — NOT the ones you'd guess. **Symptom**: Page redirects to external auth server (e.g., `auth.bsvibe.dev/api/silent-check`) despite mock setup. Screenshot shows auth error JSON instead of app UI. **Root cause**: `injectAuth` stored tokens under app-specific keys (`bsnexus_access_token`), but the auth SDK reads from its own key (`bsvibe_user`). **Fix**: Read the SDK source to find the exact key and expected shape. ```typescript // ❌ Wrong — guessing the key name localStorage.setItem('myapp_access_token', 'mock-token') // ✅ Right — match the SDK's actual storage key + full object shape const bsvibeUser = { id: 'user-001', email: 'dev@test.dev', tenantId: 'tenant-001', role: 'authenticated', accessToken: 'mock-token', refreshToken: 'mock-refresh', expiresAt: Math.floor(Date.now() / 1000) + 3600, } localStorage.setItem('bsvibe_user', JSON.stringify(bsvibeUser)) ``` **Diagnosis**: Add `page.on('pageerror')` and `page.on('console')` to a debug test. If the page is blank, check for auth redirects in the call log (`navigated to "https://auth..."` in Playwright output). --- ## 1c. `page.route` Glob vs Query Parameters ### Problem: Mock route doesn't match requests with query params `page.route('**/api/v1/agents', ...)` does **NOT** match `/api/v1/agents?active_only=true`. **Symptom**: Catch-all route returns `{}` (empty object) → `response.filter is not a function` → React error boundary → blank page. **Fix**: Register both with-query and without-query variants: ```typescript // ❌ Only matches bare path await page.route('**/api/v1/agents', handler) // ✅ Also matches with query params await page.route('**/api/v1/agents?*', handler) // with query await page.route('**/api/v1/agents', handler) // without query ``` **Key rule**: Always check if your API client adds query params (e.g., `axios.get(url, { params })`) and register both route variants. --- ## 1d. `page.route` Ordering — Specific Before Generic ### Problem: Nested RESTful routes shadow each other `/api/incidents/:id` (GET detail) and `/api/incidents/:id/resolve` (POST action) share the same base pattern. Registering in the wrong order makes the more specific route unreachable: ```typescript // ❌ Wrong order — /resolve matches /api/incidents/* first, returns detail JSON for POST await page.route('**/api/incidents/*', handler) // catches everything await page.route('**/api/incidents/*/resolve', handler) // never fires ``` **Fix**: Register more specific routes FIRST. For the generic catch, use `route.fallback()` when the URL doesn't apply: ```typescript // ✅ Specific first await page.route('**/api/incidents/*/resolve', (route) => route.fulfill({ json: { id: 'inc-1', status: 'resolved' } }) ) await page.route('**/api/incidents/*', (route) => { // Defensive: if a /resolve URL somehow reaches here, let it fall through if (route.request().url().includes('/resolve')) return route.fallback() return route.fulfill({ json: mockIncidentDetail }) }) ``` ### waitForResponse with method/URL filter When the same URL pattern is hit with different methods (GET detail + POST resolve), use a callback filter instead of glob: ```typescript // ❌ Ambiguous — matches both GET and POST await page.waitForResponse('**/api/incidents/*') // ✅ Filter by method await page.waitForResponse((resp) => resp.url().includes('/api/incidents/') && resp.request().method() === 'GET' ) ``` --- ## 1e. Layout Page Title Strict Mode Violation ### Problem: Page title text appears in both Layout header and page content Common SPA pattern: Layout component renders the page title in its header (h2), and the page component also shows the same title as a heading (h3). Playwright strict mode fails: ``` strict mode violation: getByText('Incident Timeline') resolved to 2 elements: 1) <h2 class="...">Incident Timeline</h2> (Layout) 2) <h3 class="...">Incident Timeline</h3> (Page content) ``` **Fix options**: ```typescript // Option 1: .first() if any visible element proves the assertion await expect(page.getByText('Incident Timeline').first()).toBeVisible() // Option 2: getByRole with level for specificity await expect( page.getByRole('heading', { name: 'Incident Timeline', level: 3 }) ).toBeVisible() // Option 3: scope to the content area await expect(page.locator('main').getByText('Incident Timeline')).toBeVisible() ``` --- ## 2. Selector Pitfalls ### Substring matching (default!) ```typescript // ❌ text=P1 matches "P1" AND "P100" await page.locator('text=P1').click(); // ✅ Exact match await page.getByText('P1', { exact: true }).click(); ``` ### `/` prefix = regex ```typescript // ❌ Interpreted as regex await page.locator('text=/api/v1/chat').click(); // ✅ Use container selector await page.locator('code:has-text("chat/completions")').click(); ``` ### Stateful button (onBlur race) ```typescript // ❌ Re-locating by text causes focus shift → onBlur resets state await page.click('button:has-text("Delete")'); await page.click('button:has-text("Confirm?")'); // ✅ Structural locator (same DOM position) const btn = row.locator('button'); await btn.click(); // Delete → Confirm? await btn.click(); // Confirm? → executes ``` ### Selector Priority 1. `getByText(x, { exact: true })` — unique visible text 2. CSS scoping — `.font-medium:has-text("x")` 3. Structural — `page.locator('.divide-y > div').nth(0).locator('button')` 4. `.first()` — last resort ### Label text bakes a LANGUAGE assumption into the harness If the app picks its locale at runtime (next-intl from `Accept-Language`, a cookie, a user setting), a label-text selector silently makes the helper single-language. ```ts // login helper — worked for months, English only await page.getByLabel(/email/i).fill(email); await page.getByRole("button", { name: /continue$/i }).click(); ``` Add one test with `test.use({ locale: "ko-KR" })` and it dies **inside the helper** with a 30s timeout — which reads as *"Korean onboarding is broken"* when the product is fine and the harness is not. (BSVibe 2026-09-03.) Use the form's own ids / structural attributes, which no translation touches: ```ts await page.locator("#email").fill(email); await page.locator('form button[type="submit"]').click(); ``` Still types into the real form and presses the real button — the no-bypass property is unchanged. Reserve label/role-name selectors for tests whose proposition IS the copy. ### An assertion that races a redirect is green by luck ```ts await link.click(); await expect(page).toHaveURL(/\/settings$/); // passed once, failed next run ``` `/settings` server-redirects to `/settings/general`. The assertion caught the pre-redirect URL on one run and the post-redirect URL on the next. Same code, same build — the only difference was timing. Two lessons, and the second is the valuable one: 1. **One green does not mean the assertion is right.** A URL assertion on a route that redirects is inherently racy; assert the *destination*. 2. **The flake was pointing at a real defect.** Restating the proposition as the destination (`/settings/models` + the workers section visible) is what revealed that the link went one tab short of the surface it promised. The over-loose assertion had been *hiding* the bug, not just being flaky. Rule: when an assertion targets a URL, assert where the user ENDS UP, and pair it with something only that page renders. --- ## 3. API-Based E2E (Devcontainer-Friendly) When browser tests aren't feasible, use API client pattern: | Aspect | Browser | API | |--------|---------|-----| | System deps | 12+ GUI libs | None | | Speed | 2-5s/test | 100-500ms/test | | Stability | Brittle selectors | Stable contracts | ```typescript export class APIClient { static async createProject(data: CreateProjectRequest) { const response = await fetch(`${BASE_URL}/projects`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(data), }); if (!response.ok) throw new Error(await response.text()); return response.json(); } } ``` **Tips**: - Read Pydantic schemas for ALL required fields (don't trust endpoint docs) - Test business logic prerequisites (e.g., activate phase before creating tasks) - Trace router prefixes from source code --- ## 4. Live E2E: Mock Auth Token + Real Backend Auth Cascade ### Problem: Mock token triggers signOut cascade When live E2E tests inject a mock auth token into localStorage but let API calls hit the real backend, **any authenticated endpoint returns 401** → axios response interceptor calls `signOut()` → ProtectedRoute sees `accessToken = null` → redirects to Landing. **Symptom**: Page shows Landing page or "Sign in to continue" instead of the expected protected page. Confusing because auth injection appears to work (user profile shows in sidebar on first render, then vanishes). **Root cause chain**: ``` Mock token in localStorage → App mounts, reads token, sets accessToken in store ✓ → App fetches /api/v1/settings (auth-guarded endpoint) → Real backend rejects mock token → 401 → Axios interceptor: if (401) { signOut() } → signOut() clears accessToken from store → ProtectedRoute: if (!accessToken) redirect to "/" ``` **Fix**: Mock ALL globally-fetched authenticated endpoints, not just `auth/me`: ```typescript // ❌ Only mocking auth/me — other auth-guarded endpoints still hit real backend await page.route('**/api/v1/auth/me', handler) // ✅ Mock every endpoint the app calls on mount that requires auth await page.route('**/api/v1/auth/me', handler) await page.route('**/api/v1/settings', handler) // auth guard: admin_settings await page.route('**/api/v1/dashboard/**', handler) // may have auth await page.route('**/api/v1/projects', handler) // GET list on dashboard ``` **How to find which endpoints need mocking**: 1. Check which API calls fire on page mount (React useEffect, useQuery) 2. For each: `curl -s http://localhost:8000/api/v1/<endpoint>` — if 401, must mock 3. Check for `Depends(get_current_user)` or `Depends(require_permission(...))` in FastAPI router **Key insight**: `page.route()` only intercepts browser requests. `page.request.get()` (Playwright API client) bypasses route interception — don't use it for auth-guarded endpoints in live tests. **Live E2E helper pattern** (proven working): ```typescript export async function setupLiveAuth(page: Page) { await injectAuth(page) // localStorage tokens await page.route('**/api/v1/auth/me', mockHandler) await page.route('**/api/v1/settings', mockHandler) await page.route('**/api/v1/dashboard/**', mockHandler)
Auf GitHub ansehen
Diese SKILL.md ist sehr gross, daher zeigt SkillsMP hier nur den ersten Abschnitt. Auf GitHub ansehen