Files
apparel-designer/e2e/fixtures/editor.js
2026-05-23 03:28:58 -05:00

282 lines
13 KiB
JavaScript

// 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 <canvas>
// 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
// (`<label htmlFor="tt-text-input">Your message</label>`).
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 <li> 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:
* <h3 className="layers-title">Layers ({elements.length})</h3>
*
* We parse the N out of the title rather than counting <li>
* elements because the panel's empty state doesn't render any
* <li>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 <input> or
* <textarea>, where the handler's early-return skips the
* shortcut.
*/
export async function pressUndo(page) {
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+z' : 'Control+z');
}
export async function pressRedo(page) {
// The handler accepts either Cmd+Shift+Z or Cmd+Y; we use
// Shift+Z because it's the more conventional shortcut and
// covers the same code path.
const isMac = process.platform === 'darwin';
await page.keyboard.press(isMac ? 'Meta+Shift+z' : 'Control+Shift+z');
}