Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,7 @@ extension.zip
### Playwright artifacts
/test-results/
/playwright-report/

### Local MCP server config (contains PLAYWRIGHT_MCP_EXTENSION_TOKEN)
.mcp.json
functions/package-lock.json
102 changes: 68 additions & 34 deletions e2e/EMULATOR_SETUP.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,49 @@
# Emulator setup — unlocking the infra-gated E2E gap cases
# Emulator setup — the infra-gated E2E gap cases (NOW LIVE)

**STATUS (2026-06-18): DONE.** The `AUTH` / `CLOUD` / `PADDLE` cases from
`e2e/E2E_GAP_TEST_PLAN.md` are now LIVE, emulator-backed tests in
[`e2e/tests/cloud.spec.js`](tests/cloud.spec.js), run via the `cloud` Playwright
project. 34 of the 35 cases are real passing tests; the one residual case (FLD-2)
stays `test.fixme` in [`e2e/tests/cloud.fixme.spec.js`](tests/cloud.fixme.spec.js)
because it is blocked by a MISSING UI affordance, not by infrastructure.

```bash
# Run the whole emulator-backed cloud suite (boots emulators + the wired dev server):
yarn test:e2e:cloud # == PW_CLOUD=1 playwright test --project=cloud
```

The signed-out staging gate (default `chromium` project) is UNCHANGED — it
`testIgnore`s the cloud specs and boots the plain dev server with no emulator.

The rest of this doc records the working recipe (and the two non-obvious traps the
prior sketch missed).

---

## 0. The two traps the original sketch missed

1. **The GLOBAL `firebase` v9.16.5 CLI cannot run the functions emulator here.** It
is a pkg'd Mach-O binary whose bundled Node is too old to parse
`firebase-admin@11`'s optional chaining (`this.appStore?.removeApp(...)`), so the
functions emulator crashes on load (`SyntaxError: Unexpected token '.'`) and
`/create-share` / `/get-shared-item` never work. **Fix:** use the v13 CLI that
ships in `functions/node_modules/.bin/firebase` (a devDependency, `firebase-tools
^13`). v13 runs the functions emulator under the HOST Node (20), which loads
`functions/index.js` cleanly. The cloud webServer command uses this binary.
Prereq: `cd functions && npm install` (installs firebase-admin/-functions/mixpanel
AND the v13 CLI).

2. **The Firestore emulator enforces `firestore.rules` even over REST.** Seeding a
fixture item naively returns `403 PERMISSION_DENIED`. **Fix:** the emulator grants
full admin access (bypassing all rules) to requests carrying
`Authorization: Bearer owner` — `e2e/cloud/firestoreEmu.mjs` sets this on every
seed/probe, so tests can set up arbitrary fixtures the client could never write.

---

The `AUTH` / `CLOUD` / `PADDLE` cases in `e2e/E2E_GAP_TEST_PLAN.md` are scaffolded
as **pending** (`test.fixme`) in [`e2e/tests/cloud.fixme.spec.js`](tests/cloud.fixme.spec.js).
They cannot run in a plain checkout: the signed-out staging gate runs anonymously
against a live deploy, so it never touches auth, Firestore, the cloud functions,
or Paddle. This doc is the concrete recipe to stand that infra up locally and flip
each `test.fixme(...)` → `test(...)`.
The original scaffold notes (kept for reference): they cannot run in a plain
checkout because the signed-out staging gate runs anonymously against a live deploy,
so it never touches auth, Firestore, the cloud functions, or Paddle.

What unlocks what (from the gap-plan coverage map):

Expand Down Expand Up @@ -95,33 +133,29 @@ the emulator.

---

## 3. Seed a test user (Auth emulator)

The Auth emulator accepts unsigned tokens and lets you create users via its REST
API or the admin SDK — no real Google/GitHub OAuth round-trip. Two ways:

**A. Admin SDK (deterministic, recommended)** — a global-setup script mints users
and custom tokens against the emulator:

```js
// e2e/cloud/seed.mjs (run from Playwright globalSetup)
import admin from 'firebase-admin';
process.env.FIRESTORE_EMULATOR_HOST = 'localhost:8080';
process.env.FIREBASE_AUTH_EMULATOR_HOST = 'localhost:9099';
admin.initializeApp({ projectId: 'web-sequence-local' });

export async function seedUser(uid, email) {
await admin
.auth()
.createUser({ uid, email, displayName: 'E2E User' })
.catch(() => {});
return admin.auth().createCustomToken(uid); // the test signs in with this
}
```

**B. Emulator REST** — `POST http://localhost:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=fake`
with `{ email, password, returnSecureToken: true }`. Returns an idToken you can
inject. Heavier than the admin SDK; prefer A.
## 3. How the deterministic test sign-in actually works (no admin SDK)

