---
name: md-verify
description: Playwright testing on the live dev.multi.deal instance — auth, selectors, patterns, and hard-won lessons
---

# Multideal Playwright Verification

Use for E2E/verification testing against `https://dev.multi.deal`.

---

## Config — live runs gated behind ALLOW_LIVE_E2E=1

Live runs hit `dev.multi.deal` and consume the 100k/day Worker request budget. The config refuses to load unless `ALLOW_LIVE_E2E=1` is set.

**Default to localhost** — `pnpm test:e2e` runs vs `localhost:4321`, no budget cost. Use it for any spec that doesn't strictly need the deployed worker (most flows: routing, UI, a11y, schema, query, RTL, i18n).

**Live is only for**: post-deploy smoke of a deployed-only behavior, real SUMIT tokenization round-trip, Firebase phone-auth OTP, CSP/headers from the live worker. Anything else → run vs localhost.

```bash
# From apps/web/

# Localhost (default — preferred)
pnpm test:e2e
pnpm exec playwright test tests/e2e/<test>.spec.ts

# Live (only when localhost cannot reproduce — explicit opt-in)
ALLOW_LIVE_E2E=1 pnpm exec playwright test tests/e2e/<test>.spec.ts --config=playwright.live.config.ts
```

Without `ALLOW_LIVE_E2E=1` the live config exits 1 with a usage message — guard against accidental live sweeps that burn budget.

For specs that hardcode `https://dev.multi.deal` URLs (bypass `baseURL`), add `import './_live-guard.mjs';` at the top so the guard fires even when the default config is used.

`playwright.live.config.ts` at `apps/web/playwright.live.config.ts`. Points `baseURL` to `https://dev.multi.deal`, no webServer block.

**Always run headless** (no `--headed`). No browser windows.

---

## Probing — when 20× and when single

Probing rules — they protect the 100k/day Worker request budget. 20× probes with `?cb=$RANDOM` are expensive: 78 commits × 20 probes = 1,560 hits/day from this rule alone (contributor to 2026-05-16 hitting 75% of daily limit).

**Single `curl -sI` is enough** for:
- Post-deploy smoke — confirm worker is alive.
- UI tweak / copy change / perf refactor / route rename verification.
- Routes that passed 20× recently.
- "Just to be safe" after single 200 with no 5xx report — skip the extra probes.

**20× with cache-buster is required** only in these 4 cases:

1. **A 5xx report exists.** QAI sweep, user bug, monitoring alert claims a public route returns 500/502/503/1102. Run 20× before moving the card to REJECTED, even when single `curl -sI` returns 200. (Original incident — 2026-05-15 false-positive REJECTED on /home + /search + /deals/all + /deal/{UUID}; second sweep proved 50% real failure rate, 4 cards had to be pulled back from REJECTED.)
2. **Verifying a fix for an intermittent 5xx.** Just deployed a Bundled-CPU coalesce / middleware-floor / edge-cache patch. Single 200 = warm-cache lying. Confirm fix held under cold cache before declaring resolved.
3. **Investigating a fresh perf regression.** P95 latency alert or 1102 spike — 20× shows real failure rate, not a happy-path sample.
4. **Before closing an outage as RESOLVED.** Probe with `?cb=$RANDOM` to bypass edge cache, confirm the worker itself recovered (not just CF caching a stale 200).

**Heuristic:** if you cannot point at a specific 5xx report, perf signal, or open outage — single probe or none. Do not run 20× speculatively.

**The command:**
```bash
URL="https://dev.multi.deal/PATH"
for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code} " "${URL}?cb=$RANDOM"; done; echo
```

- `?cb=$RANDOM` is critical — defeats edge cache so each request actually exercises origin
- 20 samples = minimum to distinguish "intermittent 30-70%" from "warm-cache 100% pass"
- Single sample = useless for 5xx triage, single sample is fine for liveness smoke

Related: `feedback_1102_root_cause` in auto-memory documents the underlying Bundled-tier CPU ceiling that made the failures intermittent in the first place.

---

## Test file location

Tests go in `apps/web/tests/e2e/`. Extension must be `.spec.ts` (not `.ts` — Playwright no find).

Before new test, check `apps/web/tests/e2e/` for existing one covering same area.

---

## Auth — session injection (preferred)

**NEVER use `page.request.post` to call `/api/auth/login-email` from Playwright test.** Cloudflare WAF blocks after few requests, returns `error code: 1102` (HTML, not JSON). Test fails at `loginRes.json()` with `SyntaxError: Unexpected token '<'`.

**Correct approach:** Create session directly in DB, inject cookie.

### Step 1 — generate session ID + signed cookie

