282 lines
13 KiB
JavaScript
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');
|
|
}
|