149 lines
6.8 KiB
JavaScript
149 lines
6.8 KiB
JavaScript
// E2E: state persistence across page reloads.
|
|
//
|
|
// What this guards
|
|
// ────────────────
|
|
// The persistence layer (src/utils/persistence.js + App.jsx's restore
|
|
// effect) is responsible for keeping the user's work alive across
|
|
// reloads. The hook-level tests cover the save/load round-trip; the
|
|
// utility tests cover the JSON shape. What's NOT covered there: the
|
|
// actual restore effect in App.jsx, the timing of replaceElements
|
|
// + initializeHistory, and the StrictMode dual-mount we fixed on
|
|
// May 23 (which only manifests in dev mode and was the motivation
|
|
// for the replaceElements ID-preservation work).
|
|
//
|
|
// This spec drives a real reload and asserts state survives. It also
|
|
// exercises the post-May-23 initializeHistory + replaceElements
|
|
// pipeline end-to-end: if a future regression brings back the
|
|
// blank-canvas-on-undo bug, the "undo doesn't blank the canvas after
|
|
// reload" test below will catch it.
|
|
|
|
import { test, expect } from '@playwright/test';
|
|
import {
|
|
gotoFreshEditor,
|
|
addTextViaSidebar,
|
|
getElementsCount,
|
|
pressUndo,
|
|
} from './fixtures/editor.js';
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
await gotoFreshEditor(page);
|
|
});
|
|
|
|
test('elements survive a page reload', async ({ page }) => {
|
|
// The basic persistence promise: add elements, reload, find them
|
|
// still there.
|
|
await addTextViaSidebar(page, { text: 'Surviving text' });
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
|
|
// Reload. Note we use page.reload() rather than gotoFreshEditor —
|
|
// we want to PRESERVE state across the reload, not wipe it. The
|
|
// localStorage save happens via the auto-save effect in App.jsx.
|
|
// That effect skips its first run; subsequent state changes
|
|
// trigger savePersistedState. By the time we reload, the addText
|
|
// change has been saved.
|
|
await page.reload();
|
|
|
|
// Wait for the editor to remount + restore. We can't use
|
|
// gotoFreshEditor's wait helpers directly; just wait for the
|
|
// canvas-area and the restored layer to appear.
|
|
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Surviving text' })).toBeVisible();
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
});
|
|
|
|
test('post-restore undo lands on the restored snapshot, not an empty canvas (May 22 regression)', async ({ page }) => {
|
|
// The headline May 22 bug: after restoration, the FIRST user
|
|
// action followed by Cmd+Z would walk past the restored snapshot
|
|
// and land on an empty canvas. Root cause was initializeHistory
|
|
// unconditionally wiping history to [empty], destroying the
|
|
// snapshot replaceElements had pushed during restoration.
|
|
//
|
|
// Post-fix sequence:
|
|
// 1. Add element (saved via auto-save).
|
|
// 2. Reload — restoration runs, replaceElements pushes the
|
|
// restored snapshot at idx 0, initializeHistory is a no-op.
|
|
// 3. Add another element — idx 1 (the just-added).
|
|
// 4. Cmd+Z — walks idx 1 → 0, restores the snapshot from
|
|
// step 2 (one element), NOT an empty canvas.
|
|
//
|
|
// Pre-fix, step 4 would land on the empty canvas because the
|
|
// floor at idx 0 was an empty array (wiped by initializeHistory).
|
|
await addTextViaSidebar(page, { text: 'Restored on reload' });
|
|
await page.reload();
|
|
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Restored on reload' })).toBeVisible();
|
|
|
|
// Now add a second element. This is the "first user action after
|
|
// restoration" the bug was triggered by.
|
|
await addTextViaSidebar(page, { text: 'New action' });
|
|
expect(await getElementsCount(page)).toBe(2);
|
|
|
|
// Cmd+Z: must land on the post-restore state (one element), not
|
|
// empty. This is the assertion that would fail pre-fix.
|
|
await pressUndo(page);
|
|
expect(await getElementsCount(page)).toBe(1);
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Restored on reload' })).toBeVisible();
|
|
await expect(page.locator('.layers-item-name', { hasText: 'New action' })).toHaveCount(0);
|
|
});
|
|
|
|
test('multiple elements all survive a reload', async ({ page }) => {
|
|
// Persistence isn't a single-element special case — the entire
|
|
// elements array gets saved. We add several with distinct text
|
|
// so we can verify each one survives by name.
|
|
await addTextViaSidebar(page, { text: 'Alpha' });
|
|
await addTextViaSidebar(page, { text: 'Bravo' });
|
|
await addTextViaSidebar(page, { text: 'Charlie' });
|
|
expect(await getElementsCount(page)).toBe(3);
|
|
|
|
await page.reload();
|
|
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
|
|
|
// All three should be in the layers panel.
|
|
expect(await getElementsCount(page)).toBe(3);
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Alpha' })).toBeVisible();
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Bravo' })).toBeVisible();
|
|
await expect(page.locator('.layers-item-name', { hasText: 'Charlie' })).toBeVisible();
|
|
});
|
|
|
|
test('shirt color preference survives a reload', async ({ page }) => {
|
|
// Non-elements persistence: shirt color, size, recent colors,
|
|
// etc. are also saved. Picking a non-default shirt color and
|
|
// verifying it survives confirms the preferences slice of the
|
|
// save shape works.
|
|
//
|
|
// Two UI quirks worth knowing for this test:
|
|
//
|
|
// 1. ShirtOptionsPanel is COLLAPSED by default — it shows only
|
|
// the summary chip + price until the user clicks to expand.
|
|
// The color radios live in the expanded content with
|
|
// `tabIndex={expanded ? 0 : -1}` so they're not even
|
|
// focusable when collapsed. We have to expand the panel
|
|
// before driving the radios. The collapse state is
|
|
// component-local and resets to collapsed on every fresh
|
|
// mount, so we have to re-expand after the reload too.
|
|
//
|
|
// 2. The color picker uses `role="radio"` with `aria-label`
|
|
// matching the bare color name (e.g. "Black") and uses
|
|
// `aria-checked` for the selected indicator. Earlier I
|
|
// guessed at `role="button"` + `aria-pressed`; both were
|
|
// wrong. The constants/shirt.js label is the source of
|
|
// truth for the accessible name.
|
|
const optionsToggle = page.getByRole('button', { name: /shirt options/i });
|
|
await optionsToggle.click();
|
|
|
|
const blackRadio = page.getByRole('radio', { name: 'Black' });
|
|
await blackRadio.click();
|
|
// Wait for the aria-checked update before reloading so we know
|
|
// the click registered and the save effect has had a chance to
|
|
// fire.
|
|
await expect(blackRadio).toHaveAttribute('aria-checked', 'true');
|
|
|
|
await page.reload();
|
|
await page.locator('.canvas-area').waitFor({ state: 'visible' });
|
|
|
|
// After reload the panel is collapsed again (state is
|
|
// component-local). Expand to inspect the restored selection.
|
|
await page.getByRole('button', { name: /shirt options/i }).click();
|
|
await expect(page.getByRole('radio', { name: 'Black' })).toHaveAttribute('aria-checked', 'true');
|
|
});
|