```js
node -e "
const SESSION_SECRET = '<value from .env>';
const userId = '<admin user id>';

function base64urlEncode(buf) {
  const uint8 = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
  let binary = '';
  for (const b of uint8) binary += String.fromCharCode(b);
  return btoa(binary).replace(/\+/g,'-').replace(/\//g,'_').replace(/=/g,'');
}

async function main() {
  const sessionId = crypto.randomUUID();
  const csrfToken = crypto.randomUUID();
  const expiresAt = new Date(Date.now() + 30*24*60*60*1000).toISOString();
  const key = await crypto.subtle.importKey('raw', new TextEncoder().encode(SESSION_SECRET),
    {name:'HMAC',hash:'SHA-256'}, false, ['sign']);
  const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(sessionId));
  const cookieValue = base64urlEncode(new TextEncoder().encode(sessionId)) + '.' + base64urlEncode(sig);
  console.log(JSON.stringify({sessionId, csrfToken, expiresAt, cookieValue}));
}
main();
"
```

Values from:
- `SESSION_SECRET` — in `/home/user/Projects/multideal/.env`
- `userId` — admin user ID from DB (query below)
- `PII_KEY` — in `.env`, used for email blind-index computation

### Step 2 — insert into Neon DB

Use `mcp__Neon__run_sql` with project ID `bitter-wildflower-27502830`:
```sql
INSERT INTO sessions (id, user_id, csrf_token, user_agent, ip_encrypted, expires_at)
VALUES ('<sessionId>', '<userId>', '<csrfToken>', 'playwright-test', '127.0.0.1', '<expiresAt>')
RETURNING id;
```

### Step 3 — inject cookie in test

```ts
test('...', async ({ page, context }) => {
  await context.addCookies([{
    name: 'multideal_session',
    value: '<cookieValue>',
    domain: 'dev.multi.deal',
    path: '/',
    httpOnly: true,
    secure: true,
    sameSite: 'Lax',
  }]);
  // navigate directly — already logged in
  await page.goto('https://dev.multi.deal/admin/...', { waitUntil: 'networkidle' });
});
```

---

## Admin user facts

- Email: `admin@example.dev`
- Password: `OQPDnd9QCK6K-2DB@Multideal1` (from `Docs/logins.dev`)
- User ID: `713b5813-8fa4-4b95-9836-8a8c2df72008`
- Neon project: `bitter-wildflower-27502830`

**Check admin user state:**
```sql
SELECT id, email_index IS NOT NULL AS has_email, password_hash IS NOT NULL AS has_password,
       is_admin, account_state FROM users WHERE is_admin = true;
```

**Email stored as HMAC-SHA256 blind index** using `PII_KEY`. Compute:
```js
node -e "
async function blindIndex(value, key) {
  const enc = new TextEncoder();
  const k = await crypto.subtle.importKey('raw', enc.encode(key), {name:'HMAC',hash:'SHA-256'}, false, ['sign']);
  const sig = await crypto.subtle.sign('HMAC', k, enc.encode(value));
  return Array.from(new Uint8Array(sig)).map(b=>b.toString(16).padStart(2,'0')).join('');
}
blindIndex('admin@example.dev', '<PII_KEY>').then(console.log);
"
```

**If password login fails** (INVALID_CREDENTIALS), stored hash may not match. Reset:
```js
// hash new password
node -e "
async function hashPassword(password) {
  const salt = crypto.getRandomValues(new Uint8Array(16)).buffer;
  const toHex = buf => Array.from(new Uint8Array(buf)).map(b=>b.toString(16).padStart(2,'0')).join('');
  const km = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveBits']);
  const derived = await crypto.subtle.deriveBits({name:'PBKDF2',salt,iterations:100000,hash:'SHA-256'}, km, 256);
  return 'pbkdf2:' + toHex(salt) + ':' + toHex(derived);
}
hashPassword('OQPDnd9QCK6K-2DB@Multideal1').then(console.log);
"
# Then UPDATE users SET password_hash = '<hash>' WHERE id = '713b5813-8fa4-4b95-9836-8a8c2df72008';
```

---

## Selectors — page organizer

### Canvas vs palette — CRITICAL

Both canvas module cards and palette buttons use `data-module-type="<type>"`. **Palette button found first** if page-wide search. Always scope to canvas section:

```ts
// WRONG — matches palette button (just has raw type text as innerHTML)
const card = page.locator('[data-module-type="rich-text-content"]').first();

// CORRECT — scope to canvas section
const canvas = page.locator('section[aria-label="עמוד פעיל"]');
const card = canvas.locator('[data-module-type="rich-text-content"]').first();
```

Canvas section has `aria-label="עמוד פעיל"` (from `page_organizer.active_heading` i18n).

### Clicking the ✎ edit button on a module card

Edit button inside `data-module-type` div. Has `aria-label="<label> — הגדרות מודול"`. Find via:
```ts
const editBtn = richTextCard.locator('button').first();
await expect(editBtn).toBeVisible({ timeout: 5_000 }); // waits for React hydration
await editBtn.click();
```

### Pages with rich-text-content modules

`home` page has no rich-text-content. Use `about`, `faq`, or `legal/privacy`:
```
/admin/page-organizer?page=about   ← has rich-text-content module
/admin/page-organizer?page=faq     ← has rich-text-content module
```

---

## Nested modal (Dialog inside Drawer)

Rich-text-content ConfigEditor opens `<Dialog>` inside ConfigDrawer. `DialogContent` has `data-nested-modal="true"`.

### What works

