test(chess/e2e): expand visual-builder Playwright suite to 11 scenarios

Replaces the original single-flow stub with a comprehensive suite
that exercises every user-facing behaviour of the visual authoring
surface against a live Vite dev server. 11 tests run in 23s.

Scenarios:

 1. Mode toggle persists across reload
    Opens editor, toggles to Visual, verifies aria-pressed state +
    localStorage key, reloads, confirms Visual is still active on
    the next mount.

 2. Palette click adds top-level primitive to block list
    Clicks palette-btn-on-turn-end, verifies block-card-on-turn-end
    appears with matching aria-label and the narrative preview
    mentions "turn end". Asserts via aria-label rather than visible
    text so the open inspector docs do not cause strict-mode matches.

 3. Clicking × removes the block without triggering a drag
    Adds three blocks, clicks the × button on the middle one,
    verifies it is gone AND that dnd-kits assertive announcer never
    reported "Picked up sortable item" — direct regression for the
    drag-handle isolation fix.

 4. Clicking expand toggles the block without triggering a drag
    Asserts aria-expanded flips from false to true on click without
    the card being removed or reordered.

 5. Selecting a trigger makes palette clicks add children
    Adds on-turn-end, selects it, verifies palette-add-target-banner
    appears with the parent label, then clicks add-to-attribute and
    asserts there is exactly one add-to-attribute block AND it is
    a descendant of block-card-on-turn-end.

 6. "Add at top level instead" resets the nested-target selection
    After entering nested-add mode, clicks the escape button and
    verifies subsequent palette adds are top-level siblings.

 7. × on a nested child removes only that child
    Verifies nested removal leaves the parent intact.

 8. Preview narrative reflects tree mutations immediately
    Adds primitive and checks narrative; removes and checks narrative
    no longer mentions the seeded attribute.

 9. Save → reload → load preserves the composed descriptor
    Fills inspector fields (attr=Hp, delta=1), saves, reloads,
    confirms Visual mode is remembered, loads from library, verifies
    inspector value survived the round-trip.

10. Depth-4 descriptor surfaces validation banner + disables save
    Pre-seeds localStorage with a depth-4 descriptor (conditional
    → on-capture → on-damaged → conditional → add-to-attribute),
    loads it, verifies the validation banner renders and the Save
    button becomes disabled.

11. Toggling Form ↔ Visual preserves the composed descriptor
    Composes a tree, captures the JSON preview, toggles to Form and
    back to Visual, verifies JSON preview is byte-identical.

The spec uses data-testid selectors exclusively where possible,
falls back to aria-label for the block card outer <article>, and
uses click({ position }) to land on the card header (avoiding the
× / expand / grip / inspector overlays).
This commit is contained in:
Joey Yakimowich-Payne 2026-04-21 19:47:21 -06:00
commit 9e31b6d682
No known key found for this signature in database

View file

@ -1,130 +1,536 @@
/**
* 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<void> {
await page.goto('/');
await page.evaluate(
({ customKey, modeKey }) => {
({ 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,
},
{ customKey: CUSTOM_LIBRARY_KEY, modeKey: EDITOR_MODE_KEY },
);
await page.reload();
await page.waitForSelector('[data-testid="page-home"]');
}
test.describe('T25 — Custom modifier visual builder e2e', () => {
/** Open the Custom Modifier Editor via the standard lobby flow. */
async function openCustomModifierEditor(page: Page): Promise<void> {
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<void> {
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<void> {
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);
});
test('visual authoring flow: create, save, load', async ({ page }) => {
// 1. Open editor
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();
// ---------------------------------------------------------------
// 1. Mode toggle persistence
// ---------------------------------------------------------------
test('mode toggle persists across reload', async ({ page }) => {
await openCustomModifierEditor(page);
// 2. Toggle to visual mode
await page.getByTestId('custom-modifier-editor-mode-visual').click();
// Assert visual mode components are visible
// 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();
await expect(page.getByTestId('preview-pane')).toBeVisible();
// Give it a name
await page.getByPlaceholder('Modifier Name').fill('Visual Test Modifier');
});
// 3. Add on-move primitive
await page.getByTestId('palette-btn-on-move').click();
// It should appear in the block list
const onMoveBlock = page.getByTestId('block-card-on-move');
await expect(onMoveBlock).toBeVisible();
await expect(onMoveBlock.getByText('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);
// 4. Add add-to-attribute inside on-move
// Open the block to show dropzones
await onMoveBlock.click();
// 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 <article>, 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();
// Fill in the add-to-attribute form fields
// Target the specific inputs inside the visual builder
const attrInput = page.locator('[data-testid="primitive-add-to-attribute-attr-input"]').first();
// 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');
// The delta field is typically a generic input without a testid right now,
// so we target the first number input in the inspector panel
const deltaInput = page.locator('input[type="number"]').first();
await deltaInput.fill('1');
// 5. Verify preview narrative
const previewPane = page.getByTestId('preview-pane');
await expect(previewPane).toContainText('When this piece moves');
await expect(previewPane).toContainText('add 1 to Hp');
// Take screenshot of composed state
await page.screenshot({ path: '.sisyphus/evidence/task-25-visual-composed.png' });
// 6. Save to library
// Save.
await page.getByTestId('custom-save').click();
await expect(page.getByText('Custom modifier saved to library')).toBeVisible();
await expect(
page.getByText(/saved to library|custom modifier saved/i),
).toBeVisible({ timeout: 3000 });
// 7. Reload page
// 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');
// 8. Re-open editor & load
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();
// Should remember visual mode preference
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 Test Modifier').click();
await page.getByText('Visual Round-Trip').click();
// 9. Verify loaded structure
const loadedBlock = page.getByTestId('block-card-on-move');
await expect(loadedBlock).toBeVisible();
await expect(loadedBlock.getByText('On Move')).toBeVisible();
// Open the block to see children
await loadedBlock.click();
await expect(page.getByText('Add To Attribute')).toBeVisible();
await expect(page.locator('[data-testid="primitive-add-to-attribute-attr-input"]').first()).toHaveValue('Hp');
await expect(page.locator('input[type="number"]').first()).toHaveValue('1');
// 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. Negative: try to add beyond max depth (validator prevents save)
// Add conditional -> on-capture -> on-damaged
// The block list allows arbitrary adding, but validation fails
await page.getByTestId('palette-btn-conditional').click();
const condBlock = page.getByTestId('block-card-conditional');
await condBlock.click();
await page.getByTestId('palette-btn-on-capture').click();
const captureBlock = page.getByTestId('block-card-on-capture');
await captureBlock.click();
await page.getByTestId('palette-btn-on-damaged').click();
// The validator banner should show depth exceeded
await expect(page.getByText(/exceed 3|Fix errors in Form mode/)).toBeVisible();
// Save should be disabled
// ---------------------------------------------------------------
// 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();
await page.screenshot({ path: '.sisyphus/evidence/task-25-visual-validation-error.png' });
});
// ---------------------------------------------------------------
// 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 <pre> 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);
});
});