// Shared Playwright helpers for the editor tests. // // What's here // ─────────── // • `gotoFreshEditor(page)` — navigate to the editor with a clean // localStorage and a freshly-unregistered service worker, so each // test starts from a known empty canvas regardless of state left // behind by previous tests or local browsing. // // • `addTextViaSidebar(page, { text })` — drive the Text tab to add // a text element. Used by multiple specs so the pattern lives in // one place. // // • `getElementsCount(page)` — read the LayersPanel's "Layers (N)" // title to assert how many elements exist on the canvas. The // panel is the canonical user-visible source of truth for that // count. // // • `pressUndo(page)` / `pressRedo(page)` — Cmd+Z / Cmd+Shift+Z // wrappers that send the same keystrokes the user would. Modifier // is meta on macOS, control elsewhere; we detect via the // userAgent. (The app's keyboard handler accepts either modifier // so we could hardcode one, but using the platform-correct one // keeps the tests honest about what real users do.) // // Why these live here rather than inline in each spec // ─────────────────────────────────────────────────── // Three reasons. (1) Several specs need fresh-canvas setup; if it // drifts inline-in-each-test, "fresh" stops meaning the same thing // across the suite. (2) The localStorage + service-worker reset is // non-obvious and easy to skip; centralising it ensures every test // gets the full reset rather than just the obvious bits. (3) When // the app's selectors change (e.g. someone renames the LayersPanel // title), one edit here updates every test rather than touching each // spec file. import { expect } from '@playwright/test'; /** * Navigate to the editor with a fully fresh state. * * The order matters: * 1. goto('/') first so we have a page context to evaluate against. * Without this, `evaluate` has nowhere to run. * 2. Clear localStorage AND unregister any service workers AND * clear caches. The PWA's service worker caches static assets; * stale caches between test runs can mask real regressions * (e.g. an app code change that breaks the editor but a cached * SW returns the old version). * 3. Reload to apply the wipe — the first goto loaded the app * against whatever state was there, so step 2's wipe wouldn't * affect the current page without a reload. * 4. Wait for the canvas to mount before returning. The Konva * Stage takes a few frames after the first paint to be ready * for interaction; clicking buttons before then can race. * * The wait targets are: * • `.canvas-area` — the DOM container is present immediately * once React mounts. * • A document-fonts-ready check — without this, text-related * assertions can race against the woff2 fetches that * useFontsReady waits for. measureTextWidth returns * fallback-font values until the real fonts arrive. */ export async function gotoFreshEditor(page) { // Step 1: land on the editor so we have storage access. await page.goto('/'); // Step 2: wipe persisted state + SW caches. await page.evaluate(async () => { try { localStorage.clear(); } catch { /* ignore */ } try { sessionStorage.clear(); } catch { /* ignore */ } // Unregister all service workers. The app's PWA registers one // on first load; without unregistering, a SW from a previous // test run can serve stale assets. if ('serviceWorker' in navigator) { const regs = await navigator.serviceWorker.getRegistrations(); await Promise.all(regs.map((r) => r.unregister())); } // Wipe Cache API entries (the SW's storage layer). if ('caches' in window) { const keys = await caches.keys(); await Promise.all(keys.map((k) => caches.delete(k))); } }); // Step 3: reload to apply the wipe. await page.reload(); // Step 4: wait for the editor's primary surfaces. // canvas-area exists once App.jsx renders. The stage's // tag is inside but doesn't need to be specifically awaited — // it's children of canvas-area. await page.locator('.canvas-area').waitFor({ state: 'visible' }); // Wait for fonts so the text-width measurements settle. This // mirrors what useFontsReady does inside the app — both run // until document.fonts.ready resolves. Without it, // placeTextCentered uses fallback widths and the placement- // assertion in tests can be off by tens of pixels. await page.evaluate(() => document.fonts.ready); } /** * Add a text element via the Text tab. Returns once the new element * has appeared in the LayersPanel — at which point it's safe to * make assertions about its presence on the canvas. * * The flow is: * 1. Click the Text tab in the sidebar to make sure it's active. * In a fresh editor the default tab is Upload, so we have to * switch. * 2. Type into the "Your message" textarea, replacing the * pre-populated "Your text here" draft. * 3. Click "Add text to canvas". * 4. Wait for the Layers panel to show the new layer. * * We don't change fontFamily / fontSize / fill here — they default * to whatever the draft has (DEFAULT_DRAFT in TextTab.jsx). Tests * that care about a specific font / size can call the relevant * controls afterward. */ export async function addTextViaSidebar(page, { text }) { // Ordering matters: deselect FIRST, switch to Text tab SECOND. // ──────────────────────────────────────────────────────────── // On desktop, App.jsx derives `editingTextElement` directly from // `selectedElement?.type === 'text'`, AND it has an auto-tab-switch // effect that runs on every text-selection transition: // // text → not-text : auto-switches sidebar from 'text' → 'upload' // anything → text : auto-switches sidebar to 'text' // // So after a previous addTextViaSidebar: // • A text element is selected. // • The sidebar is on the 'text' tab. // • TextTab is rendered in EDIT MODE (the Add button is gone, // replaced by fields editing the selected element). // // The naive ordering (click Text tab → deselect → wait for Add // button) doesn't work, because the deselect ALSO fires the // auto-tab-switch which snaps the sidebar back to 'upload'. The // Text tab unmounts entirely; the Add button isn't hidden, it's // gone with the rest of the tab. The waitFor times out looking // for a button that doesn't exist anywhere on the page. // // Correct ordering: deselect FIRST (sidebar auto-snaps to 'upload', // any prior selection clears), THEN click the Text tab to switch // back into it. Our explicit click is the LAST tab interaction, // so it wins; TextTab mounts in draft mode (no selected text // element to bind to) and renders the Add button. // // Deselection strategy // ──────────────────── // We tried three approaches; only the third works, and the journey // is documented so a future maintainer doesn't repeat it. // // 1. Press Escape. App.jsx wires Escape to `deselectAll`, but the // keyboard handler has an input-gate early return on INPUT / // TEXTAREA targets. After Playwright's previous `.fill()` the // textarea owns focus, so bubbled keydown's `e.target` is the // textarea and the handler bails. Programmatic blur didn't // help — Playwright tracks focus internally and routes // keystrokes through that internal state, not document.activeElement. // // 2. Click `.canvas-area` at `{x:5, y:5}`. App.jsx's mousedown // deselect handler skips clicks inside the Konva stage container // (stageContainerRef). The stage container fills most of // canvas-area, so corner positions land inside it and the // handler bails. The pink-frame deselect zone is narrower than // it looks from the docblock alone. // // 3. (Current) Dispatch a real `mousedown` event with the // canvas-area element itself as `e.target`. The handler's // early-return checks pass cleanly: canvasRef.contains(canvas-area) // is true (a node contains itself), and none of the excluded // refs (toolbar / zoom / stage container) CONTAIN their // ancestor canvas-area. The handler proceeds to deselectAll, // which clears selectedId AND triggers the auto-tab-switch to // 'upload'. const addButton = page.getByRole('button', { name: 'Add text to canvas' }); // Cheap selection probe: if there's no LayersPanel content (count is // zero) OR the Text tab isn't even active, there's no selection to // clear and we can skip the deselect path entirely. The probe avoids // a 300ms isVisible wait on every call when we'd just no-op. const currentCount = await getElementsCount(page); if (currentCount > 0) { // Deselect via a dispatched mousedown on canvas-area itself. This // is the only reliable path; see strategy notes above. await page.evaluate(() => { const area = document.querySelector('.canvas-area'); if (!area) return; const event = new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window, }); area.dispatchEvent(event); }); } // Now switch to the Text tab. After the deselect above, sidebar // is on 'upload' (auto-snapped); our explicit click moves it to // 'text' for the add. TextTab mounts in draft mode and renders // the Add button. await page.getByRole('tab', { name: /text/i }).click(); // Wait for the Add button. With the correct tab+deselect order, // this should be near-instant. await addButton.waitFor({ state: 'visible', timeout: 5000 }); // Fill the textarea. The label is "Your message" per TextTab.jsx // (``). const textarea = page.getByLabel('Your message'); await textarea.fill(text); // Capture the current layer count so we can wait for it to // increment. Reading the title text avoids us having to count // rendered
  • rows, which can be slower to read than the // pre-aggregated count in the title. const before = await getElementsCount(page); // Step 3: submit. The button is exposed by its visible text. await addButton.click(); // Step 4: wait for the count to increment. expect.poll handles // the race between the click and the React commit + Konva mount. await expect.poll(() => getElementsCount(page)).toBe(before + 1); } /** * Read the current element count from the LayersPanel title. * Returns 0 when no layers exist (the panel renders an empty-state * banner instead of the "Layers (N)" title in that case). * * The panel's title is `Layers (N)` per LayersPanel.jsx: *

    Layers ({elements.length})

    * * We parse the N out of the title rather than counting
  • * elements because the panel's empty state doesn't render any *
  • s OR the title — we'd need two different code paths for * "zero" vs "non-zero" if we counted rows. */ export async function getElementsCount(page) { // Title may not exist if the panel is showing its empty state. // Use .count() rather than waitFor to make this non-blocking. const title = page.locator('.layers-title'); if ((await title.count()) === 0) return 0; const text = await title.first().textContent(); // Title format: "Layers (N)". Extract N. Defensive fallback to 0 // if the format changes (test still fails downstream with a // useful message rather than throwing here). const match = /\((\d+)\)/.exec(text || ''); return match ? parseInt(match[1], 10) : 0; } /** * Send a Cmd+Z (macOS) / Ctrl+Z (other) keystroke. The app's * keyboard handler in App.jsx accepts either modifier, but using * the platform-correct one keeps the test mirror of real user * behaviour. * * The keystroke is sent to `document.body` (Playwright's default * for page.keyboard.press) rather than a specific element. The * app's handler is attached to `window`, so the focus target * doesn't matter — as long as it isn't inside an or *