```ts
const nestedDialog = page.locator('[data-nested-modal="true"]');
await expect(nestedDialog).toBeVisible({ timeout: 5_000 });
// verify drawer still open
await expect(drawer).toBeVisible({ timeout: 2_000 });
// Escape closes dialog, not drawer
await page.keyboard.press('Escape');
await expect(nestedDialog).not.toBeVisible({ timeout: 3_000 });
await expect(drawer).toBeVisible({ timeout: 2_000 });
```

### What does NOT work

- `nestedDialog.click({ position: { x: 100, y: 100 } })` — `<html>` intercepts (modal backdrop overlay)
- `nestedDialog.click({ force: true })` — closes dialog (synthetic click triggers `onInteractOutside` or Radix close logic)
- Clicking dialog heading with `page.getByRole('heading', ...)` — also intercepted

**No try click inside nested dialog in tests.** Verify visible, test Escape behavior only.

---

## Drawer selector

Drawer is Radix Dialog. Select via:
```ts
const drawer = page.locator('[role="dialog"]').first();
```

Note: when nested Dialog also open, two elements with `role="dialog"`. Drawer is first — opens before nested dialog.

---

## Rate limiting

Cloudflare WAF blocks IPs making too many rapid POST requests to `/api/auth/*`. After limit hit, all requests return `error code: 1102` (HTML body). Lasts ~1 minute. App-level rate limit: 10 requests/minute to `login-email`.

**Avoid** calling login endpoint from Playwright tests. Use session injection instead.

---

## Running the full verification test

```bash
cd apps/web
pnpm exec playwright test tests/e2e/verify-rich-text-dialog.spec.ts --config=playwright.live.config.ts
```

Pre-seeded session cookie in test file expires 2026-05-20. If tests fail with auth errors after that date, generate new session via steps above.

---

## Learned Rules

### otp-bypass-dev-only | fired:1 | 2026-04-21
OTP bypass `000000` only works when `ENVIRONMENT=development`. Live worker rejects it → auth fails. Never use in E2E vs live.

### no-psql-use-neon-mcp | fired:1 | 2026-04-21
`psql: command not found` in sandbox. `psql $DATABASE_URL` in Monitor loops/bash always fails.
Prevent: query Neon via `mcp__Neon__run_sql` (project `bitter-wildflower-27502830`). For polling loops, use Bash+Neon MCP, not psql.

### test-helper-signature-grep-callsites | fired:1 | 2026-05-10
Renamed `injectSessionCookie(ctx, cookieValue: string, baseUrl)` → `(ctx, session: CreatedSession, baseUrl)` in vendor harness. 13 call sites in admin/customer fixtures + vendor api specs still passed `session.cookieValue` — TypeError on first rerun, 8 hard E2E fails masquerading as JWT regression.
Prevent: ANY edit to a `tests/e2e/*/harness/auth.fixture.ts` exported function signature → first run `grep -rn '<helperName>' tests/ --include="*.ts"` and audit each call site. Mass-update via sed BEFORE running tests. Test harness is gitignored so TS may not catch all invocations during typecheck.

### cold-pop-timeout-sizing | fired:1 | 2026-05-10
3 iterative bumps in one session for cold CF Worker pop variance: redirect-after-auth 5s→15s→25s, invalid-email validation 500ms→2s→5s, codeInput visibility 10s→25s. Each retest cycle was a separate rebuild + deploy + rerun.
Prevent: any Playwright `expect.toBeVisible/toBeFocused/waitForURL` on an element that depends on (a) React island hydration, (b) cross-page navigation, OR (c) post-login redirect → default `timeout: 25_000`. Never start at <2s for hydration-dependent assertions. Bump once to 25s, not in 5s increments. Sub-second timeouts only for pure DOM attr checks where no async render path is involved.

### orchestrator-delegates-test-runs | fired:87 | 2026-05-10
User directive: playwright test runs dispatch to **gpt-5.4-mini/low** subagent using /ask-codex — run test + report only, nothing else.
Prevent: NEVER run `pnpm exec playwright test` on main thread. ALWAYS dispatch via cdx gpt-5.4-mini

Agent prompt must be minimal — only:
1. The exact `pnpm exec playwright test <file> --config=playwright.live.config.ts` command to run (from `apps/web/`)
2. Use `mcp__plugin_context-mode_context-mode__ctx_execute` (shell) to run it — never bare Bash (large output)
3. RTK proxy rewrites pnpm/git automatically — no need to prefix, just run normally
4. "Report: pass/fail per test, full error + stack if any fail. Nothing else."



Orchestrator reads summary, does NOT re-run or debug inline.

### check-existing-e2e-bypass-before-building | fired:1 | 2026-05-10
Built a full Firebase E2E bypass (server idToken pattern + client window flag + test fixture) without first checking what was already wired. User had to correct: "we have test login with firebase with one number only 055-5555555 and this is why we use email/password combinations for logging in for dev instance" — alternative path already worked, bypass was overkill.

