/** * T25 — End-to-end suite for the visual modifier builder. * * Exercises the full visual-mode authoring surface end-to-end against * a live Vite dev server. The unit tests under * `src/ui/visual-builder/**` cover props, rendering shapes, and pure * tree mutations in isolation; this spec covers integration: real * pointer events, real localStorage, real dnd-kit wiring, real * narrate → preview composition, real validator round-trips. * * Scenarios: * 1. Mode toggle persistence across reloads * 2. Palette click adds top-level primitive + appears in block list * 3. Drag-handle isolation — clicking × removes block, does NOT drag * 4. Drag-handle isolation — clicking expand toggles, does NOT drag * 5. Nested add — selecting a trigger targets new primitives as children * 6. Nested add — auto-expands parent + "Add at top level instead" resets * 7. Nested remove — × on a child primitive removes only that child * 8. Preview narrative updates live as the tree mutates * 9. Save → reload → load round-trip preserves the composed descriptor * 10. Validation banner appears on depth-4 composition + save stays disabled * 11. Form ↔ Visual toggle preserves descriptor content byte-equal */ import { test, expect, type Page } from '@playwright/test'; const CUSTOM_LIBRARY_KEY = 'houserules:custom-modifiers:v1'; const EDITOR_MODE_KEY = 'houserules:custom-modifier-editor-mode:v1'; const PROFILE_LIBRARY_KEY = 'houserules:modifier-profiles:v1'; /** Wipe both libraries + mode pref and return to a clean lobby. */ async function freshLobby(page: Page): Promise { await page.goto('/'); await page.evaluate( ({ customKey, modeKey, profileKey }) => { localStorage.removeItem(customKey); localStorage.removeItem(modeKey); localStorage.removeItem(profileKey); sessionStorage.clear(); }, { customKey: CUSTOM_LIBRARY_KEY, modeKey: EDITOR_MODE_KEY, profileKey: PROFILE_LIBRARY_KEY, }, ); await page.reload(); await page.waitForSelector('[data-testid="page-home"]'); } /** Open the Custom Modifier Editor via the standard lobby flow. */ async function openCustomModifierEditor(page: Page): Promise { await page.getByTestId('profile-picker').selectOption('custom'); await expect( page .getByTestId('per-type-panel-paste') .or(page.locator('[role="dialog"], .fixed.inset-0').first()), ).toBeVisible({ timeout: 3000 }); await page.getByTestId('open-custom-modifier-editor').click(); await expect(page.getByTestId('custom-modifier-editor')).toBeVisible({ timeout: 3000, }); } /** Switch the editor into Visual mode. */ async function switchToVisualMode(page: Page): Promise { await page.getByTestId('custom-modifier-editor-mode-visual').click(); // Palette is the clearest visual-mode marker. await expect(page.getByTestId('palette-btn-on-move')).toBeVisible(); await expect(page.getByTestId('preview-pane')).toBeVisible(); } /** * Select a block by its kind. Clicks into the card's header region * (top 40px) so the click lands on the card body, not on the expand / * × buttons that live in the same row, nor on the inspector docs that * render below when the card is already selected. */ async function selectBlockCard(page: Page, kind: string): Promise { const card = page.getByTestId(`block-card-${kind}`).first(); await expect(card).toBeVisible(); await card.click({ position: { x: 100, y: 12 } }); } test.describe('Visual modifier builder — T25 e2e', () => { test.beforeEach(async ({ page }) => { await freshLobby(page); }); // --------------------------------------------------------------- // 1. Mode toggle persistence // --------------------------------------------------------------- test('mode toggle persists across reload', async ({ page }) => { await openCustomModifierEditor(page); // Default mode is Form. await expect( page.getByTestId('custom-modifier-editor-mode-form'), ).toHaveAttribute('aria-pressed', 'true'); await expect( page.getByTestId('custom-modifier-editor-mode-visual'), ).toHaveAttribute('aria-pressed', 'false'); // Switch to Visual. await switchToVisualMode(page); await expect( page.getByTestId('custom-modifier-editor-mode-visual'), ).toHaveAttribute('aria-pressed', 'true'); // localStorage has the preference. const stored = await page.evaluate( (key) => localStorage.getItem(key), EDITOR_MODE_KEY, ); expect(stored).toBe('visual'); // Reload — mode should still be Visual on the way back in. await page.reload(); await page.waitForSelector('[data-testid="page-home"]'); await openCustomModifierEditor(page); await expect( page.getByTestId('custom-modifier-editor-mode-visual'), ).toHaveAttribute('aria-pressed', 'true'); // Palette rendered without an explicit click. await expect(page.getByTestId('palette-btn-on-move')).toBeVisible(); }); // --------------------------------------------------------------- // 2. Palette add (top-level) // --------------------------------------------------------------- test('palette click adds top-level primitive to block list', async ({ page, }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); // Click a palette button and verify a matching block appears. await page.getByTestId('palette-btn-on-turn-end').click(); const block = page.getByTestId('block-card-on-turn-end'); await expect(block).toBeVisible(); // Use the aria-label ("On Turn End block") which is unique to the // card's outer
, even when an inspector opens below. await expect(block).toHaveAttribute('aria-label', /On Turn End/); // Preview narrative reflects the new primitive. const narrative = page .getByTestId('preview-pane') .getByRole('region', { name: 'narrative' }); await expect(narrative).toContainText(/turn end/i); }); // --------------------------------------------------------------- // 3. Drag-handle isolation — × button // --------------------------------------------------------------- test('clicking × removes the block without triggering a drag', async ({ page, }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); // Add three blocks. await page.getByTestId('palette-btn-seed-attribute').click(); await page.getByTestId('palette-btn-add-to-attribute').click(); await page.getByTestId('palette-btn-multiply-attribute').click(); await expect(page.getByTestId('block-card-seed-attribute')).toBeVisible(); await expect(page.getByTestId('block-card-add-to-attribute')).toBeVisible(); await expect( page.getByTestId('block-card-multiply-attribute'), ).toBeVisible(); // Click the × on the middle (add-to-attribute) block — must remove // it WITHOUT starting a drag. If pointerdown on the × started a // drag, dnd-kit's announcer element would contain "Picked up // sortable item", and the block wouldn't be removed. const middleBlock = page.getByTestId('block-card-add-to-attribute'); await middleBlock .getByRole('button', { name: 'Remove primitive' }) .click(); await expect(page.getByTestId('block-card-add-to-attribute')).toHaveCount( 0, ); // The other two still exist. await expect(page.getByTestId('block-card-seed-attribute')).toBeVisible(); await expect( page.getByTestId('block-card-multiply-attribute'), ).toBeVisible(); // dnd-kit's visually-hidden announcer should not have reported a pickup. const announcement = await page .locator('[aria-live="assertive"]') .first() .textContent(); expect(announcement ?? '').not.toContain('Picked up sortable item'); }); // --------------------------------------------------------------- // 4. Drag-handle isolation — expand chevron // --------------------------------------------------------------- test('clicking expand toggles the block without triggering a drag', async ({ page, }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); await page.getByTestId('palette-btn-on-turn-end').click(); const block = page.getByTestId('block-card-on-turn-end'); await expect(block).toBeVisible(); // Click the expand chevron (aria-expanded toggles). const expandBtn = block.getByRole('button', { name: /expand|collapse/i }); await expect(expandBtn).toHaveAttribute('aria-expanded', 'false'); await expandBtn.click(); await expect(expandBtn).toHaveAttribute('aria-expanded', 'true'); // Block still exists — the click didn't trigger a reorder drag. await expect(block).toBeVisible(); }); // --------------------------------------------------------------- // 5. Nested add via selection // --------------------------------------------------------------- test('selecting a trigger makes palette clicks add children', async ({ page, }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); // Add a trigger container. await page.getByTestId('palette-btn-on-turn-end').click(); const onTurnEnd = page.getByTestId('block-card-on-turn-end'); await expect(onTurnEnd).toBeVisible(); // Select it by clicking the header area (avoids the × / expand / // inspector regions of the card). await selectBlockCard(page, 'on-turn-end'); // The palette-add-target banner should appear. await expect(page.getByTestId('palette-add-target-banner')).toBeVisible(); await expect(page.getByTestId('palette-add-target-banner')).toContainText( /on turn end/i, ); // Now click a state primitive — it should land INSIDE on-turn-end, // not as a top-level sibling. await page.getByTestId('palette-btn-add-to-attribute').click(); // The parent auto-expands so the child is visible. // Only ONE add-to-attribute block exists (the nested one). const addBlocks = page.getByTestId('block-card-add-to-attribute'); await expect(addBlocks).toHaveCount(1); // And it lives inside on-turn-end (descendant selector). await expect( onTurnEnd.getByTestId('block-card-add-to-attribute'), ).toBeVisible(); // Preview narrative should mention both the turn-end trigger AND // the nested add-to-attribute effect. const narrative = page .getByTestId('preview-pane') .getByRole('region', { name: 'narrative' }); await expect(narrative).toContainText(/turn end/i); await expect(narrative).toContainText(/add/i); }); // --------------------------------------------------------------- // 6. "Add at top level instead" resets selection // --------------------------------------------------------------- test('“Add at top level instead” resets the nested-target selection', async ({ page, }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); await page.getByTestId('palette-btn-on-turn-end').click(); await selectBlockCard(page, 'on-turn-end'); await expect(page.getByTestId('palette-add-target-banner')).toBeVisible(); // Click the "Add at top level instead" button. await page .getByTestId('palette-add-target-banner') .getByRole('button', { name: /top level/i }) .click(); await expect(page.getByTestId('palette-add-target-banner')).toHaveCount(0); // Now clicking a palette button adds at top level. await page.getByTestId('palette-btn-seed-attribute').click(); // Both blocks are top-level siblings now. const topLevelBlocks = page.locator('[data-testid^="block-card-"]'); await expect( topLevelBlocks.filter({ has: page.getByText('On Turn End') }), ).toHaveCount(1); await expect(page.getByTestId('block-card-seed-attribute')).toBeVisible(); }); // --------------------------------------------------------------- // 7. Nested remove // --------------------------------------------------------------- test('× on a nested child removes only that child', async ({ page }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); // Set up: on-turn-end containing add-to-attribute. await page.getByTestId('palette-btn-on-turn-end').click(); await selectBlockCard(page, 'on-turn-end'); const onTurnEnd = page.getByTestId('block-card-on-turn-end'); await page.getByTestId('palette-btn-add-to-attribute').click(); const nestedChild = onTurnEnd.getByTestId('block-card-add-to-attribute'); await expect(nestedChild).toBeVisible(); // Click × on the nested child. await nestedChild .getByRole('button', { name: 'Remove primitive' }) .click(); // Nested child gone. await expect( onTurnEnd.getByTestId('block-card-add-to-attribute'), ).toHaveCount(0); // Parent still present. await expect(onTurnEnd).toBeVisible(); }); // --------------------------------------------------------------- // 8. Preview narrative updates live // --------------------------------------------------------------- test('preview narrative reflects tree mutations immediately', async ({ page, }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); const narrative = page .getByTestId('preview-pane') .getByRole('region', { name: 'narrative' }); // Start: palette view, narrative may be empty OR show placeholder. // After adding seed-attribute it must mention the new primitive. await page.getByTestId('palette-btn-seed-attribute').click(); await expect(narrative).toContainText(/set|seed/i); // After removing it, narrative reverts. await page .getByTestId('block-card-seed-attribute') .getByRole('button', { name: 'Remove primitive' }) .click(); const afterRemove = (await narrative.textContent()) ?? ''; expect(afterRemove).not.toMatch(/ShieldCharges/i); }); // --------------------------------------------------------------- // 9. Save → reload → load round-trip // --------------------------------------------------------------- test('save → reload → load preserves the composed descriptor', async ({ page, }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); // Compose a simple top-level structure: add-to-attribute + aura. await page.getByPlaceholder('Modifier Name').fill('Visual Round-Trip'); await page.getByTestId('palette-btn-add-to-attribute').click(); await selectBlockCard(page, 'add-to-attribute'); // Inspector opens automatically on select — fill the fields. const attrInput = page .locator('[data-testid="primitive-add-to-attribute-attr-input"]') .first(); await expect(attrInput).toBeVisible({ timeout: 3000 }); await attrInput.fill('Hp'); const deltaInput = page.locator('input[type="number"]').first(); await deltaInput.fill('1'); // Save. await page.getByTestId('custom-save').click(); await expect( page.getByText(/saved to library|custom modifier saved/i), ).toBeVisible({ timeout: 3000 }); // Reload — mode preference persists, library persists. await page.reload(); await page.waitForSelector('[data-testid="page-home"]'); await openCustomModifierEditor(page); // Still in Visual mode. await expect( page.getByTestId('custom-modifier-editor-mode-visual'), ).toHaveAttribute('aria-pressed', 'true'); // Load from library. await page.getByTestId('custom-load').click(); await page.getByText('Visual Round-Trip').click(); // The composed primitive is back. await expect(page.getByTestId('block-card-add-to-attribute')).toBeVisible(); // Select it to reveal the inspector, then confirm persisted values. await selectBlockCard(page, 'add-to-attribute'); await expect( page.locator('[data-testid="primitive-add-to-attribute-attr-input"]').first(), ).toHaveValue('Hp'); }); // --------------------------------------------------------------- // 10. Depth-4 validation banner — pre-seed a too-deep descriptor // via localStorage then load it. Verifies the banner surfaces and // the Save button is disabled regardless of how the descriptor got // deep (manual click, paste, import, etc.). // --------------------------------------------------------------- test('depth-4 descriptor surfaces validation banner + disables save', async ({ page, }) => { // Seed the library with a depth-4 descriptor before opening the editor. await page.evaluate((key) => { const depth4: unknown = { type: 'data', id: 'custom-depth4', name: 'Depth 4 Test', description: '', version: 1, uiForm: 'primitive-composer', source: 'custom', targetAttrs: [], primitives: [ { kind: 'conditional', params: { condition: { type: 'always' }, then: [ { kind: 'on-capture', params: { primitives: [ { kind: 'on-damaged', params: { primitives: [ { kind: 'conditional', params: { condition: { type: 'always' }, then: [ { kind: 'add-to-attribute', params: { attr: 'Hp', delta: 1 } }, ], }, }, ], }, }, ], }, }, ], }, }, ], }; localStorage.setItem( key, JSON.stringify([ { id: 'custom-depth4', descriptor: depth4, starred: false, updatedAt: Date.now(), }, ]), ); }, CUSTOM_LIBRARY_KEY); await page.reload(); await page.waitForSelector('[data-testid="page-home"]'); await openCustomModifierEditor(page); await switchToVisualMode(page); // Load the pre-seeded depth-4 descriptor. await page.getByTestId('custom-load').click(); await page.getByText('Depth 4 Test').click(); // Validation banner appears. await expect( page.getByText(/exceed|depth|fix errors/i).first(), ).toBeVisible({ timeout: 5000 }); // Save is disabled — validator rejects depth-4 descriptors. await expect(page.getByTestId('custom-save')).toBeDisabled(); }); // --------------------------------------------------------------- // 11. Form ↔ Visual toggle preserves descriptor content // --------------------------------------------------------------- test('toggling Form ↔ Visual preserves the composed descriptor', async ({ page, }) => { await openCustomModifierEditor(page); await switchToVisualMode(page); // Compose a small non-trivial tree. await page.getByPlaceholder('Modifier Name').fill('Toggle Test'); await page.getByTestId('palette-btn-on-turn-end').click(); await selectBlockCard(page, 'on-turn-end'); await page.getByTestId('palette-btn-add-to-attribute').click(); // Capture the JSON preview (preview-json is the
 itself).
    await page
      .getByTestId('preview-pane')
      .getByRole('tab', { name: 'JSON' })
      .click();
    const jsonView = page.getByTestId('preview-json');
    await expect(jsonView).toBeVisible();
    const jsonBefore = await jsonView.textContent();
    expect(jsonBefore ?? '').toContain('on-turn-end');
    expect(jsonBefore ?? '').toContain('add-to-attribute');

    // Toggle to Form, then back to Visual.
    await page.getByTestId('custom-modifier-editor-mode-form').click();
    await expect(
      page.getByTestId('custom-modifier-editor-mode-form'),
    ).toHaveAttribute('aria-pressed', 'true');
    await page.getByTestId('custom-modifier-editor-mode-visual').click();
    await expect(page.getByTestId('palette-btn-on-move')).toBeVisible();

    // Preview JSON after the round-trip — byte-equal content.
    await page
      .getByTestId('preview-pane')
      .getByRole('tab', { name: 'JSON' })
      .click();
    await expect(jsonView).toBeVisible();
    const jsonAfter = await jsonView.textContent();

    expect(jsonAfter).toBe(jsonBefore);
  });
});