// E2E: text element CRUD via the Text tab. // // What this guards // ──────────────── // The most common editing flow in the app: open the Text tab, type a // message, add it to the canvas, optionally edit or delete it. // Vitest covers each piece (TextTab's local state, useDesignEditor's // addElement, LayersPanel rendering) but not the whole pipeline as // the user experiences it. This spec runs the actual integration. // // Why E2E rather than RTL component tests // ─────────────────────────────────────── // TextTab is leaf-ish but composed with Sidebar, App's selectedElement // logic, and useDesignEditor — testing it in RTL would require either // mocking half the app (brittle) or rendering the real App with jsdom // (which then breaks on Konva, because jsdom doesn't have a real // canvas). Playwright drives the actual Chromium where everything // works, including Konva. // // Selector strategy // ───────────────── // We use accessible queries throughout — getByRole, getByLabel, // getByText. CSS class selectors would be tighter to the // implementation and break on any styling refactor; the accessible // roles describe what the user is looking at, which is stable across // styling changes. The cost is occasional verbosity (`getByRole('tab', // { name: 'Text' })` vs `.tab-text`) but the test stays valid as // styles iterate. import { test, expect } from '@playwright/test'; import { gotoFreshEditor, addTextViaSidebar, getElementsCount, } from './fixtures/editor.js'; test.beforeEach(async ({ page }) => { // Every test starts on the editor with empty state. See // gotoFreshEditor's docblock for the full reset sequence — // localStorage + service workers + caches all wiped. await gotoFreshEditor(page); }); test('starts with zero elements', async ({ page }) => { // Empty-state check. The LayersPanel renders a banner instead of // a title when there are no elements, so getElementsCount returns // 0 by checking for the title's absence. expect(await getElementsCount(page)).toBe(0); // The empty-state message is the canonical "no layers" indicator. // We assert it's visible to confirm we're really on an empty // editor — a partially-loaded editor without elements would also // have a 0 count but no empty-state banner. await expect(page.getByText('No elements yet')).toBeVisible(); }); test('adds a text element via the sidebar and shows it in the Layers panel', async ({ page }) => { // The headline text-editing flow. addTextViaSidebar drives the // Text tab → fill textarea → click Add → wait for the layer count // to increment. After it returns, we make user-observable // assertions about what's on the canvas. await addTextViaSidebar(page, { text: 'Hello world' }); // Count is now 1. expect(await getElementsCount(page)).toBe(1); // The LayersPanel row for the new element. LayersPanel uses // `getName(el)` which returns the text content for text elements, // so "Hello world" should appear as the row label. await expect(page.locator('.layers-item-name', { hasText: 'Hello world' })).toBeVisible(); }); test('adding two text elements lists both in the Layers panel, newest on top', async ({ page }) => { // LayersPanel docblock: "Lists all elements on the canvas in // render order (last in array = topmost)." The panel renders // top-down with the most recently added on top, mirroring how // users mentally stack layers in design tools. await addTextViaSidebar(page, { text: 'First' }); await addTextViaSidebar(page, { text: 'Second' }); expect(await getElementsCount(page)).toBe(2); // Order check: read all row names and verify "Second" precedes // "First". allTextContents resolves to an array in DOM order; // for a flex column that's top-to-bottom. const names = await page.locator('.layers-item-name').allTextContents(); expect(names.indexOf('Second')).toBeLessThan(names.indexOf('First')); }); test('editing the textarea after selecting a text element updates the layer name', async ({ page }) => { // Bidirectional editing: selecting an existing text element on // the canvas (or via the layers panel, which is what we do here // since we don't want to deal with canvas pixel coordinates) // should put the Text tab into edit mode against that element. // Typing into the textarea then flows back to update the element's // text on canvas AND in the layers panel. await addTextViaSidebar(page, { text: 'Original' }); // Select the layer's row to make it the active element. await page.locator('.layers-item-main', { hasText: 'Original' }).click(); // The Text tab auto-switches on selection (desktop auto-tab-switch // effect in App.jsx). The textarea should now contain the // selected element's text. const textarea = page.getByLabel('Your message'); await expect(textarea).toHaveValue('Original'); // Edit it. fill() clears + types, mirroring a user selecting all // and re-typing. await textarea.fill('Updated'); // The layer name in the panel should reflect the new text. The // update is debounced (300ms in useDesignEditor.updateElement), // and the layer name reads from element.text directly, so it // shows the new value as soon as state propagates. await expect(page.locator('.layers-item-name', { hasText: 'Updated' })).toBeVisible(); // And the count is still 1 — editing doesn't add a new element. expect(await getElementsCount(page)).toBe(1); }); test('deleting a text element from the Layers panel removes it', async ({ page }) => { await addTextViaSidebar(page, { text: 'Doomed' }); await addTextViaSidebar(page, { text: 'Survivor' }); expect(await getElementsCount(page)).toBe(2); // Each row has a trash-can delete button as a sibling of the // main row button. LayersPanel labels it // `aria-label="Delete ${getName(element)}"`, so we can target // the specific row's delete button by accessible name. await page.getByRole('button', { name: 'Delete Doomed' }).click(); expect(await getElementsCount(page)).toBe(1); // The remaining row is the one we didn't touch. await expect(page.locator('.layers-item-name', { hasText: 'Survivor' })).toBeVisible(); // And the deleted row is gone. await expect(page.locator('.layers-item-name', { hasText: 'Doomed' })).toHaveCount(0); });