### spec-import-path-relative-to-file-location | fired:1 | 2026-05-28
`console-audit.spec.ts` at `tests/e2e/rma/` imported `'./rma/_console'` → resolved to `rma/rma/_console` (double nesting) → module not found at runtime.
Prevent: for every import in a spec file, resolve the path from the FILE's directory, not the project root. File at `rma/console-audit.spec.ts` + `'./rma/_helper'` = `rma/rma/_helper`. Helpers in same dir → use `'./_helper'`, not `'./rma/_helper'`.
Prevent: before building any new auth/E2E bypass, scan existing setup: `grep -rn "x-e2e-secret\|E2E_SECRET\|test.*phone\|055-5555555\|+972...\|test-login\|TEST_USER" apps/web/src tests/`. Read `Docs/logins.dev` if exists. Check existing test fixture patterns. Only build new bypass if no existing path covers the test scenario.

### playwright-import-package-name | fired:2 | 2026-05-14
Wrote ad-hoc verify script `import { chromium } from 'playwright'` → `ERR_MODULE_NOT_FOUND`. Repo only installs `@playwright/test`, not standalone `playwright` package. Two failed runs before fix. Repeated 2026-05-14 — same trip-up because md-verify wasn't loaded before writing PW probes.
Prevent: any ad-hoc Playwright probe → `import { chromium, ... } from '@playwright/test'`. For real specs, use the test runner with `--config=playwright.live.config.ts` instead of raw chromium scripts. Run from `apps/web/` so node_modules resolves.

### adhoc-pw-probe-on-main-thread | fired:2 | 2026-05-21
Dispatched Haiku agents with ad-hoc `chromium.launch()` diagnostic scripts. Haiku ignored scripts, returned speculative analysis, created `.spec.ts` files, or timed out — never ran the actual script. gpt-5.4-mini subagent (write .mjs to `apps/web/tmp/`, run with `node apps/web/tmp/script.mjs`) worked on first try.
Prevent: ad-hoc PW probe → write `.mjs` to `apps/web/tmp/`, dispatch to **gpt-5.4-mini** Agent with `node apps/web/tmp/script.mjs`. NEVER Haiku for ad-hoc scripts. NEVER create `.spec.ts` file for one-shot probe. Clean up `tmp/*.mjs` after run. Haiku is ONLY for pre-written `tests/e2e/*.spec.ts` via `pnpm exec playwright test`.

### pw-nav-locator-mobile-vs-desktop | fired:1 | 2026-05-14
On mobile viewport, `page.locator('nav').filter({ hasText: /בית|חיפוש|חנויות/ })` matches BOTH the mobile BottomNav and the desktop SiteNav (display:none, but text content present in DOM). First locator was the desktop nav with bbox 0×0 → false negative on `boundingBox()` checks for "fixed at viewport bottom".
Prevent: when asserting on a breakpoint-conditional nav/header, filter visible elements explicitly via `page.evaluate(() => Array.from(document.querySelectorAll('nav')).filter(n => getComputedStyle(n).display !== 'none' && /<text>/.test(n.innerText)))`. Never trust text-content selectors alone on layouts that ship two parallel nav trees (desktop + mobile).

### pw-networkidle-hangs-on-pwa | fired:1 | 2026-05-14
`page.goto(url, { waitUntil: 'networkidle' })` on dev.multi.deal hangs to timeout — the PWA service worker keeps long-poll/stream connections alive so network never idles.
Prevent: live-site Playwright probes use `waitUntil: 'load'` (or `'domcontentloaded'`) plus explicit `await page.waitForTimeout(2000-4000)` for hydration/fetch completion. Never use `'networkidle'` against the PWA shell.

### soft-status-assertion-false-pass | fired:1 | 2026-05-16
Test 4b on `/api/me/page-data` had `expect(res.status()).toBeLessThan(500)` + `if (res.status() === 200) { ... }` — passed when seeded user didn't exist (401) AND when CSRF rejected (403). Claimed "12/12 green" but success branch never ran. Real bugs (missing `users.mh_version` col, missing test user, missing CSRF in request) hidden through a deploy.
Prevent: never use range assertions (`toBeLessThan(500)`) or `if (status === 200)` branches on endpoints with a single expected status. Assert exact `expect(status).toBe(200)` + assert ALL response-shape invariants unconditionally. Multiple legitimate states → one test per state.

### pw-csrf-double-submit-on-post | fired:1 | 2026-05-16
POST to authenticated route returns 403 in E2E even with valid `multideal_at` JWT — `csrfMiddleware` enforces double-submit on every POST/PUT/PATCH/DELETE unless route is in `CSRF_OPT_OUT_PREFIXES` (`src/server/middleware/csrf.ts`). Browser tests inherit the login-set cookie; programmatic `request.post()` does not.
Prevent: every Playwright `request.{post,put,patch,delete}` against a non-opt-out route must set both `Cookie: csrf_token=<X>` AND header `x-csrf-token: <X>` (same string). Pattern: `const csrf = 'e2e-csrf-' + Date.now();` then both. Check `CSRF_OPT_OUT_PREFIXES` before assuming a route is exempt.

