Headless E2E on deployed dev (test accounts + harness) โ
How the two-account headless browser E2E runs against deployed dev (dev.ourlantern.app), and the exact steps to create a new test phone number when you need one. The first live user of this is the #144 block-sever flow (npm run e2e:block-sever).
Scope. This is on-demand verification, not CI. It drives the real deployed dev site with real Firestore writes, so it is deliberately kept out of the per-PR pipeline (too slow, too stateful, real data). Run it by hand when you want a full-stack confidence check.
The moving parts โ
| Piece | What it is | Where it lives |
|---|---|---|
| Harness | Playwright scripts (session.mjs, flow.mjs, runner) | tooling/e2e/ |
| Test creds | phone/PIN per account, App Check token, UIDs | ~/.lantern-test-accounts.env (0600, never committed) |
| App Check debug token | lets a headless browser sign in past reCAPTCHA Enterprise | registered in Firebase Console, value in the env file |
| Prelude sandbox | fakes the SMS OTP for test numbers | Prelude dashboard (operator) |
| Admin (Firestore) | reads/writes for teardown + assertions | Application Default Credentials on the VM |
Creating a NEW test phone number (do this in order) โ
The whole thing hinges on ONE rule: the Prelude sandbox number must be character-for-character identical to the E.164 string the app sends. The app assumes US and turns a bare 10-digit number into +1XXXXXXXXXX. So a US test number 619-555-0004 MUST be registered as +16195550004 (with the 1), not +6195550004.
Why this bites: +6195550004 reads to Prelude as country code +61 (Australia), a different number, so it rejects the send as "Invalid Number" and the app shows "failed to create account." We lost hours to exactly this. Confirmed in code: normalizePhoneNumber in packages/shared/encryption/index.js prepends +1 to any bare 10-digit input.
Fast path (one command): once the sandbox number exists (step 1), run
node tooling/e2e/onboard.mjs <bareUsPhone> <6-digit-pin> # e.g. 6195550004 738261It mints an invite directly (Admin SDK, no admin portal), runs the invite-gated signup headlessly with the App Check debug token, and prints the new TEST_B_PHONE/PIN/UID to paste into the env file. The PIN is yours to choose (6 digits, must pass validatePIN, i.e. not in WEAK_PINS). Note: the signup navigates away on the final submit, so a "Create my account button timeout" at the very end can be a false alarm; check whether the account was actually created by looking at the minted invite's usedBy (that value IS the new UID).
Manual path, step by step:
- Prelude โ Sandboxed Numbers โ Add Number. Enter the full E.164 with the US country code:
+16195550004. Attempt code:123456. (Every sandbox number uses OTP123456.) Sandbox sends are free and never dispatch a real SMS. - Generate an invite link. Signup is invite-gated. Either let
onboard.mjsmint one, or write auserInvites/<token>doc directly (fields:createdAt,createdBy,expiresAt,usedAt:null,usedBy:null), or log intoadmin.dev.ourlantern.appas the Agent Probe admin (creds in~/.lantern-agent-probe.env), Users page โ "Generate Invite Link". (Never use the Agent Probe number as an app user; it is admin-only.) - Rate limits exist but sandbox sends are free.
auth-api(phoneOtp.js) still counts 5 sends per phone per hour / 5 per IP per 10 min, but the OTP is a static123456and no real SMS is sent, so there is no per-send cost, just the counter. Don't churn if you're near the cap. - Sign up via
signUp({ invite, phone, pin })(session.mjs) or by hand. Phone-verify OTP is123456. The account is real afterward and signs in with phone + PIN (no OTP on login). - Record it. Add
TEST_x_PHONE/TEST_x_PIN/TEST_x_UIDto~/.lantern-test-accounts.env. The UID is needed for teardown;onboard.mjsprints it, or read it from the invite'susedByor anyconnectionsdoc the account appears in (user1Id/user2Id).
App Check debug token โ
Deployed dev enforces Firebase App Check (reCAPTCHA Enterprise). A headless browser has no reCAPTCHA, so every signed-in call 401s with "Missing X-Firebase-AppCheck header" unless you present a debug token:
- Operator registers a UUID under Firebase Console โ App Check โ (web app) โ Manage debug tokens.
- Store the UUID as
APPCHECK_DEBUG_TOKENin the env file. - The harness injects it via
self.FIREBASE_APPCHECK_DEBUG_TOKENin a PlaywrightaddInitScript(runs before page scripts), so the App Check SDK uses it instead of reCAPTCHA. - It bypasses attestation only, not auth. Low-privilege; still keep it out of git.
Running the block-sever E2E โ
npm run e2e:block-sever # from repo root; needs the env file + ADCWhat it does: teardown any prior connection/block between the two accounts โ both sign in (geolocated to the venue) โ both light a lantern โ B waves โ A accepts (chat opens) โ A messages โ A blocks B with "save a copy" โ asserts in Firestore that the connection is blockClosed and its messages are gone, AND captures the "save a copy" .txt download and asserts it still holds the message (capture-then-sever: the client copy is taken BEFORE the server destroys the thread).
Re-runnability. teardownPair(uidA, uidB) deletes every connection between the two UIDs (plus messages), both users/{uid}/blocks/{peer} docs, and pending waves before the run starts. Without it, a prior run's connection blocks a fresh wave (already-connected) and a prior block hides the two from each other. It needs TEST_MAIN_UID and TEST_B_UID in the env file.
UID correctness matters (this bit us). A block hides the two accounts from each other in the venue view, so a wrong
TEST_MAIN_UID/TEST_B_UIDmeans teardown clears the wrong pair and a real block from the previous run survives, wedging the NEXT run at the wave step ("B never sees A's lantern"). Get the UIDs from aconnectionsdoc the pair actually appears in (user1Id/user2Id) and confirm which is which, do not assume user1 = MAIN. As a backstop, the runner also self-heals at the end: it clears the block using the connection's realuser1Id/user2Id, so a stale env UID can't wedge future runs.
Gotchas baked into the harness:
- Fresh browser context = fresh onboarding every run. Tutorial state is per-browser (localStorage), so each run hits (a) the 9-step dashboard tour and (b) a venue coach-mark ("Light your lantern here").
dismissTouranddismissVenueCoachMarkhandle both. A not-yet-lit account reaches the light form via the flame button โ LanternHub โ "Light Lantern", not the/lightroute directly. - Tall viewport. The block-confirmation modal is taller than the 720px headless default, which pushed the confirm button off-screen; the context uses a
1280x1400viewport. - Venue + geofence. Dev venues are in San Diego. The runner uses Ginger's (
00N4CsJJCL4GGiRBhl8w,32.7117496,-117.160303); geolocation is set at the browser-context level, so it works regardless of the app's dev-only spoof helpers. Lighting requires being inside the ~100m geofence.
Related โ
- Feature test plan:
docs/engineering/testing/runs/block-sever-144/README.md - Phone-input UX follow-up (default
+1, reject bad country codes): issue #777 - Browser-testing basics: the
reference_browser_testingauto-memory