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

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