### verify-test-fixtures-exist-in-dev-db | fired:1 | 2026-05-16
E2E referenced user `00000000-0000-0000-0000-000000000001` not present in dev Neon. Old test "passed" only because auth/CSRF layer 401/403'd before reaching handler — success path never exercised.
Prevent: before relying on any test user/fixture row, query Neon (`mcp__Neon__run_sql` project `bitter-wildflower-27502830`) to confirm existence + related data the test asserts on. Seed via SQL if missing. Record seeded state in project memory so future sessions know what's pre-staged.
Prevent: live-site Playwright probes use `waitUntil: 'load'` (or `'domcontentloaded'`) plus explicit `await page.waitForTimeout(2000-4000)` for hydration/fetch completion. Never use `'networkidle'` against the PWA shell.

### head-probe-poisons-edge-cache | fired:1 | 2026-05-16
`curl -sI` (HEAD) on a route covered by `edge-cache.ts` middleware caches the HEAD response under the SHARED cache key (synthetic key is GET-flavored regardless of probe method). HEAD body is empty per HTTP spec → subsequent GETs HIT and return empty body. Repro: `curl -sI /api/deals/browse` cold → MISS, cache populated empty → next `curl /api/deals/browse` GET returns `content-length: 0` + `cf-cache-status: HIT`.
Prevent: never use `curl -I` / HEAD to first-probe a cacheable route. Use `curl -s` GET. Reserve `-I` for already-warm keys when only headers needed. If suspect cache poisoning, force a new cache key (`?u=$RANDOM` — see [[cache-buster-param-stripped-by-edge-cache]] in md-server-dev) and probe with GET — fresh body confirms poisoning was the cause.

### e2e-reselect-after-page-load | fired:1 | 2026-05-18
Wishlist live verify reused `page.locator('button[aria-label*="הוסף לרשימה"]').first()` after `page.reload()` → selector matched a DIFFERENT button on the new render (deal-card ordering shifted), miscalled feature as broken. Locators are lazy but `.first()` resolves against post-reload DOM where ordinal may differ.
Prevent: after `page.reload()` / `page.goto()` / any navigation, re-derive selectors that target "the same logical element" by stable attribute (`data-deal-id="<uuid>"`, `data-testid="wishlist-btn-<id>"`), not by ordinal (`.first()` / `.nth(0)`). For ad-hoc verify scripts, capture the dealId BEFORE reload and re-select by that ID after — never trust visual ordering across re-renders.

### jwt-payload-verify-claims-before-generate | fired:2 | 2026-06-02
Generated E2E/VR `multideal_at` JWT from memory (not re-reading `verifyAccessToken`) → 2026-05-19 missing `mhv`; 2026-06-02 missing `sv`+`roles` AND `mhv` hardcoded to 1 (admin had `mh_version=26019`) → auth silently rejected → every auth-gated page redirected to `/`/`/login`, misdiagnosed as "stale JWT" (3 wasted smoke iterations).
Prevent: before generating any `multideal_at` JWT, READ `src/server/auth/tokens.ts` `verifyAccessToken` for the live required-claims set — never trust this list or memory, schema evolves. Required as of 2026-06-02: `sub` (string), `iat`, `exp`, `sv` (number=session_version), `roles` (Array, e.g. `['admin']`/`['user']`), `mhv` (number, MUST be ≥ DB `users.mh_version` or `getCachedUser` returns null). Query the real user row first (`select session_version, mh_version, is_admin from users where id=...`) — pick a user actually `is_admin`/vendor-owner in the CURRENT Neon DB (ids drift across DB swaps). Sign with `JWT_SECRET` from `.dev.vars` (NOT `SESSION_SECRET`). Confirm final URL is NOT a redirect before trusting any screenshot.

### parallel-workers-cascade-timeout | fired:1 | 2026-05-19
Running `--workers=3` on live E2E suite caused Cloudflare rate-limit cascade → first run cut off at 12/50 before timeout, results invalid. Worker budget issue masked as test failure.
Prevent: default to `--workers=2` for live `dev.multi.deal` runs. Only bump to 3 if 2 consistently slow AND no 1102/429 in results. Never diagnose fails from a timed-out run — re-run with lower workers first.

### test-i18n-locale-pin-before-assertion | fired:1 | 2026-05-19
Test navigated to `/en/...` URL but asserted Hebrew text (`/מועדפים|favorites/i`) → always failed. Opposite also trips: navigated `/` (defaults HE) but asserted English text.
Prevent: before asserting any i18n string in a test, confirm URL locale matches: if test uses `/en/` URL → assert English text; if URL has no prefix or `/he/` → assert Hebrew text. Never mix locale of URL with locale of assertion.

### probe-default-ua-blocked-by-cf-bot-fight | fired:1 | 2026-05-22
Probe script using `urllib.request.urlopen` with no `User-Agent` header → Cloudflare Bot Fight Mode returns `403 Forbidden` on `dev.multi.deal` after ~3 rapid requests. First 20× probe returned `302` correctly (had Mozilla UA); a follow-up small probe without UA returned `403 403 403 403` — looked like a regression but was bot challenge. Wasted a debug cycle thinking the auth-redirect fix had broken.
Prevent: every Python/curl probe against `dev.multi.deal` MUST set `User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36`. For urllib: `req = urllib.request.Request(url, headers={'User-Agent': UA})`. For curl: `curl -A "Mozilla/5.0" …`. Bot Fight Mode is on for the zone — default Python/curl UAs are blocked. If a probe suddenly flips from 2xx/3xx to 403 after working previously, suspect missing/changed UA, not a code regression.