The chosen approach signs in entirely **client-side** with an **unsigned Firebase
custom token** — no `firebase-admin` and no service-account key in the browser:

- The Auth emulator does NOT verify the custom-token signature, so a JWT with
`alg:"none"` + an empty signature, minted for a chosen `uid`, is accepted by
`signInWithCustomToken`. (Validated: `accounts:signInWithCustomToken` returns 200
and an idToken whose `user_id` is exactly the uid we put in.)
- `web/src/services/firebase.ts`, gated on `VITE_USE_EMULATOR === '1'`:
- connects the SDK to the Auth (:9099) + Firestore (:8080) emulators;
- replaces popup `login(provider)` with a popup-free emulator sign-in (so the
real `login-google` / `login-github` buttons sign in deterministically);
- exposes `window.__e2eSignIn({ uid?, email? })` and a one-shot
`window.__e2eForceAuthError(code)` (AUTH-4).
- The uid is derived from the email (`e2e-<slugified-email>`) OR passed explicitly,
so seeded Firestore docs (`users/{uid}`, `items.createdBy`,
`user_subscriptions/user-{uid}`) line up with the signed-in session. The test-side
mirror is `uidForEmail()` in `e2e/tests/helpers/cloud.js`.

The spec's single sign-in seam is `signInViaEmulator(page, { uid?, email? })` in
`e2e/tests/helpers/cloud.js` — it waits for the dev hook, calls it, and waits for
`profile-trigger` to mount.

---

Expand Down
142 changes: 142 additions & 0 deletions e2e/cloud/firestoreEmu.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Firestore EMULATOR REST helpers — seed + probe the local emulator with ZERO
// extra dependencies (no firebase-admin at the repo root; firebase-admin only
// lives in functions/node_modules and pulling it into the Playwright runner would
// bloat the root install). The Firestore emulator exposes the standard Firestore
// REST surface at http://<host>/v1/projects/<projectId>/databases/(default)/documents
// and — being an emulator — requires NO auth token and bypasses security rules.
//
// Used by:
// - the cloud spec's in-test "side-effect" probes (IOL-2/3, SUB-4, NET-2, SET-7):
// assert a Firestore doc exists / is absent / holds a value, not just the DOM.
// - seeding shared items (SHR-5..8, PST-3/4, HDR-4, EMB-1) and paid-user docs
// (SUB-5) so the functions emulator / the client read a known fixture.
//
// projectId MUST match the web client + functions emulator: both resolve to
// 'staging-zenuml-27954' on localhost (firebaseConfig defaultConfig / functions
// FUNCTIONS_EMULATOR branch), so the docs the client writes and the docs we probe
// are the SAME database.

const HOST = process.env.FIRESTORE_EMULATOR_HOST || '127.0.0.1:8080';
const PROJECT_ID = process.env.E2E_PROJECT_ID || 'staging-zenuml-27954';
const BASE = `http://${HOST}/v1/projects/${PROJECT_ID}/databases/(default)/documents`;

// firestore.rules is enforced by the emulator EVEN over REST. The emulator grants
// FULL admin access (bypassing all rules) to requests carrying `Authorization:
// Bearer owner` — this is the documented emulator backdoor used by firebase-admin.
// Seeding/probing as admin lets a test set up arbitrary fixtures (items owned by a
// uid, paid-user subscription docs) the client could never write directly.
const ADMIN_HEADERS = { 'Content-Type': 'application/json', Authorization: 'Bearer owner' };

// ── Firestore <-> JS value (de)serialization for the REST `fields` shape ───────
function toValue(v) {
if (v === null || v === undefined) return { nullValue: null };
if (typeof v === 'boolean') return { booleanValue: v };
if (typeof v === 'number') return Number.isInteger(v) ? { integerValue: String(v) } : { doubleValue: v };
if (typeof v === 'string') return { stringValue: v };
if (Array.isArray(v)) return { arrayValue: { values: v.map(toValue) } };
if (typeof v === 'object') return { mapValue: { fields: toFields(v) } };
return { stringValue: String(v) };
}
function toFields(obj) {
const fields = {};
for (const [k, val] of Object.entries(obj)) fields[k] = toValue(val);
return fields;
}
function fromValue(v) {
if (!v) return undefined;
if ('nullValue' in v) return null;
if ('booleanValue' in v) return v.booleanValue;
if ('integerValue' in v) return Number(v.integerValue);
if ('doubleValue' in v) return v.doubleValue;
if ('stringValue' in v) return v.stringValue;
if ('timestampValue' in v) return v.timestampValue;
if ('arrayValue' in v) return (v.arrayValue.values || []).map(fromValue);
if ('mapValue' in v) return fromFields(v.mapValue.fields || {});
return undefined;
}
function fromFields(fields) {
const out = {};
for (const [k, val] of Object.entries(fields || {})) out[k] = fromValue(val);
return out;
}

