| 1 | # π€ verify β drive the web app end-to-end against a local build |
| 2 | |
| 3 | How to observe a web/chat change in a real browser, fast (no fixtures in the |
| 4 | repo; everything is provisioned over the real protocol). |
| 5 | |
| 6 | ## Build + run |
| 7 | |
| 8 | ```sh |
| 9 | go build -o /tmp/lchat . |
| 10 | LCHAT_ADMIN_TOKEN=verifytok LCHAT_DB=/tmp/verify.db ADDR=127.0.0.1:18711 /tmp/lchat & |
| 11 | ``` |
| 12 | |
| 13 | ## Provision a member + thread from Node (no UI clicking) |
| 14 | |
| 15 | `web/static/crypto.js` runs under Node. (Node β₯20 already HAS `globalThis.crypto` |
| 16 | and it's a getter-only property β assigning `webcrypto` to it throws |
| 17 | `TypeError: Cannot set property crypto`. Just import crypto.js by ABSOLUTE path |
| 18 | and use it.) |
| 19 | |
| 20 | 1. `randomSecret()` β `deriveInvite(T)` β `{id, claimHash, claimSecret}`. |
| 21 | 2. `POST /api/admin/key-invites` (Bearer admin) `{label, id, wrapped:"", claim_hash}`. |
| 22 | 3. `POST /api/key-invite/{id}/claim` `{claim_secret}` β `{token}`. |
| 23 | 4. `POST /api/pubkey` `{token, public_key}` β `generateUserKeypair()` returns |
| 24 | **`{publicKey, privateKey}`** (base64url), not `{pub, priv}`. |
| 25 | 5. `GET /api/roster?token=` β **`{roster: [...]}`** β find yourself by `public_key`. |
| 26 | 6. `generateKey()` β `{key, b64}`; `wrapThreadKey(b64, myPub)`; |
| 27 | `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). |
| 28 | |
| 29 | β οΈ **The thread-id field names differ by direction, and getting one wrong fails |
| 30 | SILENTLY as an empty result, not an error:** |
| 31 | |
| 32 | - `POST /api/threads` returns **`{thread_id}`** (not `id`). |
| 33 | - `GET /api/messages?token=β¦&thread=β¦` takes **`thread`** (not `thread_id`); |
| 34 | a wrong/absent one 403s `{"error":"not a thread member"}`, so a naive |
| 35 | `(r.messages || []).length` reads as **0 messages** and looks like your feature |
| 36 | failed to post. Assert on the count of `messages` only after checking the |
| 37 | response isn't an error β or read the DB (`sqlite3 /tmp/verify.db "select |
| 38 | thread_id, count(*) from messages group by thread_id;"`), which never lies. |
| 39 | |
| 40 | ## Drive it in Chromium (Playwright) |
| 41 | |
| 42 | Seed the session *before* page scripts run, then assert on the live DOM: |
| 43 | |
| 44 | ```js |
| 45 | await ctx.addInitScript(([sess, tid]) => { |
| 46 | localStorage.setItem("lchat_session", JSON.stringify(sess)); // {token,name,userPub,userPriv} |
| 47 | localStorage.setItem("lchat_active", tid); |
| 48 | }, [session, threadId]); |
| 49 | ``` |
| 50 | |
| 51 | - Live selectors: `.chatapp-root .composer .composer-input`, `.chatapp-root .send-btn`, |
| 52 | `.chatapp-root .composer input[type=file]`, `.chatapp-feed .msg`, |
| 53 | `.composer-preview .preview-chip`. (The id-based `#feed`/`#text` skeleton is the |
| 54 | retired classic chat β always empty.) |
| 55 | - **The composer is a ProseMirror contenteditable, not a `<textarea>`** β so |
| 56 | `.fill()` doesn't work (the locator never resolves and you get a 30s timeout). |
| 57 | Click it and type: `await page.locator(".composer .composer-input").click(); |
| 58 | await page.keyboard.type("/gif cats"); await page.keyboard.press("Enter");` |
| 59 | - **Panels are dismissed, not removed**: after `panel.close()` the `.cmd-panel` |
| 60 | element survives as an empty container β assert `isVisible() === false` (or that |
| 61 | its body is gone), never `count() === 0`. |
| 62 | - **Reset the DB between runs.** State persists, so a second run of a |
| 63 | "first-launch asks for X" test finds X already set and the form never appears. |
| 64 | - External APIs: `ctx.route("**://*.giphy.com/**", β¦)` to fulfil a fake page + |
| 65 | bytes β lets the whole tap-to-post path run with no real key, and the route |
| 66 | handler can record the `api_key` each call used (which is how you prove *which* |
| 67 | key a search read). |
| 68 | - Slow/failing uploads: `page.route("**/api/upload*", β¦)` with a delay or a 500 β |
| 69 | perfect for in-flight-upload races. |
| 70 | - `alert()` paths: register `page.on("dialog", β¦)` or the page wedges. |
| 71 | - Test users render as "Someone" unless they publish encrypted labels β harmless. |
| 72 | - A known Playwright install lives at |
| 73 | `~/.npm/_npx/e41f203b7505f1fb/node_modules/playwright/index.mjs` |
| 74 | (browsers in `~/Library/Caches/ms-playwright`); `npx playwright` works too. |
| 75 | |
| 76 | Worked example (provision + 14-assertion drive): the scripts from the |
| 77 | send-waits-for-upload verification β re-create from this recipe. |
| 78 | |
| 79 | ## β οΈ Fixtures must never be a fresh space's first boot |
| 80 | |
| 81 | The FIRST member to boot a fresh instance mints its shared instance key |
| 82 | (#785). A throwaway harness member doing that β then vanishing β orphans the |
| 83 | key: real members who join later stay keyless forever (invites break, names |
| 84 | don't decrypt). So when a canary will be handed to a person afterwards, let |
| 85 | THEM claim first; or reset the instance DB before handover; or check |
| 86 | `GET /api/instance-key` shows your fixture isn't the sole holder you're about |
| 87 | to destroy. |
| 88 | |