Major update for v1 and tests
This commit is contained in:
235
e2e/crop.spec.js
Normal file
235
e2e/crop.spec.js
Normal 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);
|
||||
});
|
||||
Reference in New Issue
Block a user