190 lines
8.3 KiB
JavaScript
190 lines
8.3 KiB
JavaScript
// E2E: undo/redo flow.
|
|
//
|
|
// What this guards
|
|
// ────────────────
|
|
// The history subsystem is the most bug-bitten area of the codebase
|
|
// (see the May 22 / May 23 Refinements notes). useDesignEditor.test.js
|
|
// covers the hook-level behaviour exhaustively, but the keyboard
|
|
// shortcuts (Cmd+Z / Cmd+Shift+Z) are attached to the window via a
|
|
// global useEffect in App.jsx — that wiring isn't exercised by Vitest.
|
|
// This spec drives the actual keystrokes and verifies the right
|
|
// things happen.
|
|
//
|
|
// Two scenarios in particular are worth catching at this layer:
|
|
//
|
|
// 1. The keyboard handler's input/textarea exclusion. The handler
|
|
// bails out early when the focused element is an <input> or
|
|
// <textarea>, so the user can type a `z` into the text field
|
|
// without accidentally undoing. Vitest can't test this because
|
|
// it doesn't render real form elements with focus state.
|
|
//
|
|
// 2. The HistoryControls buttons disabling/enabling correctly. The
|
|
// pills have `disabled` attributes wired to canUndo/canRedo,
|
|
// and the visual state of those buttons is what tells the
|
|
// user "you can / can't go back further." A bug where the
|
|
// buttons stay enabled past the history floor would only be
|
|
// visible at the DOM-attribute level.
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
import {
|
|
gotoFreshEditor,
|
|
addTextViaSidebar,
|
|
getElementsCount,
|
|
pressUndo,
|
|
pressRedo,
|
|
} from './fixtures/editor.js';
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await gotoFreshEditor(page);
|
|
});
|
|
|
|
test('Cmd/Ctrl+Z removes the most recently added element', async ({ page }) => {
|
|
// The headline undo test. Add an element, undo, verify it's gone.
|
|
// This single keystroke covers the whole pipeline: the keyboard
|
|
// handler in App.jsx fires undo(), useDesignEditor walks the
|
|
// history pointer back, setElements applies the previous snapshot,
|
|
// LayersPanel re-renders.
|
|
await addTextViaSidebar(page, { text: 'Will be undone' });
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
|
|
await pressUndo(page);
|
|
// The deleted element is gone; count drops to 0.
|
|
expect(await getElementsCount(page)).toBe(0);
|
|
});
|
|
|
|
test('Cmd/Ctrl+Shift+Z restores an undone element', async ({ page }) => {
|
|
// Redo after undo. The post-redo state must match the original
|
|
// (same text on the same element) — a regression where redo
|
|
// restored a different snapshot than what was undone would fail
|
|
// the layer-name assertion.
|
|
await addTextViaSidebar(page, { text: 'Resurrected' });
|
|
await pressUndo(page);
|
|
expect(await getElementsCount(page)).toBe(0);
|
|
|
|
await pressRedo(page);
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Resurrected' })).toBeVisible();
|
|
});
|
|
|
|
test('typing `z` in the text field does NOT undo (keyboard handler input gate)', async ({ page }) => {
|
|
// The keyboard handler in App.jsx has an explicit early-return:
|
|
// if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
|
|
// Without this, typing a `z` into the textarea while holding a
|
|
// modifier would hijack the keystroke. (Just typing `z` without
|
|
// a modifier doesn't trigger the undo branch anyway, but Cmd+Z
|
|
// is the danger — except even Cmd+Z is suppressed because the
|
|
// user's cursor is in a field where Cmd+Z is the OS-native
|
|
// "undo last typed character" shortcut, which the browser
|
|
// handles.)
|
|
//
|
|
// We test the simpler case here: pressing Cmd+Z while focused
|
|
// on the textarea should NOT remove the element. The OS-native
|
|
// textarea undo may or may not fire — we don't care; we care
|
|
// that OUR keyboard handler doesn't fire and remove an element
|
|
// from the canvas.
|
|
await addTextViaSidebar(page, { text: 'Safe' });
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
|
|
// Focus the textarea by clicking it explicitly. addTextViaSidebar
|
|
// leaves focus elsewhere by the time it returns; we have to
|
|
// re-focus deterministically.
|
|
const textarea = page.getByLabel('Your message');
|
|
await textarea.click();
|
|
await expect(textarea).toBeFocused();
|
|
|
|
// Now press Cmd+Z. The app's handler should bail out at the
|
|
// tagName check. The element on the canvas stays.
|
|
await pressUndo(page);
|
|
|
|
// Verification: element count is still 1.
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
});
|
|
|
|
test('Undo and Redo buttons in the bottom bar reflect history state', async ({ page }) => {
|
|
// HistoryControls renders two buttons with aria-label "Undo" /
|
|
// "Redo" and a `disabled` attribute wired to canUndo / canRedo.
|
|
// The button states are how users (and screen readers) see
|
|
// whether stepping further is available.
|
|
//
|
|
// History initialization model (confirmed by running the suite):
|
|
// a fresh editor's history is seeded with the empty snapshot at
|
|
// idx=0. canUndo = `idx > 0`, so a fresh editor has canUndo false.
|
|
// The FIRST add pushes a new snapshot and advances idx to 1 — at
|
|
// which point canUndo flips to true because the user can step
|
|
// back to the empty floor. This contradicted my initial mental
|
|
// model ("first add gives idx=0") but matches what the app
|
|
// actually does; the May 22 cleanup arc relies on that empty
|
|
// floor being there as a restoration target.
|
|
const undoButton = page.getByRole('button', { name: 'Undo' });
|
|
const redoButton = page.getByRole('button', { name: 'Redo' });
|
|
|
|
// Fresh editor: history=[empty], idx=0.
|
|
// canUndo = 0 > 0 = false (we're at the floor)
|
|
// canRedo = 0 < length-1=0 = false (nothing to redo)
|
|
await expect(undoButton).toBeDisabled();
|
|
await expect(redoButton).toBeDisabled();
|
|
|
|
// After one add: history=[empty, [A]], idx=1.
|
|
// canUndo = 1 > 0 = true (can step back to empty)
|
|
// canRedo = 1 < length-1=1 = false (we're at the head)
|
|
await addTextViaSidebar(page, { text: 'First' });
|
|
await expect(undoButton).toBeEnabled();
|
|
await expect(redoButton).toBeDisabled();
|
|
|
|
// After two adds: history=[empty, [A], [A,B]], idx=2.
|
|
// canUndo = 2 > 0 = true
|
|
// canRedo = 2 < length-1=2 = false
|
|
await addTextViaSidebar(page, { text: 'Second' });
|
|
await expect(undoButton).toBeEnabled();
|
|
await expect(redoButton).toBeDisabled();
|
|
|
|
// Click Undo: idx 2 → 1. Now we're mid-history with both
|
|
// directions available.
|
|
// canUndo = 1 > 0 = true
|
|
// canRedo = 1 < length-1=2 = true (can re-apply the second add)
|
|
await undoButton.click();
|
|
await expect(undoButton).toBeEnabled();
|
|
await expect(redoButton).toBeEnabled();
|
|
|
|
// Click Redo: idx 1 → 2. Back to post-two-adds state.
|
|
await redoButton.click();
|
|
await expect(undoButton).toBeEnabled();
|
|
await expect(redoButton).toBeDisabled();
|
|
|
|
// Finally, undo twice to reach the floor and confirm undo
|
|
// disables at idx=0. This is the assertion the first-add test
|
|
// ABOVE used to (incorrectly) make against a one-add history;
|
|
// the only way to actually get there is to wind all the way
|
|
// back to the seeded empty floor.
|
|
await undoButton.click(); // idx 2 → 1
|
|
await undoButton.click(); // idx 1 → 0 (the floor)
|
|
await expect(undoButton).toBeDisabled();
|
|
await expect(redoButton).toBeEnabled();
|
|
});
|
|
|
|
test('a new action after undo discards the redo stack', async ({ page }) => {
|
|
// Standard editor behaviour: after undo, redo is available. But
|
|
// a new edit branches history forward from the current idx,
|
|
// discarding anything that was previously to the right of it.
|
|
// This is the redo-stack truncation tested in
|
|
// useDesignEditor.test.js at the hook level; here we confirm
|
|
// it works end-to-end through the real keyboard handler.
|
|
await addTextViaSidebar(page, { text: 'A' });
|
|
await addTextViaSidebar(page, { text: 'B' });
|
|
await pressUndo(page);
|
|
// Now redo would restore "B".
|
|
await expect(page.getByRole('button', { name: 'Redo' })).toBeEnabled();
|
|
|
|
// New branch: add a different element. This should truncate the
|
|
// redo stack — "B" is gone from history.
|
|
await addTextViaSidebar(page, { text: 'C' });
|
|
await expect(page.getByRole('button', { name: 'Redo' })).toBeDisabled();
|
|
|
|
// Undoing now should land on the "just A" state, NOT "A + B".
|
|
await pressUndo(page);
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
await expect(page.locator('.layers-item-name', { hasText: 'A' })).toBeVisible();
|
|
// "B" should not be findable — it was truncated when we added C.
|
|
await expect(page.locator('.layers-item-name', { hasText: 'B' })).toHaveCount(0);
|
|
});
|