Major update for v1 and tests

This commit is contained in:
khalid@traclabs.com
2026-05-23 03:28:58 -05:00
parent 628a6765f4
commit b1fb6fa3aa
1748 changed files with 27723 additions and 1854 deletions

235
e2e/crop.spec.js Normal file
View File

@@ -0,0 +1,235 @@
// E2E: crop flow + the May 22 "canvas blanks on Cmd+Z after crop"
// regression.
//
// What this guards
// ────────────────
// The headline bug from the May 22 cleanup arc: cropping an image and
// then pressing Cmd+Z would land on an empty canvas instead of
// restoring the pre-crop state. Root cause was in the initializeHistory
// path — see Refinements_2026-05-22.md for the full story. The unit
// tests cover the math (cropMath) and the history-pointer behaviour
// (useDesignEditor) at the function level; this spec exercises the
// full pipeline end-to-end so a future regression that breaks any
// link in the chain (the Konva stage walk for naturalW/H, the
// updateAndCommit wiring, the keyboard handler firing on undo) gets
// caught.
//
// Why this spec is more complex than the other three
// ──────────────────────────────────────────────────
// Cropping is image-only — ElementToolbar's `allowCrop = isImage &&
// !!onStartCrop` gates the Crop button on type === 'image'. Stickers
// don't get a Crop button (even though App's handleStartCrop accepts
// them). That means this spec can't use a one-click sticker add; it
// needs a real file upload via the Upload Photo tab.
//
// Rather than committing a binary fixture file to the repo (and
// dealing with Git LFS or just bloat), we build the upload payload
// inline as a base64-encoded 1×1 transparent PNG. Playwright's
// `setInputFiles({ name, mimeType, buffer })` accepts an in-memory
// buffer with no file on disk. The PNG is the smallest valid PNG
// the spec can use — 67 bytes — which keeps the upload fast and
// avoids any minimum-dimension validation surprises.
//
// Image-load timing
// ─────────────────
// The Konva Image node loads the src URL asynchronously after the
// element is added to state. handleApplyCrop walks the stage to
// find the image node and read naturalWidth/Height; if those are
// 0 (image not yet decoded), it surfaces the
// "Photo is still loading. Try Apply again in a moment." toast
// and keeps crop mode active. Our helper below clicks Apply, then
// waits for crop mode to exit (Apply button vanishes); if it
// doesn't within a short window, that means the toast fired and
// we should wait + retry. This is the same UX a human user
// follows ("oh, didn't work, let me click again in a second").
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
getElementsCount,
pressUndo,
} from './fixtures/editor.js';
// Smallest valid PNG: 1×1 transparent. 67 bytes raw, ~90 base64
// chars. Decoded to a buffer for the upload. The image is too small
// to be visually meaningful but that's fine — the bug we're guarding
// against is history-shaped, not pixel-shaped. Crop math reduces to
// an identity crop on a 1×1 source, which still pushes a real
// history entry that undo must restore from.
const TINY_PNG_BASE64
= 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=';
/**
* Click "Apply crop" and wait for crop mode to exit. If the
* "Photo is still loading" toast appears (because the Konva
* Image node hasn't decoded the src yet), wait briefly and try
* again — up to a small attempt budget.
*
* Success signal: the Apply button stops being visible (because
* the ElementToolbar drops back to its non-crop face).
*
* This mirrors the toast-and-retry guidance in messages.js:
* 'toast.crop-not-ready': 'Photo is still loading. Try Apply
* again in a moment.'
*/
async function applyCropWithRetry(page) {
const applyButton = page.getByRole('button', { name: 'Apply crop' });
// 8 attempts × ~1.5s budget per attempt + intermediate waits =
// up to ~20s for the image to decode. In practice the buffer is
// already in memory so this resolves on the first attempt; the
// retry budget is purely defensive against slow CI environments.
for (let attempt = 0; attempt < 8; attempt++) {
await applyButton.click();
// If crop mode exits within the timeout, Apply succeeded.
// We use `waitFor` on the button's hidden state because
// toBeHidden() polls but doesn't return a boolean.
const exited = await applyButton
.waitFor({ state: 'hidden', timeout: 1500 })
.then(() => true)
.catch(() => false);
if (exited) return;
// Apply button still visible → toast probably fired. Wait
// for the image to finish loading. The toast itself
// auto-dismisses; we just need the underlying load to
// complete before the next click.
await page.waitForTimeout(500);
}
throw new Error('Apply crop did not exit crop mode after 8 attempts');
}
test.beforeEach(async ({ page }) => {
await gotoFreshEditor(page);
});
test('cropping an uploaded image and then Cmd/Ctrl+Z does not blank the canvas (May 22 regression)', async ({ page }) => {
// Step 1 — Switch to the Upload Photo tab. In a fresh editor
// the default tab is Upload, so this is mostly a defensive
// click in case that default changes; either way it makes the
// file input findable.
await page.getByRole('tab', { name: /upload/i }).click();
// Step 2 — Upload the PNG. The file input is hidden via CSS
// (`ut__hidden-input`); Playwright's setInputFiles bypasses
// visibility checks. The upload triggers a POST to /api/upload,
// which is processed by the Node server (Vite proxies the
// /api/* prefix to localhost:3001). The server's multer+sharp
// pipeline writes a preview and original to disk and returns
// their URLs; UploadTab's placeImage then adds the element.
//
// The selector targets `.ut__hidden-input` rather than a bare
// `input[type="file"]` because the page also contains a slot-
// upload hidden input (from the template-slot system) with
// accept="image/*" — a bare query would resolve to two elements
// and trip Playwright's strict mode. The class selector is
// unique to UploadTab.
await page.locator('input.ut__hidden-input').setInputFiles({
name: 'pixel.png',
mimeType: 'image/png',
buffer: Buffer.from(TINY_PNG_BASE64, 'base64'),
});
// Step 3 — Wait for the upload to complete and the element
// to be added. expect.poll handles the upload's async chain
// (server roundtrip + image dimension probe + addElement
// commit). The default expect timeout (5s) is plenty for a
// 67-byte upload; if it ever flakes here, CI is overloaded
// and the test budget should grow rather than this assertion.
await expect.poll(() => getElementsCount(page)).toBe(1);
// Step 4 — Make sure the element is selected so the
// ElementToolbar renders. The upload flow may or may not
// auto-select (depending on App's handleAddImage); clicking
// the layer row to select is defensive and explicit. The
// row's main button has class `.layers-item-main` and
// contains the element's display name (typically "Photo"
// or a filename-derived label for uploads).
await page.locator('.layers-item-main').first().click();
// Step 5 — Click the Crop button. It only renders for
// type==='image' elements (allowCrop guard in
// ElementToolbar). The button's accessible name comes from
// its ToolbarButton's `label` prop: "Crop image".
await page.getByRole('button', { name: /crop image/i }).click();
// Step 6 — The toolbar swapped to its crop-mode face;
// confirm by waiting for the Apply button before we drive it.
await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible();
// Step 7 — Apply the crop. We don't modify the crop rect —
// this is an IDENTITY crop, which still pushes a real
// history entry (the element gets a crop attribute set even
// though sx/sy=0 and sWidth/sHeight match naturalW/H). The
// headline bug doesn't depend on a meaningful crop; it
// depends on undo restoring the pre-crop snapshot.
await applyCropWithRetry(page);
// Step 8 — Sanity: still one element on canvas (the crop
// committed without removing the image).
expect(await getElementsCount(page)).toBe(1);
// Step 9 — THE BUG CHECK. Cmd/Ctrl+Z must walk history back
// to the pre-crop snapshot. Pre-fix, this would land on an
// empty canvas (element count → 0); post-fix, the element
// is still there.
await pressUndo(page);
expect(await getElementsCount(page)).toBe(1);
// Step 10 — Double check: the LayersPanel row for the
// image is still visible. A bug variant where the element
// exists in state but is invisible on canvas would slip
// past the count check alone; asserting the panel row
// catches the case where elements is still length 1 but
// populated with garbage.
await expect(page.locator('.layers-item-main')).toHaveCount(1);
});
test('cancelling crop leaves the original element untouched', async ({ page }) => {
// Sanity test for the cancel path. Not a regression check —
// just confirming the crop UI is reversible without going
// through history. If a future change accidentally pushes a
// history entry on Cancel (or worse, applies the crop
// anyway), this test catches it.
await page.getByRole('tab', { name: /upload/i }).click();
await page.locator('input.ut__hidden-input').setInputFiles({
name: 'pixel.png',
mimeType: 'image/png',
buffer: Buffer.from(TINY_PNG_BASE64, 'base64'),
});
await expect.poll(() => getElementsCount(page)).toBe(1);
// History-state baseline. After adding ONE element, history is
// `[empty, [img]]` at idx=1 — the Undo button is ENABLED
// ("step back to the empty floor"). We capture this enabled
// state so we can verify Cancel doesn't ADD another history
// entry on top.
//
// The trip wire isn't "Undo disabled" — it's enabled either
// way. The trip wire is the count of clickable history steps:
// a single Undo click should land us at the empty canvas
// (0 elements). If Cancel had pushed a history entry, Undo
// would walk back to that entry first (still 1 element, just
// a different snapshot) and only the SECOND undo would empty
// the canvas. We test the count-after-one-undo as the
// definitive check.
const undoButton = page.getByRole('button', { name: 'Undo' });
await expect(undoButton).toBeEnabled();
await page.locator('.layers-item-main').first().click();
await page.getByRole('button', { name: /crop image/i }).click();
await expect(page.getByRole('button', { name: 'Apply crop' })).toBeVisible();
// Cancel rather than Apply. The toolbar should drop back to
// its non-crop face; the element stays as it was.
await page.getByRole('button', { name: 'Cancel' }).click();
await expect(page.getByRole('button', { name: 'Apply crop' })).not.toBeVisible();
// Element count unchanged.
expect(await getElementsCount(page)).toBe(1);
// The definitive "Cancel didn't push history" check. ONE undo
// step should reach the empty floor. If Cancel had pushed a
// (no-op) entry, we'd undo to that intermediate state and still
// have 1 element here.
await undoButton.click();
expect(await getElementsCount(page)).toBe(0);
});