// Create OR overwrite a document at `path` (e.g. 'items/abc' or 'users/uid').
// Uses PATCH (create-or-replace) so seeding is idempotent across re-runs.
export async function setDoc(path, data) {
const res = await fetch(`${BASE}/${path}`, {
method: 'PATCH',
headers: ADMIN_HEADERS,
body: JSON.stringify({ fields: toFields(data) }),
});
if (!res.ok) throw new Error(`setDoc ${path} failed: ${res.status} ${await res.text()}`);
return fromFields((await res.json()).fields || {});
}

// Read a document; returns the JS object or null if it does not exist.
export async function getDoc(path) {
const res = await fetch(`${BASE}/${path}`, { headers: ADMIN_HEADERS });
if (res.status === 404) return null;
if (!res.ok) throw new Error(`getDoc ${path} failed: ${res.status} ${await res.text()}`);
const body = await res.json();
return fromFields(body.fields || {});
}

// Delete a document (best-effort; 404 is fine).
export async function deleteDoc(path) {
const res = await fetch(`${BASE}/${path}`, { method: 'DELETE', headers: ADMIN_HEADERS });
if (!res.ok && res.status !== 404) throw new Error(`deleteDoc ${path} failed: ${res.status}`);
}

// List doc ids in a collection (e.g. 'items'). Returns [] when empty/missing.
export async function listDocIds(collection) {
const res = await fetch(`${BASE}/${collection}`, { headers: ADMIN_HEADERS });
if (!res.ok) return [];
const body = await res.json();
return (body.documents || []).map((d) => d.name.split('/').pop());
}

// Run a structured query: items where createdBy == uid. Returns matched docs.
export async function queryItemsByOwner(uid) {
const res = await fetch(`${BASE}:runQuery`, {
method: 'POST',
headers: ADMIN_HEADERS,
body: JSON.stringify({
structuredQuery: {
from: [{ collectionId: 'items' }],
where: {
fieldFilter: {
field: { fieldPath: 'createdBy' },
op: 'EQUAL',
value: { stringValue: uid },
},
},
},
}),
});
if (!res.ok) throw new Error(`queryItemsByOwner failed: ${res.status} ${await res.text()}`);
const rows = await res.json();
return rows
.filter((r) => r.document)
.map((r) => ({ id: r.document.name.split('/').pop(), ...fromFields(r.document.fields || {}) }));
}

// Wipe everything an emulator project knows (auth users + firestore docs). Used in
// globalSetup so each `cloud` run starts from a clean slate regardless of prior runs.
export async function clearFirestore() {
const res = await fetch(
`http://${HOST}/emulator/v1/projects/${PROJECT_ID}/databases/(default)/documents`,
{ method: 'DELETE' },
);
if (!res.ok) throw new Error(`clearFirestore failed: ${res.status} ${await res.text()}`);
}

export async function clearAuth() {
const authHost = process.env.FIREBASE_AUTH_EMULATOR_HOST || '127.0.0.1:9099';
const res = await fetch(`http://${authHost}/emulator/v1/projects/${PROJECT_ID}/accounts`, {
method: 'DELETE',
});
// 404/200 both acceptable; only a connection error is fatal.
if (!res.ok && res.status !== 404) throw new Error(`clearAuth failed: ${res.status}`);
}

export { PROJECT_ID, HOST, BASE };
28 changes: 28 additions & 0 deletions e2e/cloud/globalSetup.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Playwright globalSetup for the `cloud` project — runs ONCE before the
// emulator-backed specs. It wipes the Auth + Firestore emulators so each run
// starts from a clean slate (prior runs' seeded items/users/subscriptions don't
// leak into a fresh run). The emulators are booted by the cloud webServer
// (firebase emulators:exec wrapping the dev server — see playwright.config.js), so
// by the time globalSetup runs they're already listening; we just clear them.
//
// This setup is ONLY registered on the cloud project's config path, never the
// default chromium suite, so the signed-out staging gate is untouched.
import { clearFirestore, clearAuth } from './firestoreEmu.mjs';

export default async function globalSetup() {
// Best-effort: if the emulators aren't up yet (rare race) we retry briefly so the
// first spec doesn't see stale data. A hard failure here would abort the run, which
// is the right signal that the emulator webServer never came up.
let lastErr;
for (let i = 0; i < 20; i++) {
try {
await clearFirestore();
await clearAuth();
return;
} catch (e) {
lastErr = e;
await new Promise((r) => setTimeout(r, 500));
}
}
throw new Error(`cloud globalSetup: emulators not reachable to clear — ${lastErr}`);
}
Loading
Loading