diff --git a/e2e/tests/helpers/hub.js b/e2e/tests/helpers/hub.js index 8421bbf5..5c904e15 100644 --- a/e2e/tests/helpers/hub.js +++ b/e2e/tests/helpers/hub.js @@ -1,28 +1,31 @@ -// Shared E2E helpers for the Hub UI (PRs #800/#801; f023f89 "no-rail layout"). +// Shared E2E helpers for the Hub UI. // -// Since the hub landed, bare '/' no longer boots the editor: it renders the -// HomeView library page (data-testid="home-view"), and the editor is reached by -// clicking "New diagram" (home-empty-new — empty-library CTA) or the header New -// button (home-new). The Sidebar icon rail was removed app-wide (f023f89), so -// nothing navigates via sidebar-* anymore. Editor URLs (/?id= or ?code=/?embed) -// still render their surface directly without passing through the hub. +// Editor-as-landing (2026-06-13): bare '/' boots the EDITOR again (resume +// last-code, else seed a sample diagram), so '/' renders data-testid="dsl-editor" +// directly. The HomeView library page (data-testid="home-view") is now OPT-IN via +// '/?view=diagrams'. The Sidebar icon rail was removed app-wide (f023f89), so +// nothing navigates via sidebar-* anymore. Editor deep links (/?id= or ?code=/ +// ?embed) still render their surface directly. // // Every spec that needs the editor goes through openEditor(); specs that need -// the library grid go through gotoHome(). Keeping the click-through in ONE place -// means the next hub-routing change is a one-file fix. +// the library grid go through gotoHome() — which navigates to '/?view=diagrams'. +// Keeping the routing in ONE place means the next routing change is a one-file fix. import { expect } from '@playwright/test'; const HOME_VIEW = '[data-testid="home-view"]'; const EDITOR_SURFACE = '[data-testid="dsl-editor"] .cm-content'; +// The hub/library is opt-in via this query param under editor-as-landing. +export const HUB_URL = '/?view=diagrams'; + /** * Navigate to `url` (default '/') and end up in the editor. * - * - If the URL renders the HomeView hub, click through its New CTA - * (home-empty-new when the library is empty, else home-new). - * - If the URL renders the editor directly (e.g. '/?id=…' or '/?code=…'), - * just wait for the editor surface. + * - If the URL renders the editor directly (the default for bare '/', and for + * '/?id=…' or '/?code=…'), just wait for the editor surface. + * - If the URL renders the HomeView hub ('/?view=diagrams'), click through its + * New CTA (home-empty-new when the library is empty, else home-new). * * React renders after the 'load' event page.goto resolves on, so we first wait * for WHICHEVER surface this URL produces (home-view or the CM6 editor) before @@ -32,7 +35,9 @@ export async function openEditor(page, { url = '/' } = {}) { await page.goto(url); const homeView = page.locator(HOME_VIEW); const editorSurface = page.locator(EDITOR_SURFACE); - await expect(homeView.or(editorSurface).first()).toBeVisible({ timeout: 15_000 }); + await expect(homeView.or(editorSurface).first()).toBeVisible({ + timeout: 15_000, + }); if (await homeView.isVisible()) { const emptyCta = page.locator('[data-testid="home-empty-new"]'); if (await emptyCta.isVisible()) { @@ -45,13 +50,15 @@ export async function openEditor(page, { url = '/' } = {}) { } /** - * Navigate to the HomeView library page ('/'). + * Navigate to the HomeView library page ('/?view=diagrams'). * - * A FULL navigation (not client-side breadcrumb) so the signed-out useItems - * mount re-reads the localItems index from a clean slate — robust regardless of - * whether the in-page subscription has caught up. + * Editor-as-landing: the hub is opt-in via ?view=diagrams; bare '/' now boots + * the editor, so the library is reached only by this param. A FULL navigation + * (not a client-side breadcrumb) so the signed-out useItems mount re-reads the + * localItems index from a clean slate — robust regardless of whether the in-page + * subscription has caught up. */ export async function gotoHome(page) { - await page.goto('/'); + await page.goto(HUB_URL); await expect(page.locator(HOME_VIEW)).toBeVisible({ timeout: 15_000 }); } diff --git a/e2e/tests/library.spec.js b/e2e/tests/library.spec.js index 7d20dc2e..d41be22e 100644 --- a/e2e/tests/library.spec.js +++ b/e2e/tests/library.spec.js @@ -1,8 +1,9 @@ // M03 Task 15 — Library E2E (signed-out / local flows only). // -// Hub (PRs #800/#801; f023f89 "no-rail layout"): the in-editor Library PANEL was -// retired — the Sidebar icon rail (sidebar-library) and LibraryPanel no longer -// render anywhere. The web library IS the HomeView page at '/': rows are +// f023f89 "no-rail layout": the in-editor Library PANEL was retired — the +// Sidebar icon rail (sidebar-library) and LibraryPanel no longer render anywhere. +// The web library IS the HomeView page, reached at '/?view=diagrams' under +// editor-as-landing (2026-06-13; bare '/' boots the editor again). Rows are // home-card- cards in home-grid, search is home-search (same SearchInput // component, so the clear affordance is still search-clear), and the bulk // Export-all / Import controls (lib-export-all / lib-import-input) were re-homed @@ -21,10 +22,10 @@ // /create-share reads the cloud item doc + needs a fresh ID token). Same auth // note as persistence.spec.js / M02. // -// Navigation note: seeding happens in the EDITOR (reached through the hub's New -// CTA — openEditor); reading the library back happens by a FULL navigation to -// '/' (gotoHome), which freshly mounts useItems and re-reads the localItems -// index regardless of in-page subscription timing. +// Navigation note: seeding happens in the EDITOR (bare '/' boots it directly +// under editor-as-landing — openEditor); reading the library back happens by a +// FULL navigation to '/?view=diagrams' (gotoHome), which freshly mounts useItems +// and re-reads the localItems index regardless of in-page subscription timing. import { test, expect } from '@playwright/test'; import { suppressOneTimeModals } from './helpers/onetime'; @@ -62,8 +63,8 @@ async function gotoFresh(page) { await suppressOneTimeModals(page); await page.goto('/'); await page.evaluate(() => localStorage.clear()); - // Hub: '/' is the HomeView library — seeding drives the editor's header, so - // click through the hub's New CTA (empty library → home-empty-new). + // Editor-as-landing: bare '/' boots the editor (cleared storage → seeds a fresh + // sample diagram), so openEditor lands straight on the CM6 surface here. await openEditor(page); } @@ -134,10 +135,11 @@ async function seedItem(page, { title, dsl, firstSave = false }) { } /** - * Navigate to the library. Hub (PRs #800/#801): the library is no longer an - * in-editor side panel (sidebar-library + library-panel are gone — f023f89 - * removed the rail and LibraryPanel was retired); it is the HomeView page at - * '/'. gotoHome is a FULL navigation, so useItems freshly re-reads the + * Navigate to the library. The library is no longer an in-editor side panel + * (sidebar-library + library-panel are gone — f023f89 removed the rail and + * LibraryPanel was retired); it is the HomeView page. Editor-as-landing + * (2026-06-13): the hub moved off bare '/' and is now opt-in at '/?view=diagrams'. + * gotoHome navigates there (a FULL navigation), so useItems freshly re-reads the * localItems index — same guarantee the old reload-then-open-panel shape gave. */ async function reloadIntoLibrary(page) { @@ -147,7 +149,9 @@ async function reloadIntoLibrary(page) { // ────────────────────────────────────────────────────────────────────────────── // Test 1: Library lists locally-saved diagrams; search filters them. // ────────────────────────────────────────────────────────────────────────────── -test('library lists local items and SearchInput filters them', async ({ page }) => { +test('library lists local items and SearchInput filters them', async ({ + page, +}) => { page.on('pageerror', (err) => { if (isThirdPartyError(err)) return; throw err; @@ -177,7 +181,9 @@ test('library lists local items and SearchInput filters them', async ({ page }) // Both saved items appear as cards in the home grid (hub PRs: library-list / // lib-row-* became home-grid / home-card-* — see HomeView.tsx + DiagramCard.tsx). - await expect(page.locator('[data-testid="home-grid"]')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('[data-testid="home-grid"]')).toBeVisible({ + timeout: 10_000, + }); await expect(page.getByText('AlphaDiagram', { exact: true })).toBeVisible(); await expect(page.getByText('GammaDiagram', { exact: true })).toBeVisible(); @@ -219,7 +225,9 @@ test('export-all triggers a JSON file download', async ({ page }) => { }); await reloadIntoLibrary(page); - await expect(page.getByText('ExportableDiagram', { exact: true })).toBeVisible(); + await expect( + page.getByText('ExportableDiagram', { exact: true }), + ).toBeVisible(); // Clicking export-all builds a Blob and triggers a download (handleExportAll → // downloadText('zenuml-diagrams.json', …)). Assert the download event fires. @@ -251,7 +259,9 @@ test('importing a JSON file adds a new library row', async ({ page }) => { }); await reloadIntoLibrary(page); - await expect(page.getByText('PreexistingDiagram', { exact: true })).toBeVisible(); + await expect( + page.getByText('PreexistingDiagram', { exact: true }), + ).toBeVisible(); // Build a minimal import payload matching the { items: Item[] } shape that // exportAllItemsJson produces and parseImportJson accepts. The migrate step @@ -288,6 +298,10 @@ test('importing a JSON file adds a new library row', async ({ page }) => { // regardless of in-page subscription timing. await reloadIntoLibrary(page); - await expect(page.getByText('ImportedDiagram', { exact: true })).toBeVisible(); - await expect(page.getByText('PreexistingDiagram', { exact: true })).toBeVisible(); + await expect( + page.getByText('ImportedDiagram', { exact: true }), + ).toBeVisible(); + await expect( + page.getByText('PreexistingDiagram', { exact: true }), + ).toBeVisible(); }); diff --git a/e2e/tests/persistence.spec.js b/e2e/tests/persistence.spec.js index 0531b00f..4ef6ff7c 100644 --- a/e2e/tests/persistence.spec.js +++ b/e2e/tests/persistence.spec.js @@ -3,14 +3,14 @@ // // Tests the local-persistence layer (localStorage) that survives page reloads // and across tabs — without any Firebase auth: -// 1. typed DSL survives a reload of the editor URL (hub re-ground — see test 1) +// 1. typed DSL survives a reload of the editor URL (last-code restore — see test 1) // 2. per-page content is isolated across multi-page tabs // 3. "New" resets to the default starter DSL // -// Hub (PRs #800/#801): bare '/' renders the HomeView library, so every test -// reaches the editor through the hub's New CTA (openEditor) and the editor lives -// at /?id=. That URL shape changes WHICH boot branch a reload exercises — -// see the test-1 comment. +// Editor-as-landing (2026-06-13): bare '/' boots the EDITOR directly (no ?id), +// so every test reaches the editor via openEditor() landing straight on '/'. A +// reload of bare '/' re-enters the boot resolver, which (with preserveLastCode, +// default true) reads localStorage['code'] back into the editor — see test 1. import { test, expect } from '@playwright/test'; import { suppressOneTimeModals } from './helpers/onetime'; @@ -60,9 +60,9 @@ async function gotoFresh(page) { // Step 1: land on the app origin so we can write to its localStorage. await page.goto('/'); await page.evaluate(() => localStorage.clear()); - // Step 2: hub (PRs #800/#801) — '/' is the HomeView library; reach the editor - // through the New CTA. User data is empty; the init script re-seeds only the - // one-time flags. We do NOT re-clear here. + // Step 2: editor-as-landing — bare '/' boots the editor directly. With storage + // cleared, boot seeds a fresh sample diagram; openEditor lands on the CM6 + // surface. The init script re-seeds only the one-time flags. We do NOT re-clear. await openEditor(page); } @@ -82,21 +82,26 @@ function editorLocator(page) { } // ────────────────────────────────────────────────────────────────────────────── -// Test 1: Typed DSL survives a page reload of the editor URL. +// Test 1: Typed DSL survives a page reload of the editor URL (last-code restore). // -// HUB RE-GROUND (PRs #800/#801) — this used to be "last-code restore": reload at -// '/' booted the preserveLastCode branch, which read localStorage['code'] back -// into the editor. Under the hub the editor lives at /?id= and bare '/' -// renders the HomeView library, so the boot-time last-code READ branch -// (resolveBootItem branch 3 — only reachable with NO ?id) can no longer be hit -// from any editor URL on the web (verified live: reloading /?id= with an unsaved -// item boots 'new' → starter DSL). The reload-persistence contract the user sees -// is now: SAVE the diagram → reload /?id= → boot getItem(id) restores the -// local copy. This test follows that contract; the still-live last-code WRITE -// path (visibilitychange/beforeunload → 'code' slot, REQ-PST) keeps its -// assertion below so a regression in the write side stays visible. +// EDITOR-AS-LANDING RE-GROUND (2026-06-13): bare '/' boots the EDITOR directly, +// and with preserveLastCode (default true) a reload of '/' re-enters the boot +// resolver's last-code branch (resolveBootItem branch 3) — reading +// localStorage['code'] back into the editor. This reinstates the original +// last-code-restore contract that the interim hub layout had displaced (under the +// hub bare '/' rendered the library, so that read branch was unreachable from any +// editor URL). The user-visible contract is again: edit at '/' → the code slot is +// written on tab-hide/unload → reload '/' restores it. +// +// We ALSO save the diagram first. That keeps two things guarded: +// - the signed-out Save path (and its one-time "Saved on this device" notice), +// - the local item slot write (itemService.setItem → localStorage[]). +// But the RESTORE on reload rides on the last-code 'code' slot, because bare '/' +// carries no ?id (save() does not navigate), so boot takes branch 3, not getItem. // ────────────────────────────────────────────────────────────────────────────── -test('reload persistence: saved DSL is restored when the editor URL reloads', async ({ page }) => { +test('reload persistence: typed DSL is restored when the editor URL reloads', async ({ + page, +}) => { page.on('pageerror', (err) => { if (isThirdPartyError(err)) return; throw err; @@ -114,29 +119,11 @@ test('reload persistence: saved DSL is restored when the editor URL reloads', as // Confirm the text landed in the editor before triggering the reload. await expect(editorLocator(page)).toContainText(UNIQUE_PARTICIPANT); - // REQ-PST write path (unchanged by the hub): AppRoot writes the last-code slot - // on `beforeunload` AND on `visibilitychange` (document.hidden → true). - // Dispatch visibilitychange explicitly and poll until localStorage['code'] - // contains our token — keeps the write side guarded even though the web boot - // no longer reads it back at an editor URL (see header comment). - await page.evaluate(() => { - Object.defineProperty(document, 'hidden', { value: true, configurable: true }); - document.dispatchEvent(new Event('visibilitychange')); - }); - await page.waitForFunction( - (participant) => { - const raw = localStorage.getItem('code'); - if (!raw) return false; - try { return JSON.parse(raw)?.js?.includes(participant); } catch { return false; } - }, - UNIQUE_PARTICIPANT, - { timeout: 5_000 }, - ); - - // Hub restore contract: SAVE the diagram so the reload's getItem(?id) finds the - // local copy. Save lives inside the header-menu dropdown; the FIRST signed-out - // save opens the one-time "Saved on this device" notice (fresh slate → - // loginAndSaveMessageSeen is false, so it shows with certainty) — dismiss it. + // Save the diagram (signed-out). Save lives inside the header-menu dropdown; the + // FIRST signed-out save opens the one-time "Saved on this device" notice (fresh + // slate → loginAndSaveMessageSeen is false, so it shows with certainty) — dismiss + // it. This guards the Save path; the reload restore itself rides on the last-code + // slot below (bare '/' boot reads 'code', not an item id). await page.locator('[data-testid="header-menu"]').click(); await page.locator('[data-testid="header-save"]').click(); const noticeCancel = page.locator('[data-testid="confirm-cancel"]'); @@ -144,32 +131,48 @@ test('reload persistence: saved DSL is restored when the editor URL reloads', as await noticeCancel.click(); await expect(noticeCancel).toBeHidden(); - // Guard: poll until the item slot (keyed by the /?id= uuid) holds our token — - // proves the save landed before we reload. - const itemId = new URL(page.url()).searchParams.get('id'); - expect(itemId, 'editor URL must carry ?id= under the hub').toBeTruthy(); + // REQ-PST write path: AppRoot writes the last-code 'code' slot on `beforeunload` + // AND on `visibilitychange` (document.hidden → true). Dispatch visibilitychange + // explicitly and poll until localStorage['code'] contains our token — proving + // the slot the bare-'/' boot reads back is populated before we reload. + await page.evaluate(() => { + Object.defineProperty(document, 'hidden', { + value: true, + configurable: true, + }); + document.dispatchEvent(new Event('visibilitychange')); + }); await page.waitForFunction( - ({ id, participant }) => { - const raw = localStorage.getItem(id); - return !!raw && raw.includes(participant); + (participant) => { + const raw = localStorage.getItem('code'); + if (!raw) return false; + try { + return JSON.parse(raw)?.js?.includes(participant); + } catch { + return false; + } }, - { id: itemId, participant: UNIQUE_PARTICIPANT }, + UNIQUE_PARTICIPANT, { timeout: 5_000 }, ); - // Reload: boot resolves ?id= via getItem (signed-out → local copy) and restores - // the item — no addInitScript clearing runs this time. + // Reload bare '/': boot takes the preserveLastCode branch, reads localStorage + // ['code'], and restores the item — no addInitScript clearing runs this time. await page.reload(); // Wait for the editor to re-hydrate with the restored content. await expect(editorLocator(page)).toBeVisible({ timeout: 15_000 }); - await expect(editorLocator(page)).toContainText(UNIQUE_PARTICIPANT, { timeout: 15_000 }); + await expect(editorLocator(page)).toContainText(UNIQUE_PARTICIPANT, { + timeout: 15_000, + }); }); // ────────────────────────────────────────────────────────────────────────────── // Test 2: Multi-page — per-page content is isolated // ────────────────────────────────────────────────────────────────────────────── -test('multi-page: each page stores its own DSL independently', async ({ page }) => { +test('multi-page: each page stores its own DSL independently', async ({ + page, +}) => { page.on('pageerror', (err) => { if (isThirdPartyError(err)) return; throw err; @@ -178,7 +181,8 @@ test('multi-page: each page stores its own DSL independently', async ({ page }) await gotoFresh(page); // Page 1: type distinctive DSL. - const DSL_PAGE1 = 'AliceService\nBobService\nAliceService->BobService: Page1Call'; + const DSL_PAGE1 = + 'AliceService\nBobService\nAliceService->BobService: Page1Call'; await typeDsl(page, DSL_PAGE1); await expect(editorLocator(page)).toContainText('AliceService'); @@ -187,7 +191,8 @@ test('multi-page: each page stores its own DSL independently', async ({ page }) // The editor remounts (CodeEditor key=`dsl-${item.currentPageId}`) for the new // empty page. Re-focus and type page-2 DSL. Allow more time for the remount. - const DSL_PAGE2 = 'CharlieService\nDaveService\nCharlieService->DaveService: Page2Call'; + const DSL_PAGE2 = + 'CharlieService\nDaveService\nCharlieService->DaveService: Page2Call'; await typeDsl(page, DSL_PAGE2, { timeout: 15_000 }); await expect(editorLocator(page)).toContainText('CharlieService'); @@ -196,21 +201,27 @@ test('multi-page: each page stores its own DSL independently', async ({ page }) await page1Tab.click(); // Editor remounts again — wait for it to show page-1 content. - await expect(editorLocator(page)).toContainText('AliceService', { timeout: 10_000 }); + await expect(editorLocator(page)).toContainText('AliceService', { + timeout: 10_000, + }); await expect(editorLocator(page)).not.toContainText('CharlieService'); // Switch back to page 2 tab. const page2Tab = page.locator('[data-testid^="page-tab-"]').nth(1); await page2Tab.click(); - await expect(editorLocator(page)).toContainText('CharlieService', { timeout: 10_000 }); + await expect(editorLocator(page)).toContainText('CharlieService', { + timeout: 10_000, + }); await expect(editorLocator(page)).not.toContainText('AliceService'); }); // ────────────────────────────────────────────────────────────────────────────── // Test 3: "New" resets to the default starter DSL // ────────────────────────────────────────────────────────────────────────────── -test('header-new: clicking New resets editor to the default starter DSL', async ({ page }) => { +test('header-new: clicking New resets editor to the default starter DSL', async ({ + page, +}) => { page.on('pageerror', (err) => { if (isThirdPartyError(err)) return; throw err; @@ -219,7 +230,8 @@ test('header-new: clicking New resets editor to the default starter DSL', async await gotoFresh(page); // Type content that is distinctly NOT the starter so New has something to replace. - const CUSTOM_DSL = 'CustomActor\nOtherActor\nCustomActor->OtherActor: CustomMessage'; + const CUSTOM_DSL = + 'CustomActor\nOtherActor\nCustomActor->OtherActor: CustomMessage'; await typeDsl(page, CUSTOM_DSL); await expect(editorLocator(page)).toContainText('CustomActor'); @@ -233,6 +245,8 @@ test('header-new: clicking New resets editor to the default starter DSL', async // After New, the editor should show the default starter DSL, not our custom content. // DEFAULT_STARTER.js = 'Alice -> Bob: Hello\nBob -> Alice: Hi back' (editorStore.ts) - await expect(editorLocator(page)).toContainText(STARTER_DSL_FRAGMENT, { timeout: 10_000 }); + await expect(editorLocator(page)).toContainText(STARTER_DSL_FRAGMENT, { + timeout: 10_000, + }); await expect(editorLocator(page)).not.toContainText('CustomActor'); }); diff --git a/e2e/tests/smoke.spec.js b/e2e/tests/smoke.spec.js index b9d3721a..2a1cb0c7 100644 --- a/e2e/tests/smoke.spec.js +++ b/e2e/tests/smoke.spec.js @@ -7,8 +7,9 @@ import { openEditor } from './helpers/hub'; // the app boots and the iframe scaffolding is in place — the dsl-spot-check spec // proves the editor -> postMessage -> @zenuml/core render path renders an SVG. // -// Hub (PRs #800/#801): '/' renders the HomeView library, not the editor — the -// editor smokes click through the hub's New CTA via openEditor(). +// Editor-as-landing (2026-06-13): bare '/' boots the EDITOR (resume last-code, +// else seed a sample diagram); the HomeView library is opt-in at '/?view=diagrams'. +// openEditor lands on the editor surface either way (a no-op click-through on '/'). // Deployed sites (staging/prod, reached via PW_BASE_URL) load third-party // analytics/CDN scripts (GTM, Cloudflare Zaraz, Clarity, Paddle) that throw @@ -40,14 +41,22 @@ test.beforeEach(async ({ page }) => { throw err; }); await suppressOneTimeModals(page); // M04: keep onboarding/pledge from trapping focus - // Hub: '/' is the HomeView library; reach the editor through the New CTA. + // Editor-as-landing: bare '/' boots the editor; openEditor lands on it directly. await openEditor(page); }); -test("'/' renders the HomeView hub library @smoke", async ({ page }) => { - // Hub (PRs #800/#801): bare '/' is the library page. Re-navigate there (the - // beforeEach clicked through into the editor) and assert the hub surface. +test("'/' boots the editor; '/?view=diagrams' renders the HomeView hub @smoke", async ({ + page, +}) => { + // Editor-as-landing (2026-06-13): bare '/' is the EDITOR surface (the beforeEach + // already landed there). Assert the editor renders at '/', and that the hub is + // reachable via the opt-in ?view=diagrams param. await page.goto('/'); + await expect(page.locator('[data-testid="dsl-editor"]')).toBeVisible(); + await expect(page.locator('[data-testid="home-view"]')).toHaveCount(0); + + // The hub library is opt-in via ?view=diagrams. + await page.goto('/?view=diagrams'); await expect(page.locator('[data-testid="home-view"]')).toBeVisible(); }); @@ -56,7 +65,9 @@ test('app loads with editor and preview iframe @smoke', async ({ page }) => { await expect(page.locator('[data-testid="preview-iframe"]')).toBeVisible(); }); -test('preview iframe carries the @zenuml/core mounting point @smoke', async ({ page }) => { +test('preview iframe carries the @zenuml/core mounting point @smoke', async ({ + page, +}) => { // CodeMirror 6 editable surface is mounted and editable. Hub (PRs #800/#801): // the hub's New CTA seeds a BLANK diagram by design (handleNewDiagramFromHome // loads js: ''), so the old "shows non-empty default text" assertion no longer