281
e2e/fixtures/editor.js Normal file
View File

@@ -0,0 +1,281 @@
// 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');
}

189
e2e/history.spec.js Normal file
View File

@@ -0,0 +1,189 @@
// E2E: undo/redo flow.
//
// What this guards
// ────────────────
// The history subsystem is the most bug-bitten area of the codebase
// (see the May 22 / May 23 Refinements notes). useDesignEditor.test.js
// covers the hook-level behaviour exhaustively, but the keyboard
// shortcuts (Cmd+Z / Cmd+Shift+Z) are attached to the window via a
// global useEffect in App.jsx — that wiring isn't exercised by Vitest.
// This spec drives the actual keystrokes and verifies the right
// things happen.
//
// Two scenarios in particular are worth catching at this layer:
//
// 1. The keyboard handler's input/textarea exclusion. The handler
// bails out early when the focused element is an <input> or
// <textarea>, so the user can type a `z` into the text field
// without accidentally undoing. Vitest can't test this because
// it doesn't render real form elements with focus state.
//
// 2. The HistoryControls buttons disabling/enabling correctly. The
// pills have `disabled` attributes wired to canUndo/canRedo,
// and the visual state of those buttons is what tells the
// user "you can / can't go back further." A bug where the
// buttons stay enabled past the history floor would only be
// visible at the DOM-attribute level.
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
addTextViaSidebar,
getElementsCount,
pressUndo,
pressRedo,
} from './fixtures/editor.js';
test.beforeEach(async ({ page }) => {
await gotoFreshEditor(page);
});
test('Cmd/Ctrl+Z removes the most recently added element', async ({ page }) => {
// The headline undo test. Add an element, undo, verify it's gone.
// This single keystroke covers the whole pipeline: the keyboard
// handler in App.jsx fires undo(), useDesignEditor walks the
// history pointer back, setElements applies the previous snapshot,
// LayersPanel re-renders.
await addTextViaSidebar(page, { text: 'Will be undone' });
expect(await getElementsCount(page)).toBe(1);
await pressUndo(page);
// The deleted element is gone; count drops to 0.
expect(await getElementsCount(page)).toBe(0);
});
test('Cmd/Ctrl+Shift+Z restores an undone element', async ({ page }) => {
// Redo after undo. The post-redo state must match the original
// (same text on the same element) — a regression where redo
// restored a different snapshot than what was undone would fail
// the layer-name assertion.
await addTextViaSidebar(page, { text: 'Resurrected' });
await pressUndo(page);
expect(await getElementsCount(page)).toBe(0);
await pressRedo(page);
expect(await getElementsCount(page)).toBe(1);
await expect(page.locator('.layers-item-name', { hasText: 'Resurrected' })).toBeVisible();
});
test('typing `z` in the text field does NOT undo (keyboard handler input gate)', async ({ page }) => {
// The keyboard handler in App.jsx has an explicit early-return:
// if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
// Without this, typing a `z` into the textarea while holding a
// modifier would hijack the keystroke. (Just typing `z` without
// a modifier doesn't trigger the undo branch anyway, but Cmd+Z
// is the danger — except even Cmd+Z is suppressed because the
// user's cursor is in a field where Cmd+Z is the OS-native
// "undo last typed character" shortcut, which the browser
// handles.)
//
// We test the simpler case here: pressing Cmd+Z while focused
// on the textarea should NOT remove the element. The OS-native
// textarea undo may or may not fire — we don't care; we care
// that OUR keyboard handler doesn't fire and remove an element
// from the canvas.
await addTextViaSidebar(page, { text: 'Safe' });
expect(await getElementsCount(page)).toBe(1);
// Focus the textarea by clicking it explicitly. addTextViaSidebar
// leaves focus elsewhere by the time it returns; we have to
// re-focus deterministically.
const textarea = page.getByLabel('Your message');
await textarea.click();
await expect(textarea).toBeFocused();
// Now press Cmd+Z. The app's handler should bail out at the
// tagName check. The element on the canvas stays.
await pressUndo(page);
// Verification: element count is still 1.
expect(await getElementsCount(page)).toBe(1);
});
test('Undo and Redo buttons in the bottom bar reflect history state', async ({ page }) => {
// HistoryControls renders two buttons with aria-label "Undo" /
// "Redo" and a `disabled` attribute wired to canUndo / canRedo.
// The button states are how users (and screen readers) see
// whether stepping further is available.
//
// History initialization model (confirmed by running the suite):
// a fresh editor's history is seeded with the empty snapshot at
// idx=0. canUndo = `idx > 0`, so a fresh editor has canUndo false.
// The FIRST add pushes a new snapshot and advances idx to 1 — at
// which point canUndo flips to true because the user can step
// back to the empty floor. This contradicted my initial mental
// model ("first add gives idx=0") but matches what the app
// actually does; the May 22 cleanup arc relies on that empty
// floor being there as a restoration target.
const undoButton = page.getByRole('button', { name: 'Undo' });
const redoButton = page.getByRole('button', { name: 'Redo' });
// Fresh editor: history=[empty], idx=0.
// canUndo = 0 > 0 = false (we're at the floor)
// canRedo = 0 < length-1=0 = false (nothing to redo)
await expect(undoButton).toBeDisabled();
await expect(redoButton).toBeDisabled();
// After one add: history=[empty, [A]], idx=1.
// canUndo = 1 > 0 = true (can step back to empty)
// canRedo = 1 < length-1=1 = false (we're at the head)
await addTextViaSidebar(page, { text: 'First' });
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// After two adds: history=[empty, [A], [A,B]], idx=2.
// canUndo = 2 > 0 = true
// canRedo = 2 < length-1=2 = false
await addTextViaSidebar(page, { text: 'Second' });
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// Click Undo: idx 2 → 1. Now we're mid-history with both
// directions available.
// canUndo = 1 > 0 = true
// canRedo = 1 < length-1=2 = true (can re-apply the second add)
await undoButton.click();
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeEnabled();
// Click Redo: idx 1 → 2. Back to post-two-adds state.
await redoButton.click();
await expect(undoButton).toBeEnabled();
await expect(redoButton).toBeDisabled();
// Finally, undo twice to reach the floor and confirm undo
// disables at idx=0. This is the assertion the first-add test
// ABOVE used to (incorrectly) make against a one-add history;
// the only way to actually get there is to wind all the way
// back to the seeded empty floor.
await undoButton.click(); // idx 2 → 1
await undoButton.click(); // idx 1 → 0 (the floor)
await expect(undoButton).toBeDisabled();
await expect(redoButton).toBeEnabled();
});
test('a new action after undo discards the redo stack', async ({ page }) => {
// Standard editor behaviour: after undo, redo is available. But
// a new edit branches history forward from the current idx,
// discarding anything that was previously to the right of it.
// This is the redo-stack truncation tested in
// useDesignEditor.test.js at the hook level; here we confirm
// it works end-to-end through the real keyboard handler.
await addTextViaSidebar(page, { text: 'A' });
await addTextViaSidebar(page, { text: 'B' });
await pressUndo(page);
// Now redo would restore "B".
await expect(page.getByRole('button', { name: 'Redo' })).toBeEnabled();
// New branch: add a different element. This should truncate the
// redo stack — "B" is gone from history.
await addTextViaSidebar(page, { text: 'C' });
await expect(page.getByRole('button', { name: 'Redo' })).toBeDisabled();
// Undoing now should land on the "just A" state, NOT "A + B".
await pressUndo(page);
expect(await getElementsCount(page)).toBe(1);
await expect(page.locator('.layers-item-name', { hasText: 'A' })).toBeVisible();
// "B" should not be findable — it was truncated when we added C.
await expect(page.locator('.layers-item-name', { hasText: 'B' })).toHaveCount(0);
});