### live-css-bug-playwright-first | fired:1 | 2026-05-21
Spent 10+ turns reading component files to find source of `overflow:hidden` on html/body. Advisor redirect: "Stop reading components. Get the stack trace." One Playwright MutationObserver run identified the exact culprit (`ScrollLock` in `Dialog.tsx`) in a single pass.
Prevent: unexpected CSS on live page (overflow, z-index, scroll lock, display:none, visibility) → run Playwright probe FIRST, before any component reading. Template:
```js
await page.addInitScript(() => {
  const obs = new MutationObserver(muts => {
    for (const m of muts) {
      if (m.attributeName === 'style') {
        const el = m.target;
        const style = el.getAttribute('style') || '';
        if (style.includes('<prop>')) window.__changes = [...(window.__changes||[]), { who: el.tagName, style, stack: new Error().stack }];
      }
    }
  });
  obs.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] });
  document.addEventListener('DOMContentLoaded', () => obs.observe(document.body, { attributes: true, attributeFilter: ['style'] }));
});
```
Dispatch to gpt-5.4-mini agent, run script from `apps/web/tmp/`. Stack trace names the file in one run.

### confirm-route-path-and-method-before-probing | fired:2 | 2026-05-29
5xx-safety probes returned 404 on `/api/feed` (GET) and `/he/deal/<id>` — wasted a probe round. Real shapes: `/api/feed` is POST-only (GET 404 is correct, not a deploy failure); PDP is `/deal/[id]` with NO locale prefix (`/he/deal/` 404s — only `/[locale]/deals/**` list pages are locale-prefixed, the deal detail route is not). 2026-05-29 repeat: ITEM smoke spec asserted `/api/vendor/invoicing/providers` returns 401 unauth — but the route is PUBLIC (no `requireVendorV2`); actual 500 came from a zod-4 internal-API bug, not auth. The wrong expectation masked a real bug.
Prevent: before writing any test status expectation, READ the route source: `grep -nE "requireAdmin|requireVendor|requireAuth|locals\.require" <file>` — absent = public route, expect 200; present = auth-gated, expect 401/403 unauth. Don't assume "/api/vendor/*" implies auth from the URL path. A 404/405 on a probe = wrong URL/method, NOT a 5xx — never count it as a deploy failure. PDP = `/deal/<id>`; locale prefix applies only to `/[locale]/deals/...` list routes.

### bisect-no-stacked-burst-probes | fired:1 | 2026-05-28
Bisected /deal/[id] 503 across 4 deploys (wave 2 → 3a → 3b → full branch → rollback) with a fresh 20×`?cb=$RANDOM` burst on each. Burst on bisect round N feeds CF Free CPU throttle into round N+1 → previously-200 deploys flip to 503 → false "code regression" conclusion. Rollback target served 503 too, proving the 503s were probe-induced, not code-induced. Wasted ~2 hours + several deploys before advisor() unblocked.
Prevent: 20× burst is for the FIRST verification of a candidate fix, not for bisect rounds. While bisecting across deploys, use SINGLE no-cb GET per deploy + ≥60s spacing between rollbacks. If two deploys disagree (one 503, one 200), suspect platform CPU/throttle drift before suspecting code — re-probe both after 10-min cooldown with single probes. Stacked bursts contaminate the bisect signal.

### lazy-dialog-assert-not-if | fired:1 | 2026-05-29
`if (await confirmBtn.isVisible({ timeout: 2000 }))` for React.lazy() dialog — chunk not loaded in 2ms, condition false, action silently skipped, state never changed. No throw → test got past click but final assertion failed.
Prevent: NEVER gate lazy-dialog actions on `if (await x.isVisible(...))`. Use `await expect(confirmBtn).toBeVisible({ timeout: 8000 })` — throws on timeout, forces real failure. 8s minimum for React.lazy chunk over CF Worker cold pop.

### spa-filter-chip-wait-for-url | fired:1 | 2026-05-29
`waitForTimeout(1000)` then `waitForLoadState('networkidle')` after SPA filter chip click — both settled before the table XHR refetch completed, rows not found despite correct filter applied.
Prevent: after clicking a chip that calls `setFilter()` (SPA router push), wait for URL: `await page.waitForURL(/state=EXPECTED_VALUE/)`. THEN assert rows with generous timeout. `waitForTimeout` is fragile; `networkidle` can fire between router push and triggered fetch.

### dialog-button-scope-to-role | fired:1 | 2026-05-29
`page.getByRole('button', { name: /אישור/i }).last()` matched BOTH trigger button AND lazy dialog confirm sharing the same text "אישור בכל זאת" — clicked trigger again, dialog stayed open, state never changed.
Prevent: scope dialog button lookups to dialog role: `page.getByRole('alertdialog').getByRole('button', { name: /text/i })`. Never use page-wide `.last()`/`.first()` when trigger + dialog share label text.

