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