// 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); });