# 🤖 verify — drive the web app end-to-end against a local build

How to observe a web/chat change in a real browser, fast (no fixtures in the
repo; everything is provisioned over the real protocol).

## Build + run

```sh
go build -o /tmp/lchat .
LCHAT_ADMIN_TOKEN=verifytok LCHAT_DB=/tmp/verify.db ADDR=127.0.0.1:18711 /tmp/lchat &
```

## Provision a member + thread from Node (no UI clicking)

`web/static/crypto.js` runs under Node. (Node ≥20 already HAS `globalThis.crypto`
and it's a getter-only property — assigning `webcrypto` to it throws
`TypeError: Cannot set property crypto`. Just import crypto.js by ABSOLUTE path
and use it.)

1. `randomSecret()` → `deriveInvite(T)` → `{id, claimHash, claimSecret}`.
2. `POST /api/admin/key-invites` (Bearer admin) `{label, id, wrapped:"", claim_hash}`.
3. `POST /api/key-invite/{id}/claim` `{claim_secret}` → `{token}`.
4. `POST /api/pubkey` `{token, public_key}` — `generateUserKeypair()` returns
   **`{publicKey, privateKey}`** (base64url), not `{pub, priv}`.
5. `GET /api/roster?token=` → **`{roster: [...]}`** — find yourself by `public_key`.
6. `generateKey()` → `{key, b64}`; `wrapThreadKey(b64, myPub)`;
   `POST /api/threads` `{token, kind:"group", name_blob: encrypt(key,{name}), members:[{user_id, wrapped}]}` — a group of one is valid and mounts the real chat engine (a `self` thread mounts a mini-app instead).

⚠️ **The thread-id field names differ by direction, and getting one wrong fails
SILENTLY as an empty result, not an error:**

- `POST /api/threads` returns **`{thread_id}`** (not `id`).
- `GET /api/messages?token=…&thread=…` takes **`thread`** (not `thread_id`);
  a wrong/absent one 403s `{"error":"not a thread member"}`, so a naive
  `(r.messages || []).length` reads as **0 messages** and looks like your feature
  failed to post. Assert on the count of `messages` only after checking the
  response isn't an error — or read the DB (`sqlite3 /tmp/verify.db "select
  thread_id, count(*) from messages group by thread_id;"`), which never lies.

## Drive it in Chromium (Playwright)

Seed the session *before* page scripts run, then assert on the live DOM:

```js
await ctx.addInitScript(([sess, tid]) => {
  localStorage.setItem("lchat_session", JSON.stringify(sess)); // {token,name,userPub,userPriv}
  localStorage.setItem("lchat_active", tid);
}, [session, threadId]);
```

- Live selectors: `.chatapp-root .composer .composer-input`, `.chatapp-root .send-btn`,
  `.chatapp-root .composer input[type=file]`, `.chatapp-feed .msg`,
  `.composer-preview .preview-chip`. (The id-based `#feed`/`#text` skeleton is the
  retired classic chat — always empty.)
- **The composer is a ProseMirror contenteditable, not a `<textarea>`** — so
  `.fill()` doesn't work (the locator never resolves and you get a 30s timeout).
  Click it and type: `await page.locator(".composer .composer-input").click();
  await page.keyboard.type("/gif cats"); await page.keyboard.press("Enter");`
- **Panels are dismissed, not removed**: after `panel.close()` the `.cmd-panel`
  element survives as an empty container — assert `isVisible() === false` (or that
  its body is gone), never `count() === 0`.
- **Reset the DB between runs.** State persists, so a second run of a
  "first-launch asks for X" test finds X already set and the form never appears.
- External APIs: `ctx.route("**://*.giphy.com/**", …)` to fulfil a fake page +
  bytes — lets the whole tap-to-post path run with no real key, and the route
  handler can record the `api_key` each call used (which is how you prove *which*
  key a search read).
- Slow/failing uploads: `page.route("**/api/upload*", …)` with a delay or a 500 —
  perfect for in-flight-upload races.
- `alert()` paths: register `page.on("dialog", …)` or the page wedges.
- Test users render as "Someone" unless they publish encrypted labels — harmless.
- A known Playwright install lives at
  `~/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs`
  (browsers in `~/Library/Caches/ms-playwright`); `npx playwright` works too.

Worked example (provision + 14-assertion drive): the scripts from the
send-waits-for-upload verification — re-create from this recipe.

## ⚠️ Fixtures must never be a fresh space's first boot

The FIRST member to boot a fresh instance mints its shared instance key
(#785). A throwaway harness member doing that — then vanishing — orphans the
key: real members who join later stay keyless forever (invites break, names
don't decrypt). So when a canary will be handed to a person afterwards, let
THEM claim first; or reset the instance DB before handover; or check
`GET /api/instance-key` shows your fixture isn't the sole holder you're about
to destroy.
