|
| 1 | +--- |
| 2 | +description: Requirement-driven e2e test writing — no defensive code |
| 3 | +globs: apps/**/*e2e*/**/*.ts |
| 4 | +alwaysApply: false |
| 5 | +--- |
| 6 | + |
| 7 | +# E2E Testing Standards |
| 8 | + |
| 9 | +Write tests that reflect exact user requirements. A failing test means a real bug — never hide it. |
| 10 | + |
| 11 | +## Reference elements by the label the user sees in the UI |
| 12 | + |
| 13 | +- GOOD: `getByRole('heading', { name: 'Create Custom Journey' })` |
| 14 | +- GOOD: `getByRole('link', { name: 'Analytics' })` |
| 15 | +- GOOD: `getByPlaceholder('Paste a YouTube link...')` |
| 16 | +- BAD: `locator('div[data-testid="JourneysAdminContainedIconButton"]')` |
| 17 | +- BAD: `locator('.MuiCardActionArea-root')` |
| 18 | + |
| 19 | +## Assert exactly what the user should see — no fallbacks |
| 20 | + |
| 21 | +If a required element is not visible, the test must FAIL and surface the bug. |
| 22 | + |
| 23 | +- GOOD: `await expect(page.getByRole('heading', { name: 'Create Custom Journey' })).toBeVisible()` |
| 24 | +- BAD: check visibility first and only assert if it happens to be present |
| 25 | +- BAD: loop over multiple fallback CSS selectors |
| 26 | +- BAD: `try/catch` that swallows an assertion failure |
| 27 | + |
| 28 | +## Always use soft waits — never hard waits |
| 29 | + |
| 30 | +ALL waiting must go through Playwright's built-in auto-waiting. There are zero exceptions in test or page-object code. |
| 31 | + |
| 32 | +Hard waits are banned because they pause unconditionally — they make tests slow on fast machines and still flaky on slow ones. Soft waits retry until the condition is true or the timeout expires. |
| 33 | + |
| 34 | +- GOOD: `await expect(button).toBeEnabled()` — retries until enabled |
| 35 | +- GOOD: `await expect(dialog).toBeVisible()` — retries until visible |
| 36 | +- GOOD: `await expect(locator).toBeHidden()` — retries until gone |
| 37 | +- GOOD: `await page.goto(url)` — Playwright waits for navigation internally |
| 38 | +- BAD: `await page.waitForTimeout(2000)` — unconditional pause |
| 39 | +- BAD: `await new Promise(resolve => setTimeout(resolve, 1000))` — unconditional pause |
| 40 | +- BAD: `sleep(n)` in any form |
| 41 | +- BAD: polling loops with `setTimeout` inside tests or page objects |
| 42 | + |
| 43 | +## Wait timeouts must be short, justified, and commented |
| 44 | + |
| 45 | +The default timeout (30s) is enough for stable app states. Only exceed it for known slow operations (cold Vercel SSR, Apollo queries on first load, multi-step server chains). Hard limit is 90s — never set a timeout above 90000ms. |
| 46 | + |
| 47 | +When a timeout exceeds the default, ALWAYS add an inline comment explaining why: |
| 48 | + |
| 49 | +```typescript |
| 50 | +// 90s: cold Vercel SSR + TeamProvider Apollo query can take >65s on first run |
| 51 | +await expect(createButton).toBeEnabled({ timeout: 90000 }) |
| 52 | +``` |
| 53 | + |
| 54 | +- BAD: `{ timeout: 150000 }` with no comment |
| 55 | +- BAD: `{ timeout: 60000 }` as the only value tried when it keeps failing — raise it to 90s and comment |
| 56 | +- GOOD: `{ timeout: 30000 }` (default, no comment needed) |
| 57 | +- GOOD: `{ timeout: 90000 }` with an inline comment explaining the slow operation |
| 58 | + |
| 59 | +## Scope selectors to the user's visual context |
| 60 | + |
| 61 | +Use the narrowest container that reflects what the user sees. If strict mode fires (multiple matches), that is a real data/state problem — fix the root cause, don't add `.first()`. |
| 62 | + |
| 63 | +- GOOD: `page.locator('div[aria-label="journey-card"]', { hasText: journeyName }).getByRole('link', { name: 'Analytics' })` |
| 64 | +- BAD: `page.locator('div[data-testid="AnalyticsItem"] a').first()` |
0 commit comments