From 2dba95ed46daba9fa5063550a5d4ee9ad64d45ec Mon Sep 17 00:00:00 2001 From: MrCoder Date: Thu, 18 Jun 2026 12:29:03 +1000 Subject: [PATCH 1/2] =?UTF-8?q?docs(e2e):=20gap=20test=20plan=20=E2=80=94?= =?UTF-8?q?=20~105=20cases=20across=2020=20areas=20for=20features=20not=20?= =?UTF-8?q?covered=20by=20e2e=20(editor=20exhaustively=20covered;=20app-le?= =?UTF-8?q?vel=20thin)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/E2E_GAP_TEST_PLAN.md | 268 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 268 insertions(+) create mode 100644 e2e/E2E_GAP_TEST_PLAN.md diff --git a/e2e/E2E_GAP_TEST_PLAN.md b/e2e/E2E_GAP_TEST_PLAN.md new file mode 100644 index 00000000..37a38e49 --- /dev/null +++ b/e2e/E2E_GAP_TEST_PLAN.md @@ -0,0 +1,268 @@ +# E2E Gap Test Plan — features not yet covered by Playwright + +**Purpose.** The editor/DSL language is exhaustively covered (≈250 tests in +`web/e2e/`: completion, snippets, indentation, brackets, highlighting). The +app-level surface (auth, sharing, subscription, settings depth, preview controls, +library actions, export variants, mobile) is thinly covered by the root +`e2e/tests/` staging-gate suite. This plan enumerates the **uncovered** behaviors +and a test for each, so the suite can grow to cover the whole product. + +**Already covered (do NOT re-plan):** editor typing/completion/snippets/indent/ +highlight (`catalog*`, `editor-language`, `typing-mechanics`); DSL→SVG render +(`dsl-spot-check`, `smoke`); embed-by-value `?code` + open-in-app + shortcuts-off +(`embed`); library list/search/export-all/import-JSON (`library`); multi-page +add/switch + last-code restore + New-resets (`persistence`); modal **open** + +one action for Settings(font-size)/Create-New/Help/Cheat-Sheet/Shortcuts/Pricing +(`modals`); Report-a-bug FAB→modal→GitHub URL (`report-bug`); routing +editor-as-landing + `?view=diagrams` (`smoke`). + +## Legend + +**Dependency** — what infra a test needs (drives where it can run): +- `UI` — pure browser, no backend. Runs today (local dev + staging gate). +- `AUTH` — needs a signed-in session (Firebase Auth emulator or a seeded test account). +- `CLOUD` — needs Firestore + cloud functions (`create-share`, `sync-diagram`, `get-shared-item`) — emulator. +- `PADDLE` — needs Paddle sandbox or a mocked `usePaddle`. +- `MOBILE` — needs Playwright viewport/device emulation (no backend). + +**Priority** — `P0` core journey, `P1` important, `P2` edge/polish. + +**Note on infra.** Most `AUTH`/`CLOUD`/`PADDLE` cases are why these gaps exist — +the staging gate runs signed-out against a live deploy. Recommended unlock: +stand up the **Firebase emulator suite** (auth + firestore + functions) for a +new `e2e/tests/*.cloud.spec.js` project, and mock Paddle's `openCheckout`. Unit +coverage already exists for the services (`useShare`, `subscriptionService`, +`exportImport`, `folderService`, …) — these e2e cases target the **UI + wiring**, +not the pure logic. + +--- + +## 1. CSS editor & custom-CSS gate + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| CSS-1 | CSS pane expands/collapses ("Custom CSS · click to expand" footer) | Click footer; assert CSS CodeMirror surface visible/hidden | UI | P1 | +| CSS-2 | CSS mode select offers CSS/SCSS/Sass/Less/Stylus/ACSS | Open mode select; assert all 6 options | UI | P1 | +| CSS-3 | SCSS/Less/Stylus source transpiles → preview reflects compiled CSS | Enter `$c:red; .label{color:$c}` (scss); assert preview label color | UI | P1 | +| CSS-4 | ACSS mode reveals the Atomic-CSS settings entry; opens `AtomicCssSettingsModal` | Switch mode→acss; click settings; assert modal | UI | P2 | +| CSS-5 | Non-plain CSS mode gated behind Plus for signed-out/free users (→ pricing) | Switch to scss while free; assert pricing/upgrade prompt (`cssGated`) | AUTH | P1 | +| CSS-6 | Prettier-format CSS (Ctrl+Shift+F) reformats the CSS buffer | Type minified CSS; press shortcut; assert formatted | UI | P2 | + +## 2. Preview controls + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| PRV-1 | Present/fullscreen toggle expands diagram, hides editor/console; Exit restores | Click Present; assert editor hidden + fullscreen surface; Exit; assert restored | UI | P0 | +| PRV-2 | Debug console toggles open/closed; shows render output | Toggle console; assert panel; assert "No issues" / entries | UI | P1 | +| PRV-3 | Ctrl+L clears the console | Produce entries; Ctrl+L; assert console emptied | UI | P2 | +| PRV-4 | Console eval runs JS against preview iframe and returns a result | Type expression in console input; assert echoed result | UI | P2 | +| PRV-5 | Mobile (<768px, non-fullscreen) renders native **SVG** (fit-to-width), not fixed-px HTML | Emulate phone; assert `#svg-mount` svg present, width:100% | MOBILE | P1 | +| PRV-6 | Auto-preview off → edits do NOT re-render until manual refresh | Disable auto-preview (settings); edit; assert preview stale until refresh | UI | P2 | +| PRV-7 | Refresh-on-resize setting re-renders on window resize | Enable; resize viewport; assert re-render | UI | P2 | +| PRV-8 | Zoom indicator shows 100% (presentational) | Assert renderer header shows `100%` | UI | P2 | + +## 3. Multi-page (beyond add/switch) + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| PG-1 | Rename page inline (double-click tab / menu) commits new title | Double-click page tab; type; blur; assert tab label | UI | P1 | +| PG-2 | Delete non-default page shows confirm, removes the page | Add page; delete; confirm; assert tab gone + active page falls back | UI | P1 | +| PG-3 | Default (first) page cannot be deleted (no delete affordance) | Assert page 1 has no delete control | UI | P2 | +| PG-4 | Page metadata edits mark the item dirty (drive autosave) | Rename page; assert unsaved indicator | UI | P2 | + +## 4. Library / Hub actions + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| LIB-1 | Card kebab → **Duplicate** creates a copy row | Seed item; kebab→Duplicate; gotoHome; assert 2 rows | UI | P1 | +| LIB-2 | Card kebab → **Delete** removes the row (with confirm if any) | Seed item; kebab→Delete; assert row gone | UI | P0 | +| LIB-3 | Card kebab → **Export as HTML** downloads a standalone file | kebab→Export HTML; assert download `*.html` | UI | P1 | +| LIB-4 | Sort toggle (Updated ↔ Title A–Z) reorders the grid | Seed items with known titles/dates; toggle sort; assert order | UI | P2 | +| LIB-5 | Card shows DSL preview (≤240 chars), title, last-updated date | Seed; assert card body contains DSL snippet + date | UI | P2 | +| LIB-6 | "New diagram" CTA from hub opens a blank editor | Click home-new; assert editor with starter/blank | UI | P1 | +| LIB-7 | Signed-out hub shows a sign-in affordance | gotoHome signed-out; assert sign-in prompt visible | UI | P2 | +| LIB-8 | Empty-state ("No diagrams yet") shows New + Browse templates | Fresh hub; assert empty-state CTAs | UI | P1 | + +## 5. Folders + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| FLD-1 | Create folder; it appears in the folder list with 0 count | Create folder "Work"; assert listed, count 0 | AUTH/CLOUD | P1 | +| FLD-2 | Move a diagram into a folder; folder count increments; "Unfiled" decrements | Drag/menu-move item; assert counts | AUTH/CLOUD | P1 | +| FLD-3 | Rename folder | Rename; assert new label | AUTH/CLOUD | P2 | +| FLD-4 | Delete folder; its items return to Unfiled | Delete; assert items unfiled | AUTH/CLOUD | P2 | +| FLD-5 | "All" vs "Unfiled" filters the grid | Click each; assert filtered set | AUTH/CLOUD | P1 | + +> Verify whether folders are local-only (then `UI`) or cloud-backed (`CLOUD`) — `useFolders.ts`/`folderService.ts`. + +## 6. Templates + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| TPL-1 | "Browse templates" opens the full `CreateNewModal` picker | Hub→Browse templates; assert modal grid | UI | P1 | +| TPL-2 | Each template (Basic, B&W, Blue, starUML, Blank) loads its DSL+CSS and opens editor | For each card: select; assert editor DSL matches template | UI | P1 | +| TPL-3 | Template previews render the schematic CSS thumbnail | Assert each card shows a styled preview | UI | P2 | + +## 7. Settings (beyond font-size) + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| SET-1 | Editor **theme** change applies live to CodeMirror | Change theme; assert `.cm-editor` theme class/colors change | UI | P1 | +| SET-2 | **Keymap = Vim** enables Vim bindings (e.g. `i`/`Esc` modal editing) | Set vim; assert vim status / modal behavior in editor | UI | P1 | +| SET-3 | **Font family** change applies to `.cm-content` | Change family; assert computed font-family | UI | P2 | +| SET-4 | **Indent** unit (2/4/8/tabs) changes auto-indent width | Set indent=4; Enter in block; assert 4-space indent | UI | P1 | +| SET-5 | Behavior toggles (line-wrap, auto-close, autocomplete, auto-save) take effect | Toggle each; assert observable effect | UI | P1 | +| SET-6 | Settings persist across reload (local syncStore) | Change; reload; assert retained | UI | P1 | +| SET-7 | Signed-in: settings persist to cloud and survive a fresh session | AUTH; change; re-login elsewhere; assert retained | AUTH/CLOUD | P2 | +| SET-8 | "Replace new tab" toggle hidden on web (extension-only) | Assert control absent in web context | UI | P2 | + +## 8. Persistence / autosave (beyond last-code) + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| PST-1 | Auto-save 15s loop fires only when enabled AND dirty | Enable; edit; wait; assert save occurs; clean → no save | UI/AUTH | P1 | +| PST-2 | Save indicator transitions Unsaved → Saving… → Saved (signed-in) | AUTH; edit→save; assert state labels | AUTH | P1 | +| PST-3 | Read-only (shared) item: Save disabled, Fork offered | Open read-only; assert Save disabled + Fork present | CLOUD | P1 | +| PST-4 | Read-only item is NOT written to the last-code slot (no stale re-boot) | Open read-only; reload `/`; assert NOT the shared item | CLOUD | P2 | +| PST-5 | preserve-last-code = off → reload `/` shows starter, not last edit | Disable setting; edit; reload; assert starter | UI | P2 | + +## 9. Authentication + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| AUTH-1 | Sign-in modal lists Google/GitHub/Facebook/Twitter providers | Open login; assert 4 provider buttons | UI | P1 | +| AUTH-2 | Google sign-in completes → profile menu shows name/photo/plan | AUTH emulator; sign in; assert ProfileMenu | AUTH | P0 | +| AUTH-3 | Last-used provider re-surfaces as elevated/primary on next login | Sign in (GitHub); sign out; reopen; assert GitHub primary + chip | AUTH | P2 | +| AUTH-4 | Auth error surfaces in the modal alert | Force provider error; assert error text | AUTH | P2 | +| AUTH-5 | Login modal auto-dismisses once auth resolves | Sign in; assert modal closes (P5 regression) | AUTH | P1 | +| AUTH-6 | Sign-out clears session, returns to editor/hub, hides profile menu | Sign out; assert signed-out chrome | AUTH | P1 | + +## 10. Import-on-login + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| IOL-1 | First sign-in with local diagrams offers `AskToImportModal` | Seed locals; sign in; assert import prompt | AUTH | P1 | +| IOL-2 | Accepting import uploads local items to the cloud account | Accept; assert items present after reload signed-in | AUTH/CLOUD | P1 | +| IOL-3 | Declining keeps locals untouched, no upload | Decline; assert no cloud write | AUTH/CLOUD | P2 | + +## 11. Sharing + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| SHR-1 | Share button: anonymous → opens sign-in first | Signed-out; click Share; assert login modal | AUTH | P1 | +| SHR-2 | Create share link → popover shows `?id=&share-token=` URL | AUTH; Share; assert URL in popover | CLOUD | P0 | +| SHR-3 | Copy button copies URL and shows "Copied ✓" | Click copy; assert confirmation + clipboard | CLOUD | P1 | +| SHR-4 | Stop-sharing revokes the token | Stop sharing; assert link no longer resolves | CLOUD | P2 | +| SHR-5 | Opening a share URL renders the diagram **read-only** + "Open in ZenUML" | Visit share URL; assert read-only render + link | CLOUD | P0 | +| SHR-6 | Fork a shared item → owned editable copy (clears readonly/shareToken) | Open shared; Fork; assert editable + new id | CLOUD | P1 | +| SHR-7 | Bad share link → `ShareErrorNotice` with "Start fresh" | Visit bad token; assert error modal | CLOUD | P2 | +| SHR-8 | Share button disabled for read-only items | Open read-only; assert Share disabled | CLOUD | P2 | + +## 12. Export / Import (beyond export-all / import-JSON) + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| EXP-1 | Export PNG downloads a `.png` of the diagram | Trigger PNG export (preview menu); assert download | UI | P1 | +| EXP-2 | Export SVG downloads an `.svg` | Trigger SVG export; assert download | UI | P1 | +| EXP-3 | Export-as-HTML produces a self-contained file that renders standalone | Export HTML; open file; assert diagram renders (CDN core) | UI | P1 | +| EXP-4 | Import of malformed JSON shows an error dialog (not silent) | Import bad file; assert error modal | UI | P1 | +| EXP-5 | Import merge skips duplicate IDs; reports added count | Import overlapping set; assert only new rows added | UI | P2 | + +## 13. Subscription / pricing + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| SUB-1 | Pricing modal shows 4 tiers (Starter/Basic/Plus/Enterprise) | Open pricing; assert tiers | UI | P1 | +| SUB-2 | Upgrade (signed-in) opens Paddle checkout for the chosen plan | AUTH; click Upgrade; assert `openCheckout(plan)` | PADDLE | P1 | +| SUB-3 | Upgrade (anonymous) → sign-in → resumes checkout for the captured plan | Signed-out Upgrade; sign in; assert checkout resumes (`5751479`) | AUTH/PADDLE | P1 | +| SUB-4 | Plan-limit at save: free user over 3 diagrams → limit notice + "Free Limit" event; local copy kept, cloud write withheld | AUTH free; exceed cap; save; assert notice + no cloud write | AUTH/CLOUD | P0 | +| SUB-5 | "Manage subscription" link present for paid users (Paddle cancel URL) | AUTH plus; assert manage link | AUTH | P2 | +| SUB-6 | Plus-only features show the Plus badge (informational) | Assert badge on gated settings | UI | P2 | + +## 14. Header / title / actions + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| HDR-1 | Inline title edit: click → focus, type, Enter commits | Edit title; assert committed | UI | P1 | +| HDR-2 | Title edit Escape cancels (reverts) | Type; Escape; assert original | UI | P2 | +| HDR-3 | Unsaved marker (`*`/`•`) appears on edit, clears on save | Edit; assert marker; save; assert cleared | UI/AUTH | P1 | +| HDR-4 | Fork button duplicates current item into an owned copy | Click Fork; assert new editable item | CLOUD | P2 | +| HDR-5 | Breadcrumb "← Your diagrams" returns to the hub | Click breadcrumb; assert `?view=diagrams` hub | UI | P1 | +| HDR-6 | Signed-out header shows "Sign in"; signed-in shows profile menu | Toggle auth; assert correct affordance | AUTH | P1 | + +## 15. Keyboard shortcuts (beyond Cmd+Shift+?) + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| KB-1 | Cmd/Ctrl+S triggers a save | Edit; press save; assert save fired | UI/AUTH | P1 | +| KB-2 | Ctrl+L clears the console | (see PRV-3) | UI | P2 | +| KB-3 | Editor find/replace/comment/format shortcuts work (smoke) | Exercise one of each via keymap | UI | P2 | + +## 16. Embed (beyond by-value) + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| EMB-1 | Embed by-reference `?embed&id=&share-token=` renders the shared diagram | CLOUD; visit; assert embed render | CLOUD | P1 | +| EMB-2 | Embed empty state ("Diagram unavailable") on bad `?code`/bad token | Visit bad embed; assert empty-state + open-in-app | UI/CLOUD | P1 | +| EMB-3 | Embed posts content-size to parent (responsive iframe sizing) | Listen for postMessage dimensions; assert message | UI | P2 | +| EMB-4 | Legacy JSON-encoded `?code=` (not bare DSL) still renders | Visit legacy ?code; assert render | UI | P2 | + +## 17. Modals (uncovered / deeper) + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| MOD-1 | Onboarding modal shows once per profile (localStorage `onboarded`) | Fresh profile; assert modal; reload; assert gone | UI | P1 | +| MOD-2 | Support-Pledge modal shows when app.version > lastSeenVersion, once | Bump version; assert modal; reload; assert gone | UI | P2 | +| MOD-3 | Only one modal open at a time (opening a 2nd closes the 1st) | Open Settings then Help; assert Settings closed | UI | P1 | +| MOD-4 | Help modal links (Docs/Contact/GitHub) have correct hrefs + new-tab | Assert href + target=_blank rel=noopener | UI | P2 | +| MOD-5 | Cheat-sheet lists all documented rows (participant…comment) | Assert each example row present | UI | P2 | +| MOD-6 | Shortcuts modal lists global + editor shortcut groups | Assert both sections | UI | P2 | +| MOD-7 | Login modal opens from header "Sign in" and from gated actions | Trigger both; assert modal | UI | P1 | + +## 18. Mobile / responsive + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| MOB-1 | Mobile: segmented control toggles Editor ↔ Preview panes | Phone viewport; toggle; assert active pane | MOBILE | P1 | +| MOB-2 | Mobile hub header is two-row; collapses to one row at sm+ | Phone vs sm; assert layout | MOBILE | P2 | +| MOB-3 | Action buttons go icon-only on mobile (text hidden) | Phone; assert Share/Present icon-only | MOBILE | P2 | +| MOB-4 | Modals fit the viewport and scroll (no overflow) on small screens | Phone; open a tall modal; assert contained | MOBILE | P2 | + +## 19. Analytics / telemetry + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| ANL-1 | `landed_in_editor{bootKind}` fires once per editor boot | Intercept analytics; boot; assert one event w/ label | UI | P1 | +| ANL-2 | `hub_opened{source}` fires on hub arrival; re-arms on Back | Open hub via param then breadcrumb/Back; assert correct labels | UI | P1 | +| ANL-3 | `first_edit` fires once per mount on first DSL edit | Edit; assert single event | UI | P2 | +| ANL-4 | `saveBtnClick`, `shareLink`, `itemsImported`, `Free Limit` fire with legacy envelope | Exercise each; assert category/label/value | mixed | P2 | + +## 20. Connectivity + +| ID | Behavior | Method | Dep | Pri | +|----|----------|--------|-----|-----| +| NET-1 | Offline status reflected in UI; local edits still persist | Go offline; edit; assert offline indicator + local save | UI | P2 | +| NET-2 | Reconnect resumes cloud sync without data loss | AUTH; offline edits; reconnect; assert synced | AUTH/CLOUD | P2 | + +--- + +## Coverage map (what unlocks what) + +| Bucket | Cases | Runnable today (signed-out, live deploy) | +|--------|-------|-------------------------------------------| +| `UI` (pure browser) | CSS-1..4/6, PRV-1..4/6..8, PG-*, LIB-1..8(local), TPL-*, SET-1..6/8, PST-1/5, EXP-1..5, SUB-1/6, HDR-1..3/5, KB-*, EMB-2..4, MOD-*, ANL-1/3, NET-1 | **Yes** — biggest immediate win; add as new root specs | +| `MOBILE` | PRV-5, MOB-1..4 | Yes (Playwright device project) | +| `AUTH` | AUTH-*, IOL-*, SET-7, PST-2, SHR-1, SUB-3/5, HDR-6 | Needs Firebase **Auth emulator** / test account | +| `CLOUD` | FLD-*, PST-3/4, SHR-2..8, SUB-4, HDR-4, EMB-1, NET-2 | Needs **Firestore + functions emulator** | +| `PADDLE` | SUB-2/3 | Needs Paddle **sandbox** or mocked `usePaddle` | + +**Recommended sequencing** +1. **P0/P1 `UI` cases** → new specs `e2e/tests/{preview,library-actions,settings,export,multipage,modals,templates}.spec.js`. No infra; runs in the staging gate signed-out. Largest coverage gain for least cost. +2. **`MOBILE`** → a Playwright `devices['Pixel 7']`/`iPhone 14` project; covers PRV-5 + MOB-*. +3. **Emulator project** `*.cloud.spec.js` (auth + firestore + functions) → unlocks AUTH/CLOUD (sharing, folders, plan limits, import-on-login, save indicator). +4. **Paddle** mock → SUB-2/3. + +**Out of e2e scope (keep at unit level — already covered):** transpiler logic +(`transpilers`), `buildIssueUrl`, `exportImport` parse/merge, `planLimit`, +`semver`, `templates`, store reducers, `parseEmbedCode`. The e2e cases above +target the **UI + wiring**, not this logic. From d2739150229e748e0be2af49b57c9622f864a1b8 Mon Sep 17 00:00:00 2001 From: MrCoder Date: Thu, 18 Jun 2026 13:12:52 +1000 Subject: [PATCH 2/2] =?UTF-8?q?test(e2e):=20execute=20gap=20plan=20?= =?UTF-8?q?=E2=80=94=2057=20passing=20specs=20across=2012=20areas=20(previ?= =?UTF-8?q?ew/library/multipage/settings/templates/export/modals/css/heade?= =?UTF-8?q?r/mobile/telemetry/connectivity)=20+=2035=20test.fixme=20cloud?= =?UTF-8?q?=20stubs=20+=20emulator=20setup;=20PRV-6=20fixme'd=20(auto-prev?= =?UTF-8?q?iew=20unwired=20product=20bug)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- e2e/EMULATOR_SETUP.md | 215 ++++++++++ e2e/tests/cloud.fixme.spec.js | 685 ++++++++++++++++++++++++++++++ e2e/tests/connectivity.spec.js | 217 ++++++++++ e2e/tests/css-editor.spec.js | 345 +++++++++++++++ e2e/tests/export.spec.js | 329 ++++++++++++++ e2e/tests/header.spec.js | 217 ++++++++++ e2e/tests/library-actions.spec.js | 468 ++++++++++++++++++++ e2e/tests/mobile.spec.js | 296 +++++++++++++ e2e/tests/modals-gap.spec.js | 323 ++++++++++++++ e2e/tests/multipage.spec.js | 251 +++++++++++ e2e/tests/preview.spec.js | 353 +++++++++++++++ e2e/tests/settings.spec.js | 376 ++++++++++++++++ e2e/tests/telemetry.spec.js | 350 +++++++++++++++ e2e/tests/templates.spec.js | 227 ++++++++++ 14 files changed, 4652 insertions(+) create mode 100644 e2e/EMULATOR_SETUP.md create mode 100644 e2e/tests/cloud.fixme.spec.js create mode 100644 e2e/tests/connectivity.spec.js create mode 100644 e2e/tests/css-editor.spec.js create mode 100644 e2e/tests/export.spec.js create mode 100644 e2e/tests/header.spec.js create mode 100644 e2e/tests/library-actions.spec.js create mode 100644 e2e/tests/mobile.spec.js create mode 100644 e2e/tests/modals-gap.spec.js create mode 100644 e2e/tests/multipage.spec.js create mode 100644 e2e/tests/preview.spec.js create mode 100644 e2e/tests/settings.spec.js create mode 100644 e2e/tests/telemetry.spec.js create mode 100644 e2e/tests/templates.spec.js diff --git a/e2e/EMULATOR_SETUP.md b/e2e/EMULATOR_SETUP.md new file mode 100644 index 00000000..4ccb7a8d --- /dev/null +++ b/e2e/EMULATOR_SETUP.md @@ -0,0 +1,215 @@ +# Emulator setup — unlocking the infra-gated E2E gap cases + +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(...)`. + +What unlocks what (from the gap-plan coverage map): + +| Bucket | Cases | Infra needed | +| -------- | ------------------------------------------------------------------------- | -------------------------------------------------- | +| `AUTH` | AUTH-1..6, IOL-1, SHR-1, HDR-6, CSS-5, SUB-5 | Firebase **Auth** emulator + a seeded test user | +| `CLOUD` | IOL-2/3, FLD-1..5, SHR-2..8, PST-2/3/4, HDR-4, EMB-1, NET-2, SET-7, SUB-4 | **Auth + Firestore + Functions** emulators | +| `PADDLE` | SUB-2, SUB-3 | mocked `window.Paddle` (no sandbox account needed) | + +--- + +## 1. Why a separate Playwright project + +`playwright.config.js` boots the app via `pnpm -C web dev` (port 3000) with **no** +emulator. These cloud cases need the app pointed at the Firebase emulators instead. +Add a second project rather than mutating the existing one, so the signed-out +staging gate is untouched. + +```js +// playwright.config.js — add to `projects` (sketch) +{ + name: 'cloud', + testMatch: /cloud\.fixme\.spec\.js/, // rename to *.cloud.spec.js once un-fixme'd + use: { ...devices['Desktop Chrome'] }, +}, +``` + +And add an emulator-backed web server to `resolveWebServers()` (guarded by an env +flag so it only runs when you intend to exercise the cloud project): + +```js +// the app must boot with VITE_USE_EMULATOR=1 so firebase.ts wires the emulators (§2) +{ + command: 'firebase emulators:exec --only auth,firestore,functions "VITE_USE_EMULATOR=1 pnpm -C web dev"', + url: 'http://localhost:3000', + reuseExistingServer: !process.env.CI, + timeout: 180 * 1000, +}, +``` + +Emulator ports are already declared in [`firebase.json`](../firebase.json): +`auth` (add it — see §2), `firestore` :8080, `functions` :5002, UI :4000. +Add the auth port to `firebase.json`: + +```jsonc +"emulators": { + "auth": { "port": 9099 }, // ADD THIS + "functions": { "port": 5002 }, + "firestore": { "port": 8080 }, + "hosting": { "port": 5000 }, + "ui": { "enabled": true, "port": 4000 } +} +``` + +--- + +## 2. Wire the app to the emulators (`web/src/services/firebase.ts`) + +Today [`web/src/services/firebase.ts`](../web/src/services/firebase.ts) calls +`getAuth(app)` and `initializeFirestore(app, …)` with **no** emulator connection. +Add an opt-in block, gated on a Vite env flag so production builds never connect: + +```ts +// web/src/services/firebase.ts — after `export const auth` / `export const db` +import { connectAuthEmulator } from 'firebase/auth'; +import { connectFirestoreEmulator } from 'firebase/firestore'; + +if (import.meta.env.VITE_USE_EMULATOR === '1') { + connectAuthEmulator(auth, 'http://localhost:9099', { disableWarnings: true }); + connectFirestoreEmulator(db, 'localhost', 8080); +} +``` + +The cloud-function rewrites (`/create-share`, `/sync-diagram`, `/get-shared-item`, +defined in [`firebase.json`](../firebase.json)) are served by the **functions** +emulator. `firebase emulators:exec` makes the hosting rewrites resolve to the local +functions automatically; if you boot `pnpm -C web dev` directly instead, proxy +those paths to `http://localhost:5002` in `web/vite.config.*` so the relative +`fetch('/get-shared-item?…')` in +[`web/src/services/cloudFunctions.ts`](../web/src/services/cloudFunctions.ts) hits +the emulator. + +> NOTE: the functions emulator runs `functions/index.js`. `create_share` / +> `get_shared_item` read/write Firestore via the admin SDK — with the Firestore +> emulator up, the admin SDK auto-targets it when `FIRESTORE_EMULATOR_HOST` is set, +> which `emulators:exec` sets for you. + +--- + +## 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. + +--- + +## 4. The in-page sign-in hook the spec calls + +`cloud.fixme.spec.js` calls `window.__e2eSignIn({ uid, email })` (and +`window.__e2eForceAuthError`). Expose these **dev-only** hooks so the spec can +authenticate deterministically without driving an OAuth popup. Add to +`web/src/services/firebase.ts` behind the same `VITE_USE_EMULATOR` flag: + +```ts +import { signInWithCustomToken } from 'firebase/auth'; +if (import.meta.env.VITE_USE_EMULATOR === '1') { + // The test mints the token via the admin SDK (§3A) and passes it in, OR the + // hook fetches one from a tiny local endpoint. Keep this OUT of prod bundles. + (window as any).__e2eSignIn = async ({ token }: { token: string }) => + signInWithCustomToken(auth, token); + (window as any).__e2eForceAuthError = (code: string) => { + (window as any).__e2eAuthErrorCode = code; // LoginModal/onLogin reads + throws this + }; +} +``` + +The spec's `signInViaEmulator()` helper is the single seam — point it at whichever +of A/B you choose; nothing else in the spec changes. + +--- + +## 5. Firestore admin probes (the "no cloud write" / "synced" assertions) + +Several cases assert a Firestore **side effect**, not just UI: IOL-2/3 (uploaded / +not uploaded), SUB-4 (4th doc withheld), NET-2 (offline edit synced after +reconnect), SET-7 (settings persisted). Read the emulator directly with the admin +SDK in the test (or a fixture): + +```js +import admin from 'firebase-admin'; // already pointed at the emulator (§3) +const db = admin.firestore(); +const snap = await db.collection('users').doc(uid).collection('items').get(); +expect(snap.size).toBe(expectedCount); +``` + +The web client writes diagrams under the user's items collection and folders on the +`users/{uid}` doc (`folders` field) — confirm the exact paths in +`web/src/services/itemService.ts` / `folderService.ts` when you wire each probe. + +--- + +## 6. Mocking Paddle (SUB-2, SUB-3) — no sandbox account needed + +[`web/src/hooks/usePaddle.ts`](../web/src/hooks/usePaddle.ts) has a deliberate +seam: `ensurePaddle()` (line ~61) sees an existing `window.Paddle` and **skips the +CDN inject**, just calling `Setup`. So a Playwright `addInitScript` that installs a +stub BEFORE app boot fully controls checkout — the spec already does this: + +```js +await page.addInitScript(() => { + window.__paddleCalls = []; + window.Paddle = { + Setup() {}, + Checkout: { open: (o) => window.__paddleCalls.push(o) }, + }; +}); +// … later, assert openCheckout was called with the right plan: +const calls = await page.evaluate(() => window.__paddleCalls); +expect(JSON.parse(calls[0].passthrough)).toMatchObject({ + planType: 'plus-monthly', +}); +``` + +`openCheckout` (usePaddle.ts:92) resolves the product id from `firebaseConfig.ts` +and passes `JSON.stringify({ userId, planType })` as `passthrough` — that's the +assertable contract. No Paddle sandbox vendor/account is required for the E2E +gate; the unit test `web/src/hooks/usePaddle.test.tsx` already covers the SDK +plumbing, and these E2E cases only verify the **UI → openCheckout wiring**. + +--- + +## 7. Flipping the scaffold on + +Once §1–§6 are in place, per case: + +1. Replace `test.fixme(` → `test(` for the cases whose infra is now live. +2. Run only the cloud project: `npx playwright test --project=cloud`. +3. Tighten any sketch that the real run surfaces (timing waits, exact seed shape, + the precise move-to-folder / manage-subscription testids marked "TBD" inline). + +Rename the file `cloud.fixme.spec.js` → `cloud.spec.js` when the majority are +active, and add the `cloud` project to CI as a **separate** job from the +signed-out staging gate (it needs the emulator services, so it can't share the +anonymous-against-live-deploy runner). diff --git a/e2e/tests/cloud.fixme.spec.js b/e2e/tests/cloud.fixme.spec.js new file mode 100644 index 00000000..dab79c2b --- /dev/null +++ b/e2e/tests/cloud.fixme.spec.js @@ -0,0 +1,685 @@ +// 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. + +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(); + }, +); + +// ════════════════════════════════════════════════════════════════════════════ +// §10 Import-on-login (IOL-1..3) — Auth (+ Firestore for the upload assertion) +// ════════════════════════════════════════════════════════════════════════════ + +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. + }, +); + +test.fixme( + 'IOL-3 — needs: emulator (auth+firestore) — declining keeps locals untouched, no cloud write', + 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(); + }, +); diff --git a/e2e/tests/connectivity.spec.js b/e2e/tests/connectivity.spec.js new file mode 100644 index 00000000..9f097d97 --- /dev/null +++ b/e2e/tests/connectivity.spec.js @@ -0,0 +1,217 @@ +// Connectivity — NET-1: going offline is reflected in the page runtime, and a +// local edit made while offline SURVIVES (the editor retains the text and the +// last-code 'code' slot is written, so a reload restores it). +// +// "Assert an offline indicator if one exists": this app renders NO visible +// offline indicator. Grepping every consumer of the online state +// (web/src/hooks/useOnlineStatus.ts → authStore.online) shows the flag is +// consumed ONLY by itemService's sync decision (web/src/services/itemService.ts) +// — it is never rendered to a banner/badge/aria-live, and there is no +// data-testid="*offline*"/"*online*" anywhere in web/src. So the OBSERVABLE the +// hook wires up is navigator.onLine itself: useOnlineStatus listens for the +// window 'offline' event and mirrors navigator.onLine into the store. We assert +// against that runtime signal (the proxy the product actually keys on) plus the +// real user-visible contract — the edit survives offline. +// +// context.setOffline(true) flips navigator.onLine to false AND dispatches the +// window 'offline' event that useOnlineStatus handles, exactly the path the app +// reacts to in production. +// +// Editor-as-landing (2026-06-13): bare '/' boots the EDITOR directly; we land on +// the CM6 surface via the inlined gotoFresh (clean localStorage slate → boot +// seeds a fresh sample, openEditor lands on '/'). + +import { test, expect } from '@playwright/test'; +import { suppressOneTimeModals } from './helpers/onetime'; +import { openEditor } from './helpers/hub'; + +const selectAll = process.platform === 'darwin' ? 'Meta+a' : 'Control+a'; + +// Deployed sites (staging/prod via PW_BASE_URL) load third-party analytics/CDN +// scripts that throw their own uncaught errors we don't own; treat those as noise +// so genuine app errors still fail the test (copied from smoke.spec.js). +const THIRD_PARTY_ERROR_SOURCES = [ + 'userscript.js', + 'gtm.js', + 'googletagmanager', + 'google-analytics', + 'analytics.js', + 'clarity.js', + 'clarity.ms', + 'paddle.js', + 'cdn.paddle.com', + 'zaraz', + 'cloudflareinsights', +]; + +function isThirdPartyError(err) { + const haystack = `${err?.message || ''}\n${err?.stack || ''}`; + return THIRD_PARTY_ERROR_SOURCES.some((src) => haystack.includes(src)); +} + +/** The CM6 editor content surface. */ +function editorLocator(page) { + return page.locator('[data-testid="dsl-editor"] .cm-content'); +} + +/** + * Navigate to the EDITOR with a clean localStorage slate (mirrors + * persistence.spec.js gotoFresh). Land on the origin first to clear storage, then + * click through openEditor — avoids addInitScript-based clearing that would re-run + * on reload and wipe the 'code' slot the reload is meant to read back. + */ +async function gotoFresh(page) { + await suppressOneTimeModals(page); // M04: keep onboarding/pledge from trapping focus + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await openEditor(page); +} + +/** Type a replacement DSL into the CM6 editor (select-all → Delete → type). */ +async function typeDsl(page, dsl, { timeout = 15_000 } = {}) { + const editor = editorLocator(page); + await expect(editor).toBeVisible({ timeout }); + await editor.click(); + await page.keyboard.press(selectAll); + await page.keyboard.press('Delete'); + await editor.pressSequentially(dsl); +} + +/** + * Flush the last-code 'code' slot. AppRoot writes localStorage['code'] on + * `visibilitychange` (document.hidden → true) and on `beforeunload`; dispatch + * visibilitychange explicitly, then poll until the slot carries `token` — proving + * the slot a bare-'/' reload reads back is populated (mirrors persistence.spec.js). + */ +async function flushCodeSlot(page, token) { + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { + value: true, + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); + await page.waitForFunction( + (t) => { + const raw = localStorage.getItem('code'); + if (!raw) return false; + try { + return JSON.parse(raw)?.js?.includes(t); + } catch { + return false; + } + }, + token, + { timeout: 5_000 }, + ); +} + +test.beforeEach(async ({ page }) => { + page.on('pageerror', (err) => { + if (isThirdPartyError(err)) return; + throw err; + }); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// NET-1 (core, runs everywhere): going offline is reflected in the page runtime, +// and a local edit made WHILE OFFLINE survives — editor keeps the text and the +// last-code 'code' slot is written. +// +// page.evaluate(navigator.onLine) and the localStorage read are plain runtime +// reads that work against any base URL, so this test is NOT skipped on the staging +// gate. (Only the back-online RELOAD round-trip below needs the local clean-slate +// guarantee and is guarded.) +// ────────────────────────────────────────────────────────────────────────────── +test('NET-1: offline is reflected in the page runtime and a local edit made offline survives', async ({ + page, + context, +}) => { + await gotoFresh(page); + + // Baseline: the page reports itself online before we cut the connection. + expect(await page.evaluate(() => navigator.onLine)).toBe(true); + + // Cut the connection. setOffline flips navigator.onLine AND fires the window + // 'offline' event that useOnlineStatus handles (→ authStore.setOnline(false)). + await context.setOffline(true); + + // The offline status is reflected in the page runtime — this is the signal the + // product keys on (useOnlineStatus mirrors navigator.onLine into authStore.online; + // there is no rendered offline indicator to assert against). Poll because the + // 'offline' event propagates to the page on a microtask after setOffline resolves. + await expect + .poll(() => page.evaluate(() => navigator.onLine), { timeout: 5_000 }) + .toBe(false); + + // Edit the DSL while OFFLINE — distinctive token proves restore, not the starter. + const TOKEN = 'OfflineEditProbe'; + const DSL = `${TOKEN}\nPeer\nPeer->${TOKEN}: madeWhileOffline`; + await typeDsl(page, DSL); + + // The edit is retained in the editor even though we are offline (local-first: + // the editor never depends on the network to accept input). + await expect(editorLocator(page)).toContainText(TOKEN); + + // And it survives to the persistence layer: the last-code 'code' slot is written + // on visibilitychange while still offline (offline does NOT block local writes — + // itemService.ts comment CQ-5: Firestore's cache queues, the local slot is always + // written). flushCodeSlot asserts (via waitForFunction) the slot carries the token. + await flushCodeSlot(page, TOKEN); + + // Restore connectivity for a clean teardown; the page reports online again. + await context.setOffline(false); + await expect + .poll(() => page.evaluate(() => navigator.onLine), { timeout: 5_000 }) + .toBe(true); +}); + +// ────────────────────────────────────────────────────────────────────────────── +// NET-1 (round-trip, local-only): an edit made while OFFLINE persists across a +// reload made AFTER coming back online — proving the offline write reached the +// 'code' slot the bare-'/' boot reads back. +// +// Guarded with PW_BASE_URL skip: this relies on the clean localStorage slate from +// gotoFresh and the deterministic 'code'-slot boot branch (preserveLastCode). On a +// remote staging/prod target that slate isn't guaranteed and the reload-reads-slot +// timing competes with third-party scripts, so self-skip on the staging gate (the +// core test above already proves offline is reflected + the edit survives). +// ────────────────────────────────────────────────────────────────────────────── +test('NET-1: an offline edit survives a reload after coming back online', async ({ + page, + context, +}) => { + test.skip( + !!process.env.PW_BASE_URL, + 'reload-reads-code-slot round-trip needs the local clean-slate guarantee', + ); + + await gotoFresh(page); + + // Go offline, then edit. + await context.setOffline(true); + await expect + .poll(() => page.evaluate(() => navigator.onLine), { timeout: 5_000 }) + .toBe(false); + + const TOKEN = 'OfflineSurvivesReload'; + const DSL = `${TOKEN}\nMate\nMate->${TOKEN}: persistAcrossReload`; + await typeDsl(page, DSL); + await expect(editorLocator(page)).toContainText(TOKEN); + + // Flush the last-code slot WHILE STILL OFFLINE — the write must not depend on + // the network being up. + await flushCodeSlot(page, TOKEN); + + // Come back online, then reload bare '/': boot takes the preserveLastCode branch + // and reads localStorage['code'], restoring our offline-made edit. + await context.setOffline(false); + await expect + .poll(() => page.evaluate(() => navigator.onLine), { timeout: 5_000 }) + .toBe(true); + + await page.reload(); + + await expect(editorLocator(page)).toBeVisible({ timeout: 15_000 }); + await expect(editorLocator(page)).toContainText(TOKEN, { timeout: 15_000 }); +}); diff --git a/e2e/tests/css-editor.spec.js b/e2e/tests/css-editor.spec.js new file mode 100644 index 00000000..d49357f6 --- /dev/null +++ b/e2e/tests/css-editor.spec.js @@ -0,0 +1,345 @@ +// CSS editor (CSS-1..CSS-6 from e2e/E2E_GAP_TEST_PLAN.md §"CSS editor"). +// +// Signed-out / local flows only. The custom-CSS pre-processor pipeline (SCSS/Less/ +// Stylus/ACSS) is Plus-gated for SIGNED-IN users; for a SIGNED-OUT user every +// non-plain CSS *user edit* (handleSetCssMode / handleSetCss) routes through +// AppRoot.cssGated() which opens the sign-in modal and WITHHOLDS the change +// (verified in web/src/app/AppRoot.tsx:604-632, and exercised by modals-gap.spec.js +// MOD-7). So we cannot reach the compiled-CSS render path by *typing* — instead we +// seed an item that ALREADY carries `cssMode:'scss'|'less'|'acss'` via the library +// JSON-import path (AppRoot.handleImport → saveItems) and OPEN it (handleOpenItem → +// loadItem). loadItem/import never call cssGated(), and the async transpile effect +// (AppRoot.tsx:354-361 computeCss → transpiledCss → previewCss) runs purely on +// item.cssMode/item.css regardless of auth. The preview iframe applies that CSS as +// the textContent of `#zenumlstyle` (web/src/preview/previewHtml.ts:55 + +// previewBootstrap.runtime.js:159-161), which is the OBSERVABLE outcome we assert. +// +// SURFACE NOTES (verified against web/src/** on 2026-06-18): +// - CSS panel (CSS-1): empty CSS → collapsed strip `css-panel-strip`; clicking it +// opens `css-panel-expanded` whose footer carries `css-panel-collapse` +// (web/src/components/editor/CssPanel.tsx). Opening the panel is NOT gated. +// - CSS mode select (CSS-2): a Radix Select `css-mode-select` rendering CSS_MODES +// (AppRoot.tsx:71-78) = CSS/SCSS/Sass/Less/Stylus/Atomic CSS. OPENING the +// dropdown is not gated (only *picking* a non-css option is), so the six +// options are assertable for a signed-out user. +// - ACSS settings entry (CSS-4): when item.cssMode === 'acss' the headerControls +// reveal an `acss-settings-open` button (AppRoot.tsx:1281-1290) that opens the +// AtomicCssSettingsModal (`acss-modal`, web/src/components/modals/AtomicCssSettingsModal.tsx). +// - Prettier-format (CSS-6): Mod-Shift-f is a CSS-only CodeMirror binding +// (web/src/editor/CodeEditor.tsx:196-215) that runs formatCss(prettier) and +// dispatches the reformatted text into the CM doc. The dispatched doc change +// fires @uiw's updateListener → onChange → handleSetCss, which for a SIGNED-OUT +// user is gated (cssGated withholds the store write; @uiw is controlled on the +// parent `value` and reverts the buffer). So the format result is NOT observable +// for a signed-out user → test.fixme. +// - CSS-5 (Plus-gate routes a free SIGNED-IN user to pricing): needs emulator auth +// → test.fixme (mirrors cloud.fixme.spec.js CSS-5). +// +// GUARDS: CSS-3 / CSS-4 drive a hidden via setInputFiles and read +// the preview iframe — meaningful only against the local app — so they self-skip on +// the remote staging gate (PW_BASE_URL set), the convention export/production-build +// specs use. + +import { test, expect } from '@playwright/test'; +import { suppressOneTimeModals } from './helpers/onetime'; +import { openEditor, gotoHome } from './helpers/hub'; + +// Deployed sites load third-party analytics/CDN scripts that throw uncaught errors +// we don't own; treat those as noise (copied verbatim from smoke/library specs). +const THIRD_PARTY_ERROR_SOURCES = [ + 'userscript.js', + 'gtm.js', + 'googletagmanager', + 'google-analytics', + 'analytics.js', + 'clarity.js', + 'clarity.ms', + 'paddle.js', + 'cdn.paddle.com', + 'zaraz', + 'cloudflareinsights', +]; + +function isThirdPartyError(err) { + const haystack = `${err?.message || ''}\n${err?.stack || ''}`; + return THIRD_PARTY_ERROR_SOURCES.some((src) => haystack.includes(src)); +} + +test.beforeEach(async ({ page }) => { + page.on('pageerror', (err) => { + if (isThirdPartyError(err)) return; + throw err; + }); + // M04: keep the Onboarding / Support-pledge one-time modals from trapping focus. + await suppressOneTimeModals(page); +}); + +/** Navigate to the EDITOR with a clean localStorage slate (export.spec.js pattern). */ +async function gotoFresh(page) { + await page.goto('/'); + await page.evaluate(() => localStorage.clear()); + await openEditor(page); +} + +/** + * Seed ONE item via the HomeView JSON-import path and OPEN it in the editor. + * + * Why import (not type): the non-plain CSS modes are Plus-gated on *user edits* for a + * signed-out user (cssGated → sign-in modal, change withheld). handleImport writes + * the item fields verbatim, and handleOpenItem → loadItem reloads them WITHOUT + * touching cssGated — so the boot-time transpile effect runs on the seeded + * cssMode/css and the preview reflects the COMPILED css. Returns the seeded id. + */ +async function importAndOpen(page, item) { + await gotoHome(page); + const payload = JSON.stringify({ items: [item] }); + await page.locator('[data-testid="lib-import-input"]').setInputFiles({ + name: 'seed.json', + mimeType: 'application/json', + buffer: Buffer.from(payload, 'utf-8'), + }); + // Re-navigate so a fresh useItems mount reads the updated local index, then open + // the card (handleOpenItem → loadItem → navigate to ?id=). + await gotoHome(page); + const card = page.locator(`[data-testid="home-card-${item.id}"]`); + await expect(card).toBeVisible({ timeout: 10_000 }); + await card.click(); + await expect( + page.locator('[data-testid="dsl-editor"] .cm-content'), + ).toBeVisible({ + timeout: 15_000, + }); +} + +/** Build a minimal importable Item with the given css fields. */ +function seedItem({ id, cssMode, css = '', html = '', cssSettings }) { + return { + id, + title: `seed-${cssMode}`, + js: 'Alice -> Bob: Hello', + css, + html, + htmlMode: 'html', + cssMode, + jsMode: 'js', + ...(cssSettings !== undefined ? { cssSettings } : {}), + updatedOn: Date.now(), + }; +} + +/** + * Poll the preview iframe's user-CSS rail (#zenumlstyle) and return its raw + * textContent. computeCss output is pushed there as the