### vendor-journey-timeout-15min | fired:1 | 2026-06-01
Full vendor journey on `dev.multi.deal` (register → vendor upgrade → Stripe Connect onboarding → 3 deal uploads → admin approve → storefront verify) takes >15 minutes wall-clock. 480s, 720s, and 900s timeouts all expired at storefront verification entry. Stripe Connect onboarding is the dominant cost (~10–12 min on live).
Prevent: set `test.describe.configure({ timeout: 1200_000 })` (20 min) for full vendor journeys. If isolating upload/storefront only (skipping Stripe), 300s sufficient. Never set journey timeout by guessing — measure first run with `--reporter=line` and add 25% buffer.

### e2e-secret-deployed-vs-test-default | fired:1 | 2026-06-01
Test helper defaulted `E2E_SECRET` to `'e2e-dev-secret'`; deployed worker had a long hash → all `resetShipment` / `setWoltMockScenario` calls returned 403 silently, fixture reset never happened, state dirty across browser runs.
Prevent: `E2E_SECRET` deployed value MUST match test helper default `'e2e-dev-secret'`. Before any E2E deploy adding a guarded test endpoint, run `wrangler secret put E2E_SECRET` with value `e2e-dev-secret`. Verify: `wrangler secret list` — mismatch = silent 403 on all test-only routes.

### aria-label-vs-inner-text-getbyrole | fired:1 | 2026-06-01
Test used `getByRole('button', { name: /^שלח$/ })` — button had `aria-label="שלח הזמנה 00000000"`. Playwright `getByRole` matches accessible name (aria-label), not inner text. Anchored regex never matched; test found no button.
Prevent: when writing `getByRole('button', { name: /regex/ })`, check component source for `aria-label` prop. If set, regex must match full aria-label value (e.g. `/ship|שלח/i` without anchors). Only use anchored `^text$` when button has NO aria-label override.

### near-you-map-location-granted-gate | fired:1 | 2026-06-01
Playwright test granted browser geolocation and waited for `.leaflet-marker-icon` — 0 markers appeared. Root cause: `useMarkersQuery` enabled only when `locationGranted = storeRadius !== null`. `storeRadius` is set only by `UseMyLocationButton` click; browser geolocation permission grant alone does NOT populate the Zustand store. No location button is present on the `/near-you` page.
Prevent: for near-you map marker verification, do NOT rely on markers appearing in the DOM. Instead verify via `/api/feed/markers` API shape check: POST with `{ preset:'near-you', radius:{lat,lng,km} }`, assert `markers.length > 0` and required fields present.

### cf-worker-networkidle-hangs | fired:1 | 2026-06-01
`page.goto(url, { waitUntil: 'networkidle' })` on `dev.multi.deal` CF Worker pages hung indefinitely — Worker keeps a long-poll or SSE connection open, so `networkidle` never fires.
Prevent: on CF Worker pages (dev.multi.deal, multi.deal), always use `waitUntil: 'domcontentloaded'` + explicit `page.waitForTimeout(N)` (8–10s for cold island hydration). Never use `networkidle` or `load` as the waitUntil on Worker-served URLs.

### jose-not-in-playwright-env | fired:1 | 2026-06-04
Playwright spec imported `jose` for JWT signing → `Cannot find package 'jose'` error at runtime — `jose` is a server dep, not installed in the playwright test runner env.
Prevent: mint JWTs outside the test using `node -e` with `crypto.createHmac('sha256', secret)` + base64url encoding, then hardcode the token string in the spec. Never import `jose` or other server-only JWT libraries in playwright specs.

### admin-user-multi-vendor-breaks-auth-verify | fired:1 | 2026-06-04
ADMIN_USER_ID (`2b9c506a-3dca-466b-bba1-a65fbc9ee338`) owns 6 vendor rows. `getVendorByOwnerUser` uses `.limit(1)` without `ORDER BY` → returns first row (nondeterministic, often NOT the target vendor like spa carmel). Earnings page showed State A despite DB having charges_enabled on the target vendor.
Prevent: when writing E2E tests that verify vendor-specific Stripe state, query the DB first to find a test user that owns ONLY one vendor. Never assume ADMIN_USER_ID's vendor has specific stripe state — the admin owns multiple test vendors with varying states.

### jwt-mhv-query-db-before-generate | fired:1 | 2026-06-05
Used `mhv=32029` from session memory in probe JWT → all admin routes returned 302. Actual DB had `mh_version=34929` (increments on every user action).
Prevent: ALWAYS run `SELECT mh_version FROM users WHERE id = '...'` via Neon MCP BEFORE setting `mhv` in any probe/E2E JWT. Never use mhv from session memory or summaries — it goes stale fast.

### waitForResponse-status-filter-skips-503 | fired:1 | 2026-06-05
`page.waitForResponse(res => res.url().includes('/path') && res.status() === 200)` silently times out (30s) when server returns 503 — response fires but status predicate rejects it, promise never resolves.
Prevent: never combine URL + status filter in waitForResponse. Either accept any response + assert `.status()` afterward, or use `page.goto()` + `page.content()` assertions. Status-200 filter masks 503s and causes 30s hangs.

### networkidle-fires-in-retry-delay-gap | fired:1 | 2026-06-05
`waitForLoadState('networkidle')` triggered mid-way through a fetchDashboard 503-retry sequence. Between retries (1s/2s delay timers) there are no active requests → networkidle fires → assertion ran before data loaded → test failed.
Prevent: when page data query retries on 503 (timer-based delay), networkidle is unreliable. Use `await expect(page.getByTestId('data-el')).toBeVisible({ timeout: 30_000 })` — waits for the DOM element that only appears after data loads, ignoring intervening network gaps.

### cache-buster-inflates-503-on-cf-cached-routes | fired:1 | 2026-06-05
Probed homepage with `?b=$RANDOM` → 75% 503; misread as "site is down". Reality: homepage is `cf-cache-status: HIT`. Cache-busting forces every request to hit the worker CPU directly → 503 rate reflects cold-start ceiling, not real user experience.
Prevent: to assess site health, probe WITHOUT cache-buster first. `cf-cache-status: HIT` → route is healthy for users. Use `?b=$RANDOM` only when explicitly testing raw worker CPU, not site health.

### pw-absolute-url-homepage-discovery | fired:1 | 2026-06-05
New spec used `page.goto('https://dev.multi.deal/')` (absolute URL) + `waitForLoadState('networkidle')` to scrape deal slugs dynamically — all 3 tests failed `net::ERR_ABORTED; maybe frame was detached?`. Absolute URLs bypass baseURL; networkidle never settles on SSR CF Worker (see cf-worker-networkidle-hangs). Homepage slug-scraping also fails if the first page.goto aborts.
Prevent: use relative paths (`'/'`) which resolve via `baseURL` in the live config. Copy the `gotoAndWait(page, path)` helper from `all-customer-flows.spec.ts` — `{ waitUntil: 'load', timeout: 30_000 }` + 2 s hydration wait. For auth-gated UI tests, navigate directly to a hardcoded deal slug from `FIXTURE_IDS` — never discover slugs dynamically by scraping the homepage.

### jwt-secret-deployed-vs-dev-vars | fired:1 | 2026-06-06
JWT injection showed guest mode even with valid signature — deployed worker had a different `JWT_SECRET` than `apps/web/.dev.vars`. Spent 10+ turns debugging: checked user mhv, verified signature locally (✓), cache-busted, redeployed — all passed but auth still failed.
Prevent: before any JWT-injection E2E test session, run `grep JWT_SECRET apps/web/.dev.vars | cut -d= -f2- | wrangler secret put JWT_SECRET --name multideal-preview` then redeploy. Treat `.dev.vars` JWT_SECRET as the canonical value; never assume the deployed secret matches without syncing.

### non-discriminating-auth-test | fired:1 | 2026-06-06
Test "auth user without phone → OTP step shown" PASSED even when JWT was not recognized — guest path also shows OTP step, so the assertion was true regardless of auth state.
Prevent: when testing auth-conditional UI, assert on something UNIQUE to the authenticated state vs guest — check nav for non-guest indicator (`:not(:has-text("אורח"))`), verify a different element, or assert the user's email/name appears. A test that passes for both guest and auth is not a real auth test.

---

## Visual Regression Snapshots

Use before and after any batch of code changes that touches UI (components, layout, styles, i18n strings, page routes).

**When to use:** capture a baseline **before** dispatching implementation agents; run compare **after** deploy to `dev.multi.deal`.

**Capture baseline** (`--update-snapshots` via `run.sh`):

```bash
cd apps/web
MODE=baseline bash tests/e2e/visual-regression/run.sh
```

**Compare** (Playwright `toHaveScreenshot`, fails if pixel diff > 2%):

```bash
cd apps/web
bash tests/e2e/visual-regression/run.sh
```

**Snapshots location:** `apps/web/tests/e2e/visual-regression/visual-regression.spec.ts-snapshots/` (baseline PNGs). On failure, actual/diff PNGs land in `test-results/`.

**Pages covered:** `/`, `/deals`, `/help`, `/support/tickets/new`, `/support/tickets`, `/purchases` (all as user1). Dynamic elements (`time`, hero carousel, deal counter, countdown) are masked.

**IMPORTANT:** Run baseline **before** dispatching implementation agents. Run compare **after** deploy — not against uncommitted local dev.

## Learned Rules

### read-component-before-probe-selector | fired:1 | 2026-06-09
Playwright probe used `pg.$('textarea')` to find notification emails field — returned NOT_FOUND. Field is `<Input>` (renders as `<input type="text">`), not a `<textarea>`. Wasted two probe rounds.
Prevent: before writing any form-field Playwright selector, grep the component for the element type: `grep -n "Textarea\|<input\|<Input\|<textarea" <component>.tsx`. Use the actual element type or `placeholder` attribute as selector (`input[placeholder*="example"]`), never assume field type.
