Files
apparel-designer/e2e/crop.spec.js
2026-05-23 03:28:58 -05:00

236 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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