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