|
| 1 | +import { test, expect } from './fixtures/server' |
| 2 | + |
| 3 | +// Design wave 3 (goal 0001, audit §5): the palette's regrouping into 9 |
| 4 | +// frontend display groups (composition/paletteGroups.ts) instead of |
| 5 | +// the 6 domain Kinds, plus the new search box. Real Go bindings over |
| 6 | +// HTTP (Wails3 server mode) -- NodeTypes() is the real backend |
| 7 | +// registry, not a fixture list, so a future node type landing without |
| 8 | +// a matching NODE_TYPE_GROUP entry would show up here as a stray |
| 9 | +// fallback-mapped item, not silently pass. |
| 10 | + |
| 11 | +function activePanel(page: import('@playwright/test').Page) { |
| 12 | + return page.locator('[role="tabpanel"]:not([hidden])').last() |
| 13 | +} |
| 14 | + |
| 15 | +async function openPaletteOnNewWorkflow(page: import('@playwright/test').Page) { |
| 16 | + await page.goto('/') |
| 17 | + await page.getByRole('link', { name: 'Workflows' }).click() |
| 18 | + await page.getByTestId('new-workflow').click() |
| 19 | + await activePanel(page).getByTestId('toggle-palette').click() |
| 20 | +} |
| 21 | + |
| 22 | +// Native HTML5 drag events (dragstart/dragover/drop), not a raw mouse |
| 23 | +// down/move/up sequence -- CompositionCanvas's onCanvasDrop and |
| 24 | +// NodePalette's onPaletteDragStart are wired to the real HTML5 Drag |
| 25 | +// and Drop API, which mouse events alone don't trigger. Same pattern |
| 26 | +// e2e/ai-extract-fields-editor.spec.ts's own dragPaletteItemToCanvas |
| 27 | +// already established; duplicated here rather than shared since each |
| 28 | +// spec file already keeps its own small helper set. |
| 29 | +async function dragPaletteItemToCanvas(page: import('@playwright/test').Page, nodeTypeID: string) { |
| 30 | + await page.evaluate((id) => { |
| 31 | + const panel = document.querySelector('[role="tabpanel"]:not([hidden])') |
| 32 | + if (!panel) throw new Error('no active tabpanel') |
| 33 | + const palette = panel.querySelector(`[data-node-type-id="${id}"]`) |
| 34 | + const canvas = panel.querySelector('.react-flow__pane') |
| 35 | + if (!palette || !canvas) throw new Error(`drag setup failed: palette found=${!!palette} canvas found=${!!canvas}`) |
| 36 | + const dataTransfer = new DataTransfer() |
| 37 | + const rect = canvas.getBoundingClientRect() |
| 38 | + const clientX = rect.x + rect.width / 2 |
| 39 | + const clientY = rect.y + rect.height / 2 |
| 40 | + palette.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer })) |
| 41 | + canvas.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer, clientX, clientY })) |
| 42 | + canvas.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer, clientX, clientY })) |
| 43 | + }, nodeTypeID) |
| 44 | +} |
| 45 | + |
| 46 | +test('the palette renders 9 groups with the expected membership', async ({ page }) => { |
| 47 | + await openPaletteOnNewWorkflow(page) |
| 48 | + const panel = activePanel(page) |
| 49 | + |
| 50 | + const groups = panel.getByTestId('palette-group') |
| 51 | + await expect(groups).toHaveCount(9) |
| 52 | + |
| 53 | + const expectedGroupIDs = ['triggers', 'capture', 'transform', 'ai', 'data', 'actions', 'flow', 'guardrails', 'apply'] |
| 54 | + for (const id of expectedGroupIDs) { |
| 55 | + await expect(panel.locator(`[data-testid="palette-group"][data-group-id="${id}"]`)).toBeVisible() |
| 56 | + } |
| 57 | + |
| 58 | + // Spot-check membership by node-type-id (stable, not display text -- |
| 59 | + // see paletteGroups.ts's NODE_TYPE_GROUP for the full map). |
| 60 | + const membership: Record<string, string[]> = { |
| 61 | + ai: ['process-ai-classify', 'process-ai-completion', 'process-ai-extract-structured'], |
| 62 | + data: ['list-lookup', 'list-search'], |
| 63 | + guardrails: ['human-review', 'ruleset', 'decision-outcome'], |
| 64 | + flow: ['child-workflow', 'decision-route'], |
| 65 | + } |
| 66 | + for (const [groupID, nodeTypeIDs] of Object.entries(membership)) { |
| 67 | + const group = panel.locator(`[data-testid="palette-group"][data-group-id="${groupID}"]`) |
| 68 | + for (const ntID of nodeTypeIDs) { |
| 69 | + await expect(group.locator(`[data-node-type-id="${ntID}"]`)).toBeVisible() |
| 70 | + } |
| 71 | + } |
| 72 | +}) |
| 73 | + |
| 74 | +test('palette labels are shortened under their group (no repeated prefix)', async ({ page }) => { |
| 75 | + await openPaletteOnNewWorkflow(page) |
| 76 | + const panel = activePanel(page) |
| 77 | + |
| 78 | + // "AI: Classify" -> "Classify" under the AI group; the palette item's |
| 79 | + // own text is the short form, never the full "<Group>: " prefix. |
| 80 | + const classifyItem = panel.locator('[data-node-type-id="process-ai-classify"]') |
| 81 | + await expect(classifyItem).toHaveText('Classify') |
| 82 | + |
| 83 | + const httpItem = panel.locator('[data-node-type-id="integration-http"]') |
| 84 | + await expect(httpItem).toHaveText('HTTP call') |
| 85 | + |
| 86 | + // Already-clean labels (no colon prefix) pass through unchanged. |
| 87 | + const childItem = panel.locator('[data-node-type-id="child-workflow"]') |
| 88 | + await expect(childItem).toHaveText('Run another workflow') |
| 89 | +}) |
| 90 | + |
| 91 | +test('canvas node cards keep their full, self-contained label after being dropped from the palette', async ({ page }) => { |
| 92 | + await openPaletteOnNewWorkflow(page) |
| 93 | + await dragPaletteItemToCanvas(page, 'process-ai-classify') |
| 94 | + |
| 95 | + // A card on canvas has no surrounding group context, so it keeps the |
| 96 | + // full "AI: Classify" label -- only the palette display shortens. |
| 97 | + await expect(activePanel(page).locator('.react-flow__node').filter({ hasText: 'AI: Classify' })).toBeVisible() |
| 98 | +}) |
| 99 | + |
| 100 | +test('palette search matches both the shortened display name and the full underlying label', async ({ page }) => { |
| 101 | + await openPaletteOnNewWorkflow(page) |
| 102 | + const panel = activePanel(page) |
| 103 | + const search = panel.getByTestId('palette-search') |
| 104 | + |
| 105 | + // "classify" matches the SHORT displayed text ("Classify"). |
| 106 | + await search.fill('classify') |
| 107 | + await expect(panel.locator('[data-node-type-id="process-ai-classify"]')).toBeVisible() |
| 108 | + await expect(panel.getByTestId('palette-item')).toHaveCount(1) |
| 109 | + |
| 110 | + // "AI:" only appears in the FULL underlying label ("AI: Classify"), |
| 111 | + // never in the shortened display text ("Classify") -- still matches. |
| 112 | + await search.fill('AI:') |
| 113 | + await expect(panel.locator('[data-node-type-id="process-ai-classify"]')).toBeVisible() |
| 114 | + await expect(panel.locator('[data-node-type-id="process-ai-completion"]')).toBeVisible() |
| 115 | + await expect(panel.locator('[data-node-type-id="process-ai-extract-structured"]')).toBeVisible() |
| 116 | + |
| 117 | + // A query matching nothing shows the empty state, not a silently |
| 118 | + // blank tree. |
| 119 | + await search.fill('zzz-no-such-step') |
| 120 | + await expect(panel.getByTestId('palette-item')).toHaveCount(0) |
| 121 | + await expect(panel.getByTestId('palette-no-matches')).toBeVisible() |
| 122 | + |
| 123 | + // Clearing the query restores the full 29-item palette. |
| 124 | + await search.fill('') |
| 125 | + await expect(panel.getByTestId('palette-item')).toHaveCount(29) |
| 126 | +}) |
0 commit comments