148
e2e/persistence.spec.js Normal file
View File

@@ -0,0 +1,148 @@
// E2E: state persistence across page reloads.
//
// What this guards
// ────────────────
// The persistence layer (src/utils/persistence.js + App.jsx's restore
// effect) is responsible for keeping the user's work alive across
// reloads. The hook-level tests cover the save/load round-trip; the
// utility tests cover the JSON shape. What's NOT covered there: the
// actual restore effect in App.jsx, the timing of replaceElements
// + initializeHistory, and the StrictMode dual-mount we fixed on
// May 23 (which only manifests in dev mode and was the motivation
// for the replaceElements ID-preservation work).
//
// This spec drives a real reload and asserts state survives. It also
// exercises the post-May-23 initializeHistory + replaceElements
// pipeline end-to-end: if a future regression brings back the
// blank-canvas-on-undo bug, the "undo doesn't blank the canvas after
// reload" test below will catch it.
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
addTextViaSidebar,
getElementsCount,
pressUndo,
} from './fixtures/editor.js';
test.beforeEach(async ({ page }) => {
await gotoFreshEditor(page);
});
test('elements survive a page reload', async ({ page }) => {
// The basic persistence promise: add elements, reload, find them
// still there.
await addTextViaSidebar(page, { text: 'Surviving text' });
expect(await getElementsCount(page)).toBe(1);
// Reload. Note we use page.reload() rather than gotoFreshEditor —
// we want to PRESERVE state across the reload, not wipe it. The
// localStorage save happens via the auto-save effect in App.jsx.
// That effect skips its first run; subsequent state changes
// trigger savePersistedState. By the time we reload, the addText
// change has been saved.
await page.reload();
// Wait for the editor to remount + restore. We can't use
// gotoFreshEditor's wait helpers directly; just wait for the
// canvas-area and the restored layer to appear.
await page.locator('.canvas-area').waitFor({ state: 'visible' });
await expect(page.locator('.layers-item-name', { hasText: 'Surviving text' })).toBeVisible();
expect(await getElementsCount(page)).toBe(1);
});
test('post-restore undo lands on the restored snapshot, not an empty canvas (May 22 regression)', async ({ page }) => {
// The headline May 22 bug: after restoration, the FIRST user
// action followed by Cmd+Z would walk past the restored snapshot
// and land on an empty canvas. Root cause was initializeHistory
// unconditionally wiping history to [empty], destroying the
// snapshot replaceElements had pushed during restoration.
//
// Post-fix sequence:
// 1. Add element (saved via auto-save).
// 2. Reload — restoration runs, replaceElements pushes the
// restored snapshot at idx 0, initializeHistory is a no-op.
// 3. Add another element — idx 1 (the just-added).
// 4. Cmd+Z — walks idx 1 → 0, restores the snapshot from
// step 2 (one element), NOT an empty canvas.
//
// Pre-fix, step 4 would land on the empty canvas because the
// floor at idx 0 was an empty array (wiped by initializeHistory).
await addTextViaSidebar(page, { text: 'Restored on reload' });
await page.reload();
await page.locator('.canvas-area').waitFor({ state: 'visible' });
await expect(page.locator('.layers-item-name', { hasText: 'Restored on reload' })).toBeVisible();
// Now add a second element. This is the "first user action after
// restoration" the bug was triggered by.
await addTextViaSidebar(page, { text: 'New action' });
expect(await getElementsCount(page)).toBe(2);
// Cmd+Z: must land on the post-restore state (one element), not
// empty. This is the assertion that would fail pre-fix.
await pressUndo(page);
expect(await getElementsCount(page)).toBe(1);
await expect(page.locator('.layers-item-name', { hasText: 'Restored on reload' })).toBeVisible();
await expect(page.locator('.layers-item-name', { hasText: 'New action' })).toHaveCount(0);
});
test('multiple elements all survive a reload', async ({ page }) => {
// Persistence isn't a single-element special case — the entire
// elements array gets saved. We add several with distinct text
// so we can verify each one survives by name.
await addTextViaSidebar(page, { text: 'Alpha' });
await addTextViaSidebar(page, { text: 'Bravo' });
await addTextViaSidebar(page, { text: 'Charlie' });
expect(await getElementsCount(page)).toBe(3);
await page.reload();
await page.locator('.canvas-area').waitFor({ state: 'visible' });
// All three should be in the layers panel.
expect(await getElementsCount(page)).toBe(3);
await expect(page.locator('.layers-item-name', { hasText: 'Alpha' })).toBeVisible();
await expect(page.locator('.layers-item-name', { hasText: 'Bravo' })).toBeVisible();
await expect(page.locator('.layers-item-name', { hasText: 'Charlie' })).toBeVisible();
});
test('shirt color preference survives a reload', async ({ page }) => {
// Non-elements persistence: shirt color, size, recent colors,
// etc. are also saved. Picking a non-default shirt color and
// verifying it survives confirms the preferences slice of the
// save shape works.
//
// Two UI quirks worth knowing for this test:
//
// 1. ShirtOptionsPanel is COLLAPSED by default — it shows only
// the summary chip + price until the user clicks to expand.
// The color radios live in the expanded content with
// `tabIndex={expanded ? 0 : -1}` so they're not even
// focusable when collapsed. We have to expand the panel
// before driving the radios. The collapse state is
// component-local and resets to collapsed on every fresh
// mount, so we have to re-expand after the reload too.
//
// 2. The color picker uses `role="radio"` with `aria-label`
// matching the bare color name (e.g. "Black") and uses
// `aria-checked` for the selected indicator. Earlier I
// guessed at `role="button"` + `aria-pressed`; both were
// wrong. The constants/shirt.js label is the source of
// truth for the accessible name.
const optionsToggle = page.getByRole('button', { name: /shirt options/i });
await optionsToggle.click();
const blackRadio = page.getByRole('radio', { name: 'Black' });
await blackRadio.click();
// Wait for the aria-checked update before reloading so we know
// the click registered and the save effect has had a chance to
// fire.
await expect(blackRadio).toHaveAttribute('aria-checked', 'true');
await page.reload();
await page.locator('.canvas-area').waitFor({ state: 'visible' });
// After reload the panel is collapsed again (state is
// component-local). Expand to inspect the restored selection.
await page.getByRole('button', { name: /shirt options/i }).click();
await expect(page.getByRole('radio', { name: 'Black' })).toHaveAttribute('aria-checked', 'true');
});

138
e2e/text-editing.spec.js Normal file
View File

@@ -0,0 +1,138 @@
// E2E: text element CRUD via the Text tab.
//
// What this guards
// ────────────────
// The most common editing flow in the app: open the Text tab, type a
// message, add it to the canvas, optionally edit or delete it.
// Vitest covers each piece (TextTab's local state, useDesignEditor's
// addElement, LayersPanel rendering) but not the whole pipeline as
// the user experiences it. This spec runs the actual integration.
//
// Why E2E rather than RTL component tests
// ───────────────────────────────────────
// TextTab is leaf-ish but composed with Sidebar, App's selectedElement
// logic, and useDesignEditor — testing it in RTL would require either
// mocking half the app (brittle) or rendering the real App with jsdom
// (which then breaks on Konva, because jsdom doesn't have a real
// canvas). Playwright drives the actual Chromium where everything
// works, including Konva.
//
// Selector strategy
// ─────────────────
// We use accessible queries throughout — getByRole, getByLabel,
// getByText. CSS class selectors would be tighter to the
// implementation and break on any styling refactor; the accessible
// roles describe what the user is looking at, which is stable across
// styling changes. The cost is occasional verbosity (`getByRole('tab',
// { name: 'Text' })` vs `.tab-text`) but the test stays valid as
// styles iterate.
import { test, expect } from '@playwright/test';
import {
gotoFreshEditor,
addTextViaSidebar,
getElementsCount,
} from './fixtures/editor.js';
test.beforeEach(async ({ page }) => {
// Every test starts on the editor with empty state. See
// gotoFreshEditor's docblock for the full reset sequence —
// localStorage + service workers + caches all wiped.
await gotoFreshEditor(page);
});
test('starts with zero elements', async ({ page }) => {
// Empty-state check. The LayersPanel renders a banner instead of
// a title when there are no elements, so getElementsCount returns
// 0 by checking for the title's absence.
expect(await getElementsCount(page)).toBe(0);
// The empty-state message is the canonical "no layers" indicator.
// We assert it's visible to confirm we're really on an empty
// editor — a partially-loaded editor without elements would also
// have a 0 count but no empty-state banner.
await expect(page.getByText('No elements yet')).toBeVisible();
});
test('adds a text element via the sidebar and shows it in the Layers panel', async ({ page }) => {
// The headline text-editing flow. addTextViaSidebar drives the
// Text tab → fill textarea → click Add → wait for the layer count
// to increment. After it returns, we make user-observable
// assertions about what's on the canvas.
await addTextViaSidebar(page, { text: 'Hello world' });
// Count is now 1.
expect(await getElementsCount(page)).toBe(1);
// The LayersPanel row for the new element. LayersPanel uses
// `getName(el)` which returns the text content for text elements,
// so "Hello world" should appear as the row label.
await expect(page.locator('.layers-item-name', { hasText: 'Hello world' })).toBeVisible();
});
test('adding two text elements lists both in the Layers panel, newest on top', async ({ page }) => {
// LayersPanel docblock: "Lists all elements on the canvas in
// render order (last in array = topmost)." The panel renders
// top-down with the most recently added on top, mirroring how
// users mentally stack layers in design tools.
await addTextViaSidebar(page, { text: 'First' });
await addTextViaSidebar(page, { text: 'Second' });
expect(await getElementsCount(page)).toBe(2);
// Order check: read all row names and verify "Second" precedes
// "First". allTextContents resolves to an array in DOM order;
// for a flex column that's top-to-bottom.
const names = await page.locator('.layers-item-name').allTextContents();
expect(names.indexOf('Second')).toBeLessThan(names.indexOf('First'));
});
test('editing the textarea after selecting a text element updates the layer name', async ({ page }) => {
// Bidirectional editing: selecting an existing text element on
// the canvas (or via the layers panel, which is what we do here
// since we don't want to deal with canvas pixel coordinates)
// should put the Text tab into edit mode against that element.
// Typing into the textarea then flows back to update the element's
// text on canvas AND in the layers panel.
await addTextViaSidebar(page, { text: 'Original' });
// Select the layer's row to make it the active element.
await page.locator('.layers-item-main', { hasText: 'Original' }).click();
// The Text tab auto-switches on selection (desktop auto-tab-switch
// effect in App.jsx). The textarea should now contain the
// selected element's text.
const textarea = page.getByLabel('Your message');
await expect(textarea).toHaveValue('Original');
// Edit it. fill() clears + types, mirroring a user selecting all
// and re-typing.
await textarea.fill('Updated');
// The layer name in the panel should reflect the new text. The
// update is debounced (300ms in useDesignEditor.updateElement),
// and the layer name reads from element.text directly, so it
// shows the new value as soon as state propagates.
await expect(page.locator('.layers-item-name', { hasText: 'Updated' })).toBeVisible();
// And the count is still 1 — editing doesn't add a new element.
expect(await getElementsCount(page)).toBe(1);
});
test('deleting a text element from the Layers panel removes it', async ({ page }) => {
await addTextViaSidebar(page, { text: 'Doomed' });
await addTextViaSidebar(page, { text: 'Survivor' });
expect(await getElementsCount(page)).toBe(2);
// Each row has a trash-can delete button as a sibling of the
// main row button. LayersPanel labels it
// `aria-label="Delete ${getName(element)}"`, so we can target
// the specific row's delete button by accessible name.
await page.getByRole('button', { name: 'Delete Doomed' }).click();
expect(await getElementsCount(page)).toBe(1);
// The remaining row is the one we didn't touch.
await expect(page.locator('.layers-item-name', { hasText: 'Survivor' })).toBeVisible();
// And the deleted row is gone.
await expect(page.locator('.layers-item-name', { hasText: 'Doomed' })).toHaveCount(0);
});