diff --git a/.gitignore b/.gitignore index 7d414e09..d642d3b7 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/e2e/EMULATOR_SETUP.md b/e2e/EMULATOR_SETUP.md index 4ccb7a8d..95654dac 100644 --- a/e2e/EMULATOR_SETUP.md +++ b/e2e/EMULATOR_SETUP.md @@ -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): @@ -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-`) 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. --- diff --git a/e2e/cloud/firestoreEmu.mjs b/e2e/cloud/firestoreEmu.mjs new file mode 100644 index 00000000..a02b3707 --- /dev/null +++ b/e2e/cloud/firestoreEmu.mjs @@ -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:///v1/projects//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 }; diff --git a/e2e/cloud/globalSetup.mjs b/e2e/cloud/globalSetup.mjs new file mode 100644 index 00000000..f095f87d --- /dev/null +++ b/e2e/cloud/globalSetup.mjs @@ -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}`); +} diff --git a/e2e/tests/cloud.fixme.spec.js b/e2e/tests/cloud.fixme.spec.js index dab79c2b..d38c5c4d 100644 --- a/e2e/tests/cloud.fixme.spec.js +++ b/e2e/tests/cloud.fixme.spec.js @@ -1,685 +1,45 @@ -// Infra-gated E2E gap cases — AUTH / CLOUD / PADDLE buckets from -// e2e/E2E_GAP_TEST_PLAN.md, captured as a runnable-but-PENDING scaffold. -// -// WHY EVERY CASE IS test.fixme(): -// These cases need infrastructure that is NOT available in this checkout: -// - the Firebase Emulator Suite (auth + firestore + functions), and -// - a Paddle sandbox / a mocked `usePaddle` openCheckout. -// The signed-out staging gate cannot reach them (it runs anonymously against a -// live deploy). Rather than delete or fake-pass them, each is written as a real -// body sketch wrapped in `test.fixme(title, fn)`. Playwright reports a fixme -// test as PENDING (skipped) — it is NEVER executed, so the body cannot fail the -// suite, and it cannot fake-pass either. The moment the emulator project exists -// (see e2e/EMULATOR_SETUP.md), flip `test.fixme` → `test` per case and the body -// is already written against the real selectors. -// -// SELECTORS ARE REAL: every data-testid referenced below was read out of the -// live components (web/src/components/**), so the sketches are accurate, not -// guesses. The bodies are best-effort and may need small timing/seed tweaks once -// the emulator is wired — that is exactly what un-fixme-ing each case will surface. -// -// NAMING: each fixme title starts with its gap-plan ID and a one-line -// "needs: " note, so `--list` reads as a -// checklist of what infra unlocks what. -// -// HELPERS reused from the signed-out specs (openEditor/gotoHome/typeDsl pattern): -// the emulator project keeps the same page-driving shape; only auth/seed change. +// Residual infra-gated case(s) that the emulator alone does NOT unlock. The bulk of +// the AUTH / CLOUD / PADDLE gap buckets are now LIVE emulator-backed tests in +// e2e/tests/cloud.spec.js (run via the `cloud` Playwright project). What remains here +// is blocked by a MISSING PRODUCT AFFORDANCE, not by missing infrastructure — flipping +// it to a real test would require a UI that does not exist, so it stays test.fixme +// with the exact blocker documented (never faked). import { test, expect } from '@playwright/test'; -import { suppressOneTimeModals } from './helpers/onetime'; -import { openEditor, gotoHome } from './helpers/hub'; - -const selectAll = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; - -// ── shared sketch helpers (intentionally NOT exported; these run only once the -// emulator project is live, so they live beside the cases that use them) ───── - -/** Editor CM6 content surface. */ -function editorLocator(page) { - return page.locator('[data-testid="dsl-editor"] .cm-content'); -} - -/** - * Sign in via the emulator. PLACEHOLDER until the emulator project exists: - * with the Auth emulator wired (see EMULATOR_SETUP.md §3), real flows are either - * (a) seed a custom token into the page and call signInWithCustomToken, or - * (b) drive the emulator's popup provider stub. - * Sketch (a) — the deterministic one — is shown; it needs `firebase/auth` exposed - * on window in dev OR an addInitScript that signs in before AppRoot boots. - */ -async function signInViaEmulator( - page, - { uid = 'e2e-user', email = 'e2e@test.local' } = {}, -) { - // needs: Auth emulator + a test custom token (EMULATOR_SETUP.md §3/§4) - await page.evaluate( - async ({ uid, email }) => { - // window.__e2eSignIn is the dev-only test hook described in EMULATOR_SETUP.md §4. - // It wraps signInWithCustomToken(auth, ). - await window.__e2eSignIn?.({ uid, email }); - }, - { uid, email }, - ); - // ProfileMenu's trigger only mounts once onAuthStateChanged fires signed-in. - await expect(page.locator('[data-testid="profile-trigger"]')).toBeVisible({ - timeout: 15_000, - }); -} - -/** Boot the editor with a clean slate + one-time modals suppressed. */ -async function gotoFresh(page) { - await suppressOneTimeModals(page); - await page.goto('/'); - await page.evaluate(() => localStorage.clear()); - await openEditor(page); -} - -/** Type a replacement DSL into the editor (select-all → Delete → type). */ -async function typeDsl(page, dsl) { - const editor = editorLocator(page); - await expect(editor).toBeVisible({ timeout: 15_000 }); - await editor.click(); - await page.keyboard.press(selectAll); - await page.keyboard.press('Delete'); - await editor.pressSequentially(dsl); -} - -// ════════════════════════════════════════════════════════════════════════════ -// §9 Authentication (AUTH-1..6) — Firebase Auth emulator -// ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'AUTH-1 — needs: emulator (auth) — login modal lists Google/GitHub/Facebook/Twitter', - async ({ page }) => { - await gotoFresh(page); - // Header "Sign in" opens LoginModal. (header-login → AppHeader.tsx:379) - await page.locator('[data-testid="header-login"]').click(); - // LoginModal renders one button per provider, testid login- - // (web/src/components/auth/LoginModal.tsx:87 → `login-${id}`). - for (const id of ['google', 'github', 'facebook', 'twitter']) { - await expect(page.locator(`[data-testid="login-${id}"]`)).toBeVisible(); - } - }, -); - -test.fixme( - 'AUTH-2 — needs: emulator (auth) — Google sign-in → profile menu shows name/photo/plan', - async ({ page }) => { - await gotoFresh(page); - await signInViaEmulator(page, { uid: 'auth2', email: 'auth2@test.local' }); - // ProfileMenu trigger present; opening it reveals the plan row (profile-plan). - await page.locator('[data-testid="profile-trigger"]').click(); - await expect(page.locator('[data-testid="profile-plan"]')).toBeVisible(); - // Free account → "Free" / Starter copy in the plan row. - await expect(page.locator('[data-testid="profile-plan"]')).toContainText( - /free|starter/i, - ); - }, -); - -test.fixme( - 'AUTH-3 — needs: emulator (auth) — last-used provider re-surfaces as primary with a "Last used" chip', - async ({ page }) => { - await gotoFresh(page); - // Sign in with GitHub, then sign out. LoginModal persists lastProvider. - await page.locator('[data-testid="header-login"]').click(); - await page.locator('[data-testid="login-github"]').click(); // emulator GitHub stub completes - await page.locator('[data-testid="profile-trigger"]').click(); - await page.locator('[data-testid="profile-logout"]').click(); - // Reopen → GitHub elevated (cobalt primary) + login-github-lastused chip - // (LoginModal.tsx:105 → `login-${id}-lastused`). - await page.locator('[data-testid="header-login"]').click(); - await expect( - page.locator('[data-testid="login-github-lastused"]'), - ).toBeVisible(); - }, -); - -test.fixme( - 'AUTH-4 — needs: emulator (auth) — auth error surfaces in the modal alert', - async ({ page }) => { - await gotoFresh(page); - await page.locator('[data-testid="header-login"]').click(); - // Force the provider to reject (emulator: configure the provider to fail, or - // stub signInWithPopup to throw). LoginModal renders the message in login-error - // (LoginModal.tsx:128). - await page.evaluate(() => { - window.__e2eForceAuthError?.('auth/popup-closed-by-user'); - }); - await page.locator('[data-testid="login-google"]').click(); - await expect(page.locator('[data-testid="login-error"]')).toBeVisible(); - }, -); - -test.fixme( - 'AUTH-5 — needs: emulator (auth) — login modal auto-dismisses once auth resolves (P5 regression)', - async ({ page }) => { - await gotoFresh(page); - await page.locator('[data-testid="header-login"]').click(); - await expect(page.locator('[data-testid="login-google"]')).toBeVisible(); - await signInViaEmulator(page); // resolves onAuthStateChanged - // Modal closes automatically — the provider buttons unmount. - await expect(page.locator('[data-testid="login-google"]')).toBeHidden(); - }, -); - -test.fixme( - 'AUTH-6 — needs: emulator (auth) — sign-out clears session, hides profile menu, restores "Sign in"', - async ({ page }) => { - await gotoFresh(page); - await signInViaEmulator(page); - await page.locator('[data-testid="profile-trigger"]').click(); - await page.locator('[data-testid="profile-logout"]').click(); - // Signed-out chrome: profile gone, header-login back. - await expect(page.locator('[data-testid="profile-trigger"]')).toBeHidden(); - await expect(page.locator('[data-testid="header-login"]')).toBeVisible(); - }, -); +import { signInViaEmulator, gotoFresh, gotoHome } from './helpers/cloud'; // ════════════════════════════════════════════════════════════════════════════ -// §10 Import-on-login (IOL-1..3) — Auth (+ Firestore for the upload assertion) +// §5 Folders — FLD-2 // ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'IOL-1 — needs: emulator (auth) — first sign-in with local diagrams offers AskToImportModal', - async ({ page }) => { - await gotoFresh(page); - // Seed a local item first (save signed-out → localItems index). - await typeDsl(page, 'A\nB\nA->B: local'); - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="header-save"]').click(); - await page - .locator('[data-testid="confirm-cancel"]') - .click() - .catch(() => {}); // first-save notice - // Sign in → useImportOnLogin detects locals → AskToImportModal - // (import-confirm / import-dismiss → AskToImportModal.tsx:28/41). - await signInViaEmulator(page); - await expect(page.locator('[data-testid="import-confirm"]')).toBeVisible(); - }, -); - -test.fixme( - 'IOL-2 — needs: emulator (auth+firestore) — accepting import uploads locals to the cloud account', - async ({ page }) => { - await gotoFresh(page); - await typeDsl(page, 'A\nB\nA->B: local'); - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="header-save"]').click(); - await page - .locator('[data-testid="confirm-cancel"]') - .click() - .catch(() => {}); - await signInViaEmulator(page); - await page.locator('[data-testid="import-confirm"]').click(); - // After a fresh reload signed-in, the item is read from the cloud account. - await gotoHome(page); - await expect(page.locator('[data-testid^="home-card-"]')).toHaveCount(1); - // STRONGER assertion (needs Firestore admin probe): query the emulator's - // users/{uid}/items and assert the doc exists. See EMULATOR_SETUP.md §5. - }, -); - +// +// BLOCKER (UI gap, NOT emulator): "move a diagram into a folder" has no affordance +// in the editor-as-landing hub. The hub's HomeView renders diagrams as `DiagramCard` +// (web/src/components/home/DiagramCard.tsx) whose ONLY testid is `home-card-{id}` — +// it exposes Open / Delete / Fork / Export, but NO "Move to folder" menu. The only +// move-to-folder UI in the codebase is `lib-move-{itemId}-{folderId}` in +// web/src/components/library/LibraryItemRow.tsx (the LibraryPanel LIST view), which +// the editor-as-landing hub does not mount. So there is no clickable path from a +// HomeView card to a folder, and the per-card kebab move-menu the original sketch +// assumed does not exist. +// +// To un-fixme: add a "Move to folder" action to DiagramCard (wiring HomeView's +// existing `onMoveItem` prop, which is declared but currently unused — see +// HomeView.tsx:58 `onMoveItem?(...)`) with a `home-card-move-{id}-{folderId}` testid, +// then drive: seed item + folder → open the card menu → click the folder target → +// assert the Work count increments to 1 and folder-unfiled decrements. The Firestore +// side-effect (item.folderId updated) is probeable today via e2e/cloud/firestoreEmu.mjs; +// only the click target is missing. test.fixme( - 'IOL-3 — needs: emulator (auth+firestore) — declining keeps locals untouched, no cloud write', + 'FLD-2 — needs: DiagramCard "Move to folder" affordance (UI gap, not emulator) — move a diagram into a folder; counts update; Unfiled decrements', async ({ page }) => { await gotoFresh(page); - await typeDsl(page, 'A\nB\nA->B: local'); - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="header-save"]').click(); - await page - .locator('[data-testid="confirm-cancel"]') - .click() - .catch(() => {}); - await signInViaEmulator(page); - await page.locator('[data-testid="import-dismiss"]').click(); - // Firestore admin probe: assert users/{uid}/items is empty (no upload). - // EMULATOR_SETUP.md §5 shows the admin-SDK read against the emulator. - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §5 Folders (FLD-1..5) — Auth + Firestore (folders live on users/{uid}.folders) -// ════════════════════════════════════════════════════════════════════════════ -// Folders are cloud-backed (useFolders.ts / folderService.ts write users/{uid}), -// so they need the Firestore emulator + a signed-in session. - -test.fixme( - 'FLD-1 — needs: emulator (auth+firestore) — create folder "Work" appears with count 0', - async ({ page }) => { - await signInViaEmulator(page); - await gotoHome(page); - // folder-new → folder-new-input (testids confirmed in the folder UI). - await page.locator('[data-testid="folder-new"]').click(); - await page.locator('[data-testid="folder-new-input"]').fill('Work'); - await page.keyboard.press('Enter'); - await expect(page.getByText('Work', { exact: true })).toBeVisible(); - // Count chip reads 0 on a fresh folder. - }, -); - -test.fixme( - 'FLD-2 — needs: emulator (auth+firestore) — move a diagram into a folder; counts update; Unfiled decrements', - async ({ page }) => { - await signInViaEmulator(page); - await gotoHome(page); - // Seed item + folder, then move via the card kebab → "Move to". Assert the - // Work count increments to 1 and folder-unfiled decrements. - // (Exact move-menu testid TBD when the emulator project lands — folder-${id}.) - }, -); - -test.fixme( - 'FLD-3 — needs: emulator (auth+firestore) — rename folder commits the new label', - async ({ page }) => { - await signInViaEmulator(page); - await gotoHome(page); - // Open the folder row menu → Rename → type → Enter; assert new label visible. - }, -); - -test.fixme( - 'FLD-4 — needs: emulator (auth+firestore) — delete folder returns its items to Unfiled', - async ({ page }) => { - await signInViaEmulator(page); - await gotoHome(page); - // Delete a folder holding 1 item; assert folder-unfiled count increments and - // the item still appears under Unfiled. - }, -); - -test.fixme( - 'FLD-5 — needs: emulator (auth+firestore) — "All" vs "Unfiled" filters the grid', - async ({ page }) => { await signInViaEmulator(page); await gotoHome(page); - // folder-all shows every card; folder-unfiled shows only unfoldered cards. - await page.locator('[data-testid="folder-all"]').click(); - const allCount = await page.locator('[data-testid^="home-card-"]').count(); - await page.locator('[data-testid="folder-unfiled"]').click(); - const unfiledCount = await page - .locator('[data-testid^="home-card-"]') - .count(); - expect(unfiledCount).toBeLessThanOrEqual(allCount); - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §11 Sharing (SHR-1..8) — SHR-1 is AUTH-only; SHR-2..8 need Firestore+functions -// ════════════════════════════════════════════════════════════════════════════ -// create-share / get-shared-item are cloud functions (firebase.json rewrites); -// they read the cloud item doc and need a fresh ID token → emulator functions. - -test.fixme( - 'SHR-1 — needs: emulator (auth) — Share while signed-out opens the sign-in modal first', - async ({ page }) => { - await gotoFresh(page); - // ShareButton is signed-out-gated: clicking it should route to LoginModal. - await page.locator('[data-testid="share-button"]').click(); - await expect(page.locator('[data-testid="login-google"]')).toBeVisible(); - }, -); - -test.fixme( - 'SHR-2 — needs: emulator (auth+firestore+functions) — create share link → popover shows ?id=&share-token= URL', - async ({ page }) => { - await signInViaEmulator(page); - await gotoFresh(page); - await typeDsl(page, 'A\nB\nA->B: share me'); - // Save so the item exists in the cloud (create-share reads the cloud doc). - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="header-save"]').click(); - // Open share popover → Create link (share-create → SharePopover.tsx:82), - // then assert the URL field carries both params (share-url → :36). - await page.locator('[data-testid="share-button"]').click(); - await page.locator('[data-testid="share-create"]').click(); - const url = page.locator('[data-testid="share-url"]'); - await expect(url).toBeVisible(); - await expect(url).toHaveValue(/[?&]id=.+&share-token=.+/); - }, -); - -test.fixme( - 'SHR-3 — needs: emulator (auth+firestore+functions) — Copy copies the URL and shows "Copied ✓"', - async ({ page, context }) => { - await context.grantPermissions(['clipboard-read', 'clipboard-write']); - await signInViaEmulator(page); - await gotoFresh(page); - await typeDsl(page, 'A\nB\nA->B: copy'); - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="header-save"]').click(); - await page.locator('[data-testid="share-button"]').click(); - await page.locator('[data-testid="share-create"]').click(); - await page.locator('[data-testid="share-copy"]').click(); // SharePopover.tsx:45 - const clip = await page.evaluate(() => navigator.clipboard.readText()); - expect(clip).toMatch(/[?&]id=.+&share-token=.+/); - }, -); - -test.fixme( - 'SHR-4 — needs: emulator (auth+firestore+functions) — Stop-sharing revokes the token', - async ({ page }) => { - await signInViaEmulator(page); - await gotoFresh(page); - await typeDsl(page, 'A\nB\nA->B: revoke'); - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="header-save"]').click(); - await page.locator('[data-testid="share-button"]').click(); - await page.locator('[data-testid="share-create"]').click(); - const url = await page.locator('[data-testid="share-url"]').inputValue(); - await page.locator('[data-testid="share-stop"]').click(); // SharePopover.tsx:56 - // Re-visiting the now-revoked URL should NOT render the diagram (token gone). - await page.goto(url); - await expect( - page.locator('[data-testid="share-error-text"]'), - ).toBeVisible(); - }, -); - -test.fixme( - 'SHR-5 — needs: emulator (firestore+functions) — opening a share URL renders read-only + "Open in ZenUML"', - async ({ page }) => { - // Pre-seed a shared item in Firestore (admin SDK, EMULATOR_SETUP.md §5) and - // build ${origin}/?id=&share-token=. useBootItem calls getSharedItem - // (cloudFunctions.ts:51) → item with isReadOnly:true. - const id = 'seeded-shared-id'; - const token = 'seeded-token'; - await page.goto(`/?id=${id}&share-token=${token}`); - await expect(editorLocator(page)).toBeVisible(); - // Read-only chrome: header-savestate is in the "readonly" state (AppHeader.tsx:488). - await expect( - page.locator('[data-testid="header-savestate"]'), - ).toHaveAttribute('data-state', 'readonly'); - }, -); - -test.fixme( - 'SHR-6 — needs: emulator (firestore+functions) — Fork a shared item → owned editable copy (clears readonly/shareToken)', - async ({ page }) => { - await signInViaEmulator(page); - const id = 'seeded-shared-id'; - const token = 'seeded-token'; - await page.goto(`/?id=${id}&share-token=${token}`); - // Fork lives in the header file-menu (filemenu-duplicate → AppHeader.tsx:293). - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="filemenu-duplicate"]').click(); - // After fork: savestate leaves readonly (becomes dirty/saved, owned copy). - await expect( - page.locator('[data-testid="header-savestate"]'), - ).not.toHaveAttribute('data-state', 'readonly'); - }, -); - -test.fixme( - 'SHR-7 — needs: emulator (firestore+functions) — bad share link → ShareErrorNotice with "Start fresh"', - async ({ page }) => { - // getSharedItem rejects for an unknown token → useBootItem yields share-error. - await page.goto('/?id=does-not-exist&share-token=bad'); - await expect(page.locator('[data-testid="share-error"]')).toBeVisible(); - await expect( - page.locator('[data-testid="share-error-fresh"]'), - ).toBeVisible(); // "Start fresh" - }, -); - -test.fixme( - 'SHR-8 — needs: emulator (firestore+functions) — Share button disabled for read-only items', - async ({ page }) => { - await signInViaEmulator(page); - await page.goto('/?id=seeded-shared-id&share-token=seeded-token'); - await expect(editorLocator(page)).toBeVisible(); - // ShareButton renders disabled for read-only items (ShareButton.tsx:60 branch). - await expect(page.locator('[data-testid="share-button"]')).toBeDisabled(); - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §13 Subscription / pricing (SUB-2/3/4/5) -// ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'SUB-2 — needs: paddle (mock usePaddle.openCheckout) — Upgrade (signed-in) opens Paddle checkout for the chosen plan', - async ({ page }) => { - // Install a window.Paddle stub BEFORE app boot so usePaddle.ensurePaddle() - // picks it up (usePaddle.ts:61 — "Paddle already present" path skips the CDN). - // EMULATOR_SETUP.md §6 shows the addInitScript stub that records Checkout.open. - await page.addInitScript(() => { - window.__paddleCalls = []; - window.Paddle = { - Setup() {}, - Checkout: { open: (opts) => window.__paddleCalls.push(opts) }, - }; - }); - await signInViaEmulator(page); - await gotoFresh(page); - await page.locator('[data-testid="header-pricing"]').click(); // open PricingModal - await page.locator('[data-testid="pricing-period-monthly"]').click(); - // Click an Upgrade CTA on the Plus tier (profile-upgrade / pricing tier button). - await page.locator('[data-testid="profile-upgrade"]').first().click(); - // usePaddle passes JSON.stringify({ userId, planType }) as passthrough. - const calls = await page.evaluate(() => window.__paddleCalls); - expect(calls.length).toBe(1); - expect(JSON.parse(calls[0].passthrough)).toMatchObject({ - planType: 'plus-monthly', - }); - }, -); - -test.fixme( - 'SUB-3 — needs: emulator (auth) + paddle (mock) — anonymous Upgrade → sign-in → resumes checkout for captured plan', - async ({ page }) => { - await page.addInitScript(() => { - window.__paddleCalls = []; - window.Paddle = { - Setup() {}, - Checkout: { open: (o) => window.__paddleCalls.push(o) }, - }; - }); - await gotoFresh(page); - await page.locator('[data-testid="header-pricing"]').click(); - // Anonymous Upgrade should stash the plan and open LoginModal first. - await page - .locator('[data-testid="profile-upgrade"]') - .first() - .click() - .catch(() => {}); - await expect(page.locator('[data-testid="login-google"]')).toBeVisible(); - await signInViaEmulator(page); - // After auth resolves, checkout resumes for the captured plan (legacy product 5751479). - const calls = await page.evaluate(() => window.__paddleCalls); - expect(calls.length).toBe(1); - }, -); - -test.fixme( - 'SUB-4 — needs: emulator (auth+firestore) — free user over 3 diagrams → limit notice + "Free Limit" event; local kept, cloud write withheld', - async ({ page }) => { - await signInViaEmulator(page, { uid: 'free-cap-user' }); - await gotoFresh(page); - // Seed 3 saved diagrams (the free cap), then attempt a 4th save. - // LimitReachedNotice mounts (limit-notice → LimitReachedNotice.tsx:25) with a - // limit-upgrade CTA (:41). Firestore admin probe: assert the 4th doc was NOT - // written (cloud write withheld) while the local copy is kept. - // ... seed loop omitted in the sketch; see EMULATOR_SETUP.md §5 for the probe. - await expect(page.locator('[data-testid="limit-notice"]')).toBeVisible(); - await expect(page.locator('[data-testid="limit-upgrade"]')).toBeVisible(); - }, -); - -test.fixme( - 'SUB-5 — needs: emulator (auth) — "Manage subscription" link present for paid users (Paddle cancel URL)', - async ({ page }) => { - // Seed a paid (plus) user doc in Firestore so resolved plan === plus, then open - // the profile menu and assert the manage-subscription link is present. - await signInViaEmulator(page, { uid: 'plus-user' }); - await page.locator('[data-testid="profile-trigger"]').click(); - await expect(page.locator('[data-testid="profile-plan"]')).toContainText( - /plus/i, - ); - // Manage-subscription link (Paddle cancel URL) — exact testid TBD when wired. - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §8 Persistence / autosave (PST-2/3/4) -// ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'PST-2 — needs: emulator (auth) — save indicator transitions Unsaved → Saving… → Saved (signed-in)', - async ({ page }) => { - await signInViaEmulator(page); - await gotoFresh(page); - await typeDsl(page, 'A\nB\nA->B: dirty'); - // header-savestate cycles data-state dirty → saving → saved (AppHeader.tsx:449/457/471). - await expect( - page.locator('[data-testid="header-savestate"]'), - ).toHaveAttribute('data-state', 'dirty'); - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="header-save"]').click(); - await expect( - page.locator('[data-testid="header-savestate"]'), - ).toHaveAttribute('data-state', 'saved'); - }, -); - -test.fixme( - 'PST-3 — needs: emulator (firestore+functions) — read-only (shared) item: Save disabled, Fork offered', - async ({ page }) => { - await page.goto('/?id=seeded-shared-id&share-token=seeded-token'); - await expect(editorLocator(page)).toBeVisible(); - // Read-only: savestate is "readonly", Save is unavailable, Fork (filemenu-duplicate) offered. - await expect( - page.locator('[data-testid="header-savestate"]'), - ).toHaveAttribute('data-state', 'readonly'); - await page.locator('[data-testid="header-menu"]').click(); - await expect(page.locator('[data-testid="header-save"]')).toBeDisabled(); - await expect( - page.locator('[data-testid="filemenu-duplicate"]'), - ).toBeVisible(); - }, -); - -test.fixme( - 'PST-4 — needs: emulator (firestore+functions) — read-only item is NOT written to the last-code slot', - async ({ page }) => { - await page.goto('/?id=seeded-shared-id&share-token=seeded-token'); - await expect(editorLocator(page)).toBeVisible(); - const sharedFirstLine = (await editorLocator(page).innerText()).split( - '\n', - )[0]; - // Reload bare '/' — editor-as-landing resumes last-code; the shared item must - // NOT be it (read-only items skip the last-code write). - await page.goto('/'); - await expect(editorLocator(page)).toBeVisible(); - await expect(editorLocator(page)).not.toContainText(sharedFirstLine); - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §14 Header / title / actions (HDR-4/6) -// ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'HDR-4 — needs: emulator (firestore+functions) — Fork duplicates current item into an owned copy', - async ({ page }) => { - await signInViaEmulator(page); - await page.goto('/?id=seeded-shared-id&share-token=seeded-token'); - await expect(editorLocator(page)).toBeVisible(); - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="filemenu-duplicate"]').click(); - // New owned, editable copy: savestate no longer readonly. - await expect( - page.locator('[data-testid="header-savestate"]'), - ).not.toHaveAttribute('data-state', 'readonly'); - }, -); - -test.fixme( - 'HDR-6 — needs: emulator (auth) — signed-out header shows "Sign in"; signed-in shows profile menu', - async ({ page }) => { - await gotoFresh(page); - await expect(page.locator('[data-testid="header-login"]')).toBeVisible(); - await expect(page.locator('[data-testid="profile-trigger"]')).toBeHidden(); - await signInViaEmulator(page); - await expect(page.locator('[data-testid="profile-trigger"]')).toBeVisible(); - await expect(page.locator('[data-testid="header-login"]')).toBeHidden(); - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §16 Embed (by-reference) (EMB-1) — Firestore + functions -// ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'EMB-1 — needs: emulator (firestore+functions) — embed by-reference ?embed&id=&share-token= renders the shared diagram', - async ({ page }) => { - // Seed a shared item (admin SDK), then visit the embed-by-reference URL. - // AppRoot's embed path calls getSharedItem and renders the embed-root surface. - const id = 'seeded-shared-id'; - const token = 'seeded-token'; - await page.goto(`/?embed&id=${id}&share-token=${token}`); - await expect(page.locator('[data-testid="embed-root"]')).toBeVisible(); - await expect(page.locator('[data-testid="embed-open-link"]')).toBeVisible(); - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §20 Connectivity (NET-2) — Auth + Firestore (offline → reconnect sync) -// ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'NET-2 — needs: emulator (auth+firestore) — reconnect resumes cloud sync without data loss', - async ({ page, context }) => { - await signInViaEmulator(page); - await gotoFresh(page); - await typeDsl(page, 'A\nB\nA->B: offline edit'); - // Go offline, edit + save locally, then reconnect; assert the edit syncs to the - // cloud (Firestore admin probe confirms the doc reflects the offline edit). - await context.setOffline(true); - await page.locator('[data-testid="header-menu"]').click(); - await page.locator('[data-testid="header-save"]').click(); - await context.setOffline(false); - // After reconnect, Firestore persistence flushes the queued write. Probe the - // emulator doc (EMULATOR_SETUP.md §5) to assert no data loss. - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §7 Settings (SET-7) — Auth + Firestore (settings persist to cloud) -// ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'SET-7 — needs: emulator (auth+firestore) — signed-in settings persist to cloud and survive a fresh session', - async ({ page }) => { - await signInViaEmulator(page, { uid: 'settings-user' }); - await gotoFresh(page); - // Open settings, change a value (e.g. theme via theme-select), close. - await page.locator('[data-testid="header-settings"]').click(); - await page - .locator('[data-testid="theme-select"]') - .selectOption({ index: 1 }); - // Sign out, clear localStorage, sign back in (fresh session — no local cache): - // the cloud-synced setting should be restored from Firestore. - await page.evaluate(() => localStorage.clear()); - await signInViaEmulator(page, { uid: 'settings-user' }); - await page.locator('[data-testid="header-settings"]').click(); - // Assert the previously-chosen theme is still selected (read from cloud). - }, -); - -// ════════════════════════════════════════════════════════════════════════════ -// §1 CSS gate (CSS-5) — Auth (non-plain CSS mode gated behind Plus) -// ════════════════════════════════════════════════════════════════════════════ - -test.fixme( - 'CSS-5 — needs: emulator (auth) — non-plain CSS mode gated behind Plus for free users (→ pricing)', - async ({ page }) => { - // Free signed-in user. Expand the CSS pane, switch the mode select to SCSS. - await signInViaEmulator(page, { uid: 'free-css-user' }); - await gotoFresh(page); - await page.locator('[data-testid="css-panel-strip"]').click(); // expand CSS pane - await page.locator('[data-testid="css-mode-select"]').selectOption('scss'); - // Gate fires for free users → pricing / upgrade prompt surfaces. - await expect(page.locator('[data-testid="pricing-modal"]')).toBeVisible(); + // No move-to-folder control exists on the HomeView card today; this body is the + // shape it should take once DiagramCard gains the affordance. + await expect(page.locator('[data-testid^="home-card-"]').first()).toBeVisible(); + // await page.locator('[data-testid="home-card--menu"]').click(); + // await page.locator('[data-testid="home-card-move--"]').click(); + // → assert folder count 1, Unfiled count decremented. }, ); diff --git a/e2e/tests/cloud.spec.js b/e2e/tests/cloud.spec.js new file mode 100644 index 00000000..578d8571 --- /dev/null +++ b/e2e/tests/cloud.spec.js @@ -0,0 +1,584 @@ +// Emulator-backed cloud E2E — the AUTH / CLOUD / PADDLE gap cases from +// e2e/E2E_GAP_TEST_PLAN.md, now LIVE against the Firebase Emulator Suite. +// +// HOW THIS RUNS (see playwright.config.js `cloud` project + e2e/EMULATOR_SETUP.md): +// - The cloud webServer boots `firebase emulators:exec --only auth,firestore, +// functions` (firebase-tools v13 from functions/node_modules — the global v9 +// CLI's pkg'd Node is too old to parse firebase-admin@11's optional chaining) +// wrapping `VITE_USE_EMULATOR=1 pnpm -C web dev`. +// - web/src/services/firebase.ts (gated on VITE_USE_EMULATOR) repoints the SDK at +// the Auth (:9099) + Firestore (:8080) emulators and exposes window.__e2eSignIn, +// which signs in a deterministic uid via an UNSIGNED custom token (the emulator +// doesn't verify the signature) — no popup, no firebase-admin in the browser. +// - cloudFunctions.ts uses same-origin relative fetch ('/create-share' …); the +// existing web/vite.config.ts proxy forwards those to the functions emulator. +// - Firestore side-effects are seeded/probed over the emulator's REST API with the +// `Authorization: Bearer owner` admin backdoor (e2e/cloud/firestoreEmu.mjs). +// +// Every data-testid here was read out of the live components (verified, not guessed). +// globalSetup wipes Auth+Firestore before the run so each spec starts clean. + +import { test, expect } from '@playwright/test'; +import { + signInViaEmulator, + gotoFresh, + gotoHome, + typeDsl, + editorLocator, + saveViaMenu, + uidForEmail, +} from './helpers/cloud'; +import { suppressOneTimeModals } from './helpers/onetime'; +import { + setDoc, + getDoc, + queryItemsByOwner, +} from '../cloud/firestoreEmu.mjs'; + +// A minimal cloud item doc owned by `uid`. The web client reads items/{id} where +// createdBy === uid (itemService) and the functions read the same doc. +function seedItem(uid, id, extra = {}) { + return setDoc(`items/${id}`, { + id, + title: extra.title ?? 'Seeded', + js: extra.js ?? 'A->B: seeded', + css: '', + html: '', + htmlMode: 'html', + cssMode: 'css', + jsMode: 'sequence', + createdBy: uid, + updatedOn: Date.now(), + isShared: extra.isShared ?? false, + shareToken: extra.shareToken ?? '', + folderId: extra.folderId ?? null, + pages: [], + currentPageId: '', + ...extra.fields, + }); +} + +// ════════════════════════════════════════════════════════════════════════════ +// §9 Authentication (AUTH-1..6) +// ════════════════════════════════════════════════════════════════════════════ + +test('AUTH-1 — login modal lists Google/GitHub/Facebook/Twitter', async ({ page }) => { + await gotoFresh(page); + await page.locator('[data-testid="header-login"]').click(); + for (const id of ['google', 'github', 'facebook', 'twitter']) { + await expect(page.locator(`[data-testid="login-${id}"]`)).toBeVisible(); + } +}); + +test('AUTH-2 — sign-in → profile menu shows the plan row (free)', async ({ page }) => { + await gotoFresh(page); + await signInViaEmulator(page, { uid: 'auth2', email: 'auth2@test.local' }); + await page.locator('[data-testid="profile-trigger"]').click(); + // Free user: profile-upgrade is the plan affordance (profile-plan only shows for + // subscribed users — paymentEnabled && subscribed). Assert the upgrade row. + await expect(page.locator('[data-testid="profile-upgrade"]')).toBeVisible(); +}); + +test('AUTH-3 — last-used provider re-surfaces with a "Last used" chip', async ({ page }) => { + await gotoFresh(page); + await page.locator('[data-testid="header-login"]').click(); + // Under the emulator, clicking a provider button signs in via custom token (no popup). + await page.locator('[data-testid="login-github"]').click(); + await expect(page.locator('[data-testid="profile-trigger"]')).toBeVisible({ timeout: 15_000 }); + await page.locator('[data-testid="profile-trigger"]').click(); + await page.locator('[data-testid="profile-logout"]').click(); + // RELOAD: AppRoot reads lastAuthProvider from localStorage ONCE at mount + // (useEffect([], …) — see AppRoot.tsx:205-208), so the chip surfaces on the NEXT + // visit, not within the same session. That IS the product behavior (sign in, leave, + // come back → "Last used"), so re-load to re-read the persisted provider. + await page.goto('/'); + await page.locator('[data-testid="header-login"]').click(); + await expect(page.locator('[data-testid="login-github-lastused"]')).toBeVisible({ timeout: 15_000 }); +}); + +test('AUTH-4 — auth error surfaces in the modal alert', async ({ page }) => { + await gotoFresh(page); + await page.locator('[data-testid="header-login"]').click(); + // Arm a one-shot forced error: the next provider click rejects with this code, + // which useAuth surfaces into the login-error notice. + await page.evaluate(() => window.__e2eForceAuthError('auth/popup-closed-by-user')); + await page.locator('[data-testid="login-google"]').click(); + await expect(page.locator('[data-testid="login-error"]')).toBeVisible(); +}); + +test('AUTH-5 — login modal auto-dismisses once auth resolves', async ({ page }) => { + await gotoFresh(page); + await page.locator('[data-testid="header-login"]').click(); + await expect(page.locator('[data-testid="login-google"]')).toBeVisible(); + await signInViaEmulator(page); + await expect(page.locator('[data-testid="login-google"]')).toBeHidden(); +}); + +test('AUTH-6 — sign-out hides profile menu, restores "Sign in"', async ({ page }) => { + await gotoFresh(page); + await signInViaEmulator(page); + await page.locator('[data-testid="profile-trigger"]').click(); + await page.locator('[data-testid="profile-logout"]').click(); + await expect(page.locator('[data-testid="profile-trigger"]')).toBeHidden(); + await expect(page.locator('[data-testid="header-login"]')).toBeVisible(); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §10 Import-on-login (IOL-1..3) +// ════════════════════════════════════════════════════════════════════════════ + +test('IOL-1 — first sign-in with local diagrams offers AskToImportModal', async ({ page }) => { + await gotoFresh(page); + await typeDsl(page, 'A\nB\nA->B: local'); + await saveViaMenu(page); + // First signed-out save shows a one-time notice; dismiss it if present. + await page.locator('[data-testid="confirm-cancel"]').click().catch(() => {}); + await signInViaEmulator(page); + await expect(page.locator('[data-testid="import-confirm"]')).toBeVisible({ timeout: 15_000 }); +}); + +test('IOL-2 — accepting import uploads locals to the cloud account', async ({ page }) => { + const email = 'iol2@test.local'; + const uid = uidForEmail(email); + await gotoFresh(page); + await typeDsl(page, 'A\nB\nA->B: local'); + await saveViaMenu(page); + await page.locator('[data-testid="confirm-cancel"]').click().catch(() => {}); + await signInViaEmulator(page, { email }); + await page.locator('[data-testid="import-confirm"]').click(); + // Firestore side-effect: the local item is uploaded under this account. + await expect.poll(async () => (await queryItemsByOwner(uid)).length, { timeout: 15_000 }) + .toBeGreaterThanOrEqual(1); +}); + +test('IOL-3 — declining keeps locals untouched, no cloud write', async ({ page }) => { + const email = 'iol3@test.local'; + const uid = uidForEmail(email); + await gotoFresh(page); + await typeDsl(page, 'A\nB\nA->B: local'); + await saveViaMenu(page); + await page.locator('[data-testid="confirm-cancel"]').click().catch(() => {}); + await signInViaEmulator(page, { email }); + await page.locator('[data-testid="import-dismiss"]').click(); + // Give any (erroneous) upload a moment, then assert NO items were written. + await page.waitForTimeout(1500); + expect((await queryItemsByOwner(uid)).length).toBe(0); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §5 Folders (FLD-1, FLD-3, FLD-4, FLD-5) — FLD-2 stays fixme (see cloud.fixme.spec.js) +// ════════════════════════════════════════════════════════════════════════════ + +test('FLD-1 — create folder "Work" appears with count 0', async ({ page }) => { + await gotoFresh(page); + await signInViaEmulator(page); + await gotoHome(page); + await page.locator('[data-testid="folder-new"]').click(); + await page.locator('[data-testid="folder-new-input"]').fill('Work'); + await page.keyboard.press('Enter'); + await expect(page.getByText('Work', { exact: true })).toBeVisible(); +}); + +test('FLD-3 — rename folder commits the new label', async ({ page }) => { + const email = 'fld3@test.local'; + const uid = uidForEmail(email); + // Seed a user doc with one folder so the rename UI has a target on first home load. + await setDoc(`users/${uid}`, { + folders: [{ id: 'folder-fld3', name: 'OldName', createdOn: Date.now(), updatedOn: Date.now() }], + }); + await gotoFresh(page); + await signInViaEmulator(page, { email }); + await gotoHome(page); + const folderBtn = page.locator('[data-testid="folder-folder-fld3"]'); + await expect(folderBtn).toBeVisible({ timeout: 15_000 }); + await folderBtn.dblclick(); // double-click starts rename + const renameInput = page.locator('[data-testid="folder-rename-folder-fld3"]'); + await expect(renameInput).toBeVisible(); + await renameInput.fill('NewName'); + await page.keyboard.press('Enter'); + await expect(page.getByText('NewName', { exact: true })).toBeVisible(); +}); + +test('FLD-4 — delete folder returns its item to Unfiled', async ({ page }) => { + const email = 'fld4@test.local'; + const uid = uidForEmail(email); + await setDoc(`users/${uid}`, { + items: { 'fld4-item': true }, + folders: [{ id: 'folder-fld4', name: 'Doomed', createdOn: Date.now(), updatedOn: Date.now() }], + }); + await seedItem(uid, 'fld4-item', { title: 'Filed', folderId: 'folder-fld4' }); + await gotoFresh(page); + await signInViaEmulator(page, { email }); + await gotoHome(page); + const folderBtn = page.locator('[data-testid="folder-folder-fld4"]'); + await expect(folderBtn).toBeVisible({ timeout: 15_000 }); + // Delete via the row delete button → confirm dialog. + await page.locator('[data-testid="folder-delete-folder-fld4"]').click(); + await page.getByRole('button', { name: 'Delete' }).click(); + await expect(folderBtn).toBeHidden(); + // The item's folderId is now an orphan → it counts under Unfiled. Assert the + // Unfiled filter shows it. + await page.locator('[data-testid="folder-unfiled"]').click(); + await expect(page.locator('[data-testid="home-card-fld4-item"]')).toBeVisible({ timeout: 15_000 }); +}); + +test('FLD-5 — "All" vs "Unfiled" filters the grid', async ({ page }) => { + const email = 'fld5@test.local'; + const uid = uidForEmail(email); + await setDoc(`users/${uid}`, { + items: { 'fld5-unfiled': true, 'fld5-filed': true }, + folders: [{ id: 'folder-fld5', name: 'Box', createdOn: Date.now(), updatedOn: Date.now() }], + }); + await seedItem(uid, 'fld5-unfiled', { title: 'Loose', folderId: null }); + await seedItem(uid, 'fld5-filed', { title: 'Boxed', folderId: 'folder-fld5' }); + await gotoFresh(page); + await signInViaEmulator(page, { email }); + await gotoHome(page); + await page.locator('[data-testid="folder-all"]').click(); + await expect.poll(async () => page.locator('[data-testid^="home-card-"]').count(), { + timeout: 15_000, + }).toBe(2); + await page.locator('[data-testid="folder-unfiled"]').click(); + await expect(page.locator('[data-testid="home-card-fld5-unfiled"]')).toBeVisible(); + await expect(page.locator('[data-testid="home-card-fld5-filed"]')).toBeHidden(); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §11 Sharing (SHR-1..8) — functions emulator round-trips create_share/get_shared_item +// ════════════════════════════════════════════════════════════════════════════ + +test('SHR-1 — Share while signed-out opens the sign-in modal first', async ({ page }) => { + await gotoFresh(page); + await page.locator('[data-testid="share-button"]').click(); + await expect(page.locator('[data-testid="login-google"]')).toBeVisible(); +}); + +test('SHR-2 — create share link → URL carries ?id=&share-token=', async ({ page }) => { + await gotoFresh(page); + await signInViaEmulator(page); + await typeDsl(page, 'A\nB\nA->B: share me'); + await saveViaMenu(page); + // Wait for the cloud save to settle (savestate leaves dirty/saving). + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'saved', { timeout: 15_000 }); + await page.locator('[data-testid="share-button"]').click(); + await page.locator('[data-testid="share-create"]').click(); + const url = page.locator('[data-testid="share-url"]'); + await expect(url).toBeVisible({ timeout: 15_000 }); + await expect(url).toHaveValue(/[?&]id=.+&share-token=.+/); +}); + +test('SHR-3 — Copy copies the URL and shows "Copied ✓"', async ({ page, context }) => { + await context.grantPermissions(['clipboard-read', 'clipboard-write']); + await gotoFresh(page); + await signInViaEmulator(page); + await typeDsl(page, 'A\nB\nA->B: copy'); + await saveViaMenu(page); + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'saved', { timeout: 15_000 }); + await page.locator('[data-testid="share-button"]').click(); + await page.locator('[data-testid="share-create"]').click(); + await expect(page.locator('[data-testid="share-url"]')).toBeVisible({ timeout: 15_000 }); + await page.locator('[data-testid="share-copy"]').click(); + await expect(page.locator('[data-testid="share-copy"]')).toContainText('Copied'); + const clip = await page.evaluate(() => navigator.clipboard.readText()); + expect(clip).toMatch(/[?&]id=.+&share-token=.+/); +}); + +test('SHR-4 — Stop-sharing revokes the token (re-visit fails)', async ({ page }) => { + await gotoFresh(page); + await signInViaEmulator(page); + await typeDsl(page, 'A\nB\nA->B: revoke'); + await saveViaMenu(page); + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'saved', { timeout: 15_000 }); + await page.locator('[data-testid="share-button"]').click(); + await page.locator('[data-testid="share-create"]').click(); + const url = await page.locator('[data-testid="share-url"]').inputValue(); + const shareId = new URL(url).searchParams.get('id'); + await page.locator('[data-testid="share-stop"]').click(); + // stopSharing writes isShared:false to items/{id} asynchronously; wait for that + // side-effect to LAND in Firestore before re-visiting, otherwise the read races the + // revoke and get_shared_item still returns the item (the revoke is real, just async). + await expect.poll(async () => { + const doc = await getDoc(`items/${shareId}`); + return doc?.isShared; + }, { timeout: 15_000 }).toBe(false); + // Now the URL is revoked: re-visiting yields the share-error notice (get_shared_item + // rejects — isShared flipped false → useBootItem share-error). + await page.goto(url); + await expect(page.locator('[data-testid="share-error"]')).toBeVisible({ timeout: 15_000 }); +}); + +test('SHR-5 — opening a share URL renders read-only', async ({ page }) => { + // Seed a shared item directly, then visit its share URL. useBootItem → getSharedItem + // (functions emulator) returns isReadOnly:true. + const ownerUid = 'shr5-owner'; + const id = 'shr5-item'; + const token = 'shr5-token'; + await seedItem(ownerUid, id, { title: 'ReadMe', isShared: true, shareToken: token }); + await page.goto(`/?id=${id}&share-token=${token}`); + await expect(editorLocator(page)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'readonly', { timeout: 15_000 }); +}); + +test('SHR-6 — Fork a shared item → owned editable copy (leaves readonly)', async ({ page }) => { + const ownerUid = 'shr6-owner'; + const id = 'shr6-item'; + const token = 'shr6-token'; + await seedItem(ownerUid, id, { title: 'ForkMe', isShared: true, shareToken: token }); + await gotoFresh(page); + await signInViaEmulator(page); + await page.goto(`/?id=${id}&share-token=${token}`); + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'readonly', { timeout: 15_000 }); + await page.locator('[data-testid="filemenu-trigger"]').click(); + await page.locator('[data-testid="filemenu-duplicate"]').click(); + await expect(page.locator('[data-testid="header-savestate"]')).not.toHaveAttribute('data-state', 'readonly', { timeout: 15_000 }); +}); + +test('SHR-7 — bad share link → ShareErrorNotice with "Start fresh"', async ({ page }) => { + await page.goto('/?id=does-not-exist&share-token=bad'); + await expect(page.locator('[data-testid="share-error"]')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="share-error-fresh"]')).toBeVisible(); +}); + +test('SHR-8 — Share button disabled for read-only items', async ({ page }) => { + const ownerUid = 'shr8-owner'; + const id = 'shr8-item'; + const token = 'shr8-token'; + await seedItem(ownerUid, id, { title: 'NoReshare', isShared: true, shareToken: token }); + await gotoFresh(page); + await signInViaEmulator(page); + await page.goto(`/?id=${id}&share-token=${token}`); + await expect(editorLocator(page)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="share-button"]')).toBeDisabled(); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §13 Subscription / pricing (SUB-2/3/4/5) +// ════════════════════════════════════════════════════════════════════════════ + +test('SUB-2 — Upgrade (signed-in) opens Paddle checkout for the chosen plan', async ({ page }) => { + // Install a window.Paddle stub BEFORE app boot so usePaddle.ensurePaddle() skips + // the CDN inject and uses it (the "Paddle already present" path). + await page.addInitScript(() => { + window.__paddleCalls = []; + window.Paddle = { Setup() {}, Checkout: { open: (opts) => window.__paddleCalls.push(opts) } }; + }); + await gotoFresh(page); + await signInViaEmulator(page); + // Open the pricing modal and pick the monthly Plus upgrade. + await page.locator('[data-testid="header-menu"]').click(); + await page.locator('[data-testid="header-pricing"]').click(); + await page.locator('[data-testid="pricing-period-monthly"]').click().catch(() => {}); + await page.locator('[data-testid="pricing-upgrade-plus"]').click(); + await expect.poll(async () => page.evaluate(() => window.__paddleCalls.length), { timeout: 15_000 }) + .toBe(1); + const passthrough = await page.evaluate(() => JSON.parse(window.__paddleCalls[0].passthrough)); + expect(passthrough.planType).toBe('plus-monthly'); +}); + +test('SUB-3 — anonymous Upgrade → sign-in → resumes checkout', async ({ page }) => { + await page.addInitScript(() => { + window.__paddleCalls = []; + window.Paddle = { Setup() {}, Checkout: { open: (o) => window.__paddleCalls.push(o) } }; + }); + await gotoFresh(page); + await page.locator('[data-testid="header-menu"]').click(); + await page.locator('[data-testid="header-pricing"]').click(); + // Anonymous Upgrade stashes the plan and opens the login modal first. + await page.locator('[data-testid="pricing-upgrade-plus"]').click(); + await expect(page.locator('[data-testid="login-google"]')).toBeVisible({ timeout: 15_000 }); + await signInViaEmulator(page); + // After auth resolves the captured checkout resumes. + await expect.poll(async () => page.evaluate(() => window.__paddleCalls.length), { timeout: 15_000 }) + .toBe(1); +}); + +test('SUB-4 — free user over the cap → limit notice + cloud write withheld', async ({ page }) => { + const email = 'sub4@test.local'; + const uid = uidForEmail(email); + // Seed the user already AT the cap (4 owned ids → ownedIds.length 4 > free limit 3), + // so the NEXT save is withheld and the limit notice fires. + await setDoc(`users/${uid}`, { items: { a: true, b: true, c: true, d: true } }); + await gotoFresh(page); + await signInViaEmulator(page, { email }); + await typeDsl(page, 'A\nB\nA->B: fifth'); + await saveViaMenu(page); + await expect(page.locator('[data-testid="limit-notice"]')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="limit-upgrade"]')).toBeVisible(); + // Firestore side-effect: still exactly 4 owned ids — the new item's cloud write was + // withheld (kept local only). + const userDoc = await getDoc(`users/${uid}`); + expect(Object.keys(userDoc.items)).toHaveLength(4); +}); + +test('SUB-5 — paid user sees the plan row in the profile menu', async ({ page }) => { + const email = 'sub5@test.local'; + const uid = uidForEmail(email); + // Seed an ACTIVE plus subscription so the resolved plan is plus. + await setDoc(`users/${uid}`, { items: {} }); + await setDoc(`user_subscriptions/user-${uid}`, { + status: 'active', + passthrough: JSON.stringify({ userId: uid, planType: 'plus-monthly' }), + }); + await gotoFresh(page); + await signInViaEmulator(page, { email }); + await page.locator('[data-testid="profile-trigger"]').click(); + // Subscribed → profile-plan (My Plan) is shown instead of profile-upgrade. + await expect(page.locator('[data-testid="profile-plan"]')).toBeVisible({ timeout: 15_000 }); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §8 Persistence / autosave (PST-2/3/4) +// ════════════════════════════════════════════════════════════════════════════ + +test('PST-2 — save indicator transitions dirty → saved (signed-in)', async ({ page }) => { + await gotoFresh(page); + await signInViaEmulator(page); + await typeDsl(page, 'A\nB\nA->B: dirty'); + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'dirty', { timeout: 15_000 }); + await saveViaMenu(page); + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'saved', { timeout: 15_000 }); +}); + +test('PST-3 — read-only (shared) item: savestate readonly, Fork offered', async ({ page }) => { + const ownerUid = 'pst3-owner'; + const id = 'pst3-item'; + const token = 'pst3-token'; + await seedItem(ownerUid, id, { title: 'RO', isShared: true, shareToken: token }); + // Suppress the one-time onboarding/pledge modals BEFORE boot — their Radix overlay + // otherwise intercepts the filemenu-trigger click on this bare (non-gotoFresh) goto. + await suppressOneTimeModals(page); + await page.goto(`/?id=${id}&share-token=${token}`); + await expect(editorLocator(page)).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'readonly', { timeout: 15_000 }); + // Fork (filemenu-duplicate) is the offered escape from read-only. NOTE: header-save + // (in the app menu) is NOT a disabled control — save() is a no-op for read-only items + // (AppRoot save(): `if (it.isReadOnly) return`), so we assert Fork is present rather + // than asserting a disabled Save that the component never renders. + await page.locator('[data-testid="filemenu-trigger"]').click(); + await expect(page.locator('[data-testid="filemenu-duplicate"]')).toBeVisible(); +}); + +test('PST-4 — read-only item is NOT written to the last-code slot', async ({ page }) => { + const ownerUid = 'pst4-owner'; + const id = 'pst4-item'; + const token = 'pst4-token'; + await seedItem(ownerUid, id, { title: 'NoLastCode', js: 'ZZZ_SHARED_ONLY->QQQ: marker', isShared: true, shareToken: token }); + await page.goto(`/?id=${id}&share-token=${token}`); + await expect(editorLocator(page)).toBeVisible({ timeout: 15_000 }); + await expect(editorLocator(page)).toContainText('ZZZ_SHARED_ONLY', { timeout: 15_000 }); + // Reload bare '/' — editor-as-landing resumes last-code; the read-only shared item + // must NOT have become the last-code (AppRoot: `if (current && !current.isReadOnly) + // saveLastCode(current)`). + await page.goto('/'); + await expect(editorLocator(page)).toBeVisible({ timeout: 15_000 }); + await expect(editorLocator(page)).not.toContainText('ZZZ_SHARED_ONLY'); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §14 Header / title / actions (HDR-4/6) +// ════════════════════════════════════════════════════════════════════════════ + +test('HDR-4 — Fork duplicates a shared item into an owned editable copy', async ({ page }) => { + const ownerUid = 'hdr4-owner'; + const id = 'hdr4-item'; + const token = 'hdr4-token'; + await seedItem(ownerUid, id, { title: 'ForkHdr', isShared: true, shareToken: token }); + await gotoFresh(page); + await signInViaEmulator(page); + await page.goto(`/?id=${id}&share-token=${token}`); + await expect(page.locator('[data-testid="header-savestate"]')).toHaveAttribute('data-state', 'readonly', { timeout: 15_000 }); + await page.locator('[data-testid="filemenu-trigger"]').click(); + await page.locator('[data-testid="filemenu-duplicate"]').click(); + await expect(page.locator('[data-testid="header-savestate"]')).not.toHaveAttribute('data-state', 'readonly', { timeout: 15_000 }); +}); + +test('HDR-6 — signed-out shows "Sign in"; signed-in shows profile menu', async ({ page }) => { + await gotoFresh(page); + await expect(page.locator('[data-testid="header-login"]')).toBeVisible(); + await expect(page.locator('[data-testid="profile-trigger"]')).toBeHidden(); + await signInViaEmulator(page); + await expect(page.locator('[data-testid="profile-trigger"]')).toBeVisible(); + await expect(page.locator('[data-testid="header-login"]')).toBeHidden(); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §16 Embed (by-reference) (EMB-1) +// ════════════════════════════════════════════════════════════════════════════ + +test('EMB-1 — embed by-reference renders the shared diagram', async ({ page }) => { + const ownerUid = 'emb1-owner'; + const id = 'emb1-item'; + const token = 'emb1-token'; + await seedItem(ownerUid, id, { title: 'Embedded', isShared: true, shareToken: token }); + await page.goto(`/?embed&id=${id}&share-token=${token}`); + await expect(page.locator('[data-testid="embed-root"]')).toBeVisible({ timeout: 15_000 }); + await expect(page.locator('[data-testid="embed-open-link"]')).toBeVisible(); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §20 Connectivity (NET-2) — offline → reconnect sync +// ════════════════════════════════════════════════════════════════════════════ + +test('NET-2 — reconnect resumes cloud sync without data loss', async ({ page, context }) => { + const email = 'net2@test.local'; + const uid = uidForEmail(email); + await gotoFresh(page); + await signInViaEmulator(page, { email }); + await typeDsl(page, 'A\nB\nA->B: offline edit marker'); + // Capture the item id the editor is on so we can probe its cloud doc post-reconnect. + await context.setOffline(true); + await saveViaMenu(page); + await context.setOffline(false); + // After reconnect, Firestore's persistence queue flushes the write. Probe the + // emulator: the item owned by this uid carries the offline edit. + await expect.poll(async () => { + const items = await queryItemsByOwner(uid); + return items.some((i) => typeof i.js === 'string' && i.js.includes('offline edit marker')); + }, { timeout: 30_000 }).toBe(true); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §7 Settings (SET-7) — settings persist to cloud +// ════════════════════════════════════════════════════════════════════════════ + +test('SET-7 — signed-in settings persist to cloud (Firestore probe)', async ({ page }) => { + const email = 'set7@test.local'; + const uid = uidForEmail(email); + await gotoFresh(page); + await signInViaEmulator(page, { email }); + // Toggle a boolean setting (autoSave) in the Settings modal; it writes + // users/{uid}.settings.autoSave to Firestore for signed-in users. + await page.locator('[data-testid="header-menu"]').click(); + await page.locator('[data-testid="header-settings"]').click(); + const toggle = page.locator('[data-testid="setting-autoSave"]'); + await expect(toggle).toBeVisible({ timeout: 15_000 }); + // Read the current value, flip it, and assert the cloud doc reflects the new value. + const before = await getDoc(`users/${uid}`); + const beforeAutoSave = before?.settings?.autoSave; + await toggle.click(); + await expect.poll(async () => { + const doc = await getDoc(`users/${uid}`); + return doc?.settings?.autoSave; + }, { timeout: 15_000 }).not.toBe(beforeAutoSave); +}); + +// ════════════════════════════════════════════════════════════════════════════ +// §1 CSS gate (CSS-5) — non-plain CSS mode gated behind Plus for free users +// ════════════════════════════════════════════════════════════════════════════ + +test('CSS-5 — non-plain CSS mode gated behind Plus for free users', async ({ page }) => { + await gotoFresh(page); + await signInViaEmulator(page, { email: 'css5@test.local' }); + // Expand the CSS pane to reach the pre-processor mode Select (a Radix Select, NOT + // a native