---
name: worldpol-ui-testing
description: Use ONLY when the user mentions Playwright, run-pw.mjs, the manual UI test runner, or asks to "test the UI manually" / "drive the browser". Surfaces how to run the Playwright runner, what to warm up first, and known flake sources.
---

# WorldPol UI Testing

Manual end-to-end test driver. Live at `tests/manual/run-pw.mjs`.
Project-wide conventions live in the root `AGENTS.md`; component gotchas
in the sibling `worldpol-gotchas` skill.

## What it does

11 phases that exercise the browse-then-act compare flow:

1. Load home page, verify h1
2. Selectors rendered; model checkboxes default to ALL checked
3. Click a topic toggle → the ENTIRE grid renders (cached + missing cells)
   with no other input; bulk "Generate all missing (N)" button appears
4. Deselect one model → its cells vanish (selectors are filters); reselect
5. Deselect Arabic → its row vanishes; reselect
6. Per-cell Generate on a missing `:free`-model cell (real OpenRouter call;
   429-failed → Retry is a PASS — both terminal states are valid)
7. Navigate to `#analytics`
8. Change the agreement-matrix topic select and click a heatmap cell; the
   detail panel must load response text from `/api/response-text`
9. Navigate back to `#compare`; grid still rendered
10. Open API key panel via the header "API key" button, close via **Escape**
11. Re-open analytics, check for alert / content size

Screenshots go to `/tmp/pw-test/artifacts/`. Summary JSON at the same path.

## How to run

Prereqs:

- API on `:3001` (Docker `worldpol-api` container, or local `tsx server/index.ts`)
- Dev server on `:5173` (`vp dev` from the project root)
- Chromium downloaded at `~/Library/Caches/ms-playwright/chromium-1223/`

```bash
# from project root
node tests/manual/run-pw.mjs
```

Exits 0 on success, 1 on failure, 2 on top-level crash. Look at the tail
of stdout for the `ok` / `FAIL` lines and the screenshot files for what
the browser actually saw.

## First-run flake: Vite dep pre-bundling aborts

On a cold dev server, Vite+ lazily pre-bundles `node_modules/.vite/deps/*`
on the first request from the browser. The Playwright run will fire many
of those requests in parallel, and some get `net::ERR_ABORTED` because
Vite re-optimizes mid-flight. **These are not real failures** — re-run
the test and they go away. The summary line `request failures: N` will
show this; only `page errors: >0` and `HTTP >=400 responses: >0`
indicate real bugs.

To make tests deterministic, warm Vite first:

```bash
curl -s -o /dev/null http://localhost:5173/
curl -s -o /dev/null http://localhost:5173/src/main.tsx
sleep 2
```

## What the assertions actually check

- **Phase 3 (topic click):** `aria-pressed="true"`, `data-pressed` attribute
  present, computed `background-color` not transparent. The CSS uses
  `data-pressed:bg-muted` (NOT `data-[state=on]:bg-muted` — see
  `worldpol-gotchas` for why).
- **Grid cells:** every cell carries `data-cell-id="<modelId>|<langCode>"`
  and `data-cell-state` (`cached` / `missing` / `queued` / `generating` /
  `failed`). Target cells with those, not CSS classes.
- **Phase 4 (models):** the visible `<label>` is clicked, not the hidden
  `<input type="checkbox">`. The base-ui `Checkbox` renders an `<input>`
  inside a `<label>`; clicking the label is what the user does.
- **Phase 5 (languages):** toggle text is `"العربية(Arabic)"` (no space)
  because the `native_name` and `name` spans have no whitespace between
  them in `LanguageSelector.tsx`. Use substring match.
- **Phase 6 (generate):** the per-cell button is found inside the
  `[data-cell-id=...]` scope — the bulk button also contains the word
  "Generate", so never match on text alone. The target cell is chosen by
  querying `/api/grid` for a missing `:free` model so the test never
  spends OpenRouter credit.
- **Phase 8 (analytics):** the agreement select uses
  `data-testid="agreement-topic-select"`, clickable heatmap cells use
  `data-testid="heatmap-cell"`, and the detail panel uses
  `data-testid="heatmap-detail"`. After clicking a heatmap cell, wait for
  `/api/response-text` to return 200, wait until the panel text includes
  `Response` and no longer includes `Fetching`, then assert there is no
  `Error:` text.
- **Phase 10 (API key panel):** the modal backdrop is a
  `<div class="fixed inset-0 z-50 ...">` which blocks pointer events
  on the rest of the page. Escape is wired and should detach the
  `input[placeholder='sk-or-v1-...']` modal input.

## Adding a new phase

Append a new `console.log("\n[Phase N] ...")` block, use `record(cond, msg)`
to log pass/fail, push to the `failures` array via `record`, take a
screenshot with `await page.screenshot({ path: join(ARTIFACTS, "...") })`,
and update the summary at the bottom.
