eleven / messenger Public
π€ 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
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.)
randomSecret()βderiveInvite(T)β{id, claimHash, claimSecret}.POST /api/admin/key-invites(Bearer admin){label, id, wrapped:"", claim_hash}.POST /api/key-invite/{id}/claim{claim_secret}β{token}.POST /api/pubkey{token, public_key}βgenerateUserKeypair()returns{publicKey, privateKey}(base64url), not{pub, priv}.GET /api/roster?token=β{roster: [...]}β find yourself bypublic_key.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 (aselfthread 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/threadsreturns{thread_id}(notid).GET /api/messages?token=β¦&thread=β¦takesthread(notthread_id); a wrong/absent one 403s{"error":"not a thread member"}, so a naive(r.messages || []).lengthreads as 0 messages and looks like your feature failed to post. Assert on the count ofmessagesonly 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:
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/#textskeleton 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-panelelement survives as an empty container β assertisVisible() === false(or that its body is gone), nevercount() === 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 theapi_keyeach 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: registerpage.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 playwrightworks 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.