236 lines
11 KiB
JavaScript
236 lines
11 KiB
JavaScript
// 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);
|
||
});
|