|
| 1 | +import { test, expect } from './fixtures/server' |
| 2 | +import { clickRowAction } from './inventoryRow' |
| 3 | + |
| 4 | +// docs/goals/0011-lists-maturation.md: exercises the typed List |
| 5 | +// Configure UI (column schema editor + schema-generated row editor, |
| 6 | +// ConfigureLists.tsx) and the list-search node's own Inspector |
| 7 | +// (ListSearchParamsEditor.tsx, a column picker + literal-or-attribute |
| 8 | +// value + exact/fuzzy match type) built live through the canvas -- |
| 9 | +// not just against the hardcoded seed (seed-completeness.spec.ts |
| 10 | +// already covers running the seed). Deletes the workflow it creates, |
| 11 | +// same shared-settings-file discipline every other spec here follows; |
| 12 | +// reuses the seeded "Example: Country codes" List rather than |
| 13 | +// creating a second one, so there's nothing List-shaped to clean up. |
| 14 | + |
| 15 | +function workflowRow(page: import('@playwright/test').Page, label: string) { |
| 16 | + return page.locator('[data-testid="inventory-row"][data-entity="workflow"]', { has: page.getByText(label, { exact: true }) }) |
| 17 | +} |
| 18 | + |
| 19 | +function activePanel(page: import('@playwright/test').Page) { |
| 20 | + return page.locator('[role="tabpanel"]:not([hidden])').last() |
| 21 | +} |
| 22 | + |
| 23 | +async function dragPaletteItemToCanvas(page: import('@playwright/test').Page, nodeTypeID: string) { |
| 24 | + await page.evaluate((id) => { |
| 25 | + const panel = document.querySelector('[role="tabpanel"]:not([hidden])') |
| 26 | + if (!panel) throw new Error('no active tabpanel') |
| 27 | + const palette = panel.querySelector(`[data-node-type-id="${id}"]`) |
| 28 | + const canvas = panel.querySelector('.react-flow__pane') |
| 29 | + if (!palette || !canvas) throw new Error(`drag setup failed: palette found=${!!palette} canvas found=${!!canvas}`) |
| 30 | + const dataTransfer = new DataTransfer() |
| 31 | + const rect = canvas.getBoundingClientRect() |
| 32 | + const clientX = rect.x + rect.width / 2 |
| 33 | + const clientY = rect.y + rect.height / 2 |
| 34 | + palette.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer })) |
| 35 | + canvas.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer, clientX, clientY })) |
| 36 | + canvas.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer, clientX, clientY })) |
| 37 | + }, nodeTypeID) |
| 38 | +} |
| 39 | + |
| 40 | +async function connectNodes(page: import('@playwright/test').Page, sourceLabel: string, targetLabel: string) { |
| 41 | + const panel = activePanel(page) |
| 42 | + await panel.getByRole('button', { name: 'Fit View' }).click() |
| 43 | + await page.waitForTimeout(300) |
| 44 | + const sourceHandle = panel.locator('.react-flow__node').filter({ hasText: sourceLabel }).locator('.react-flow__handle.source') |
| 45 | + const targetHandle = panel.locator('.react-flow__node').filter({ hasText: targetLabel }).locator('.react-flow__handle.target') |
| 46 | + const sourceBox = await sourceHandle.boundingBox() |
| 47 | + const targetBox = await targetHandle.boundingBox() |
| 48 | + if (!sourceBox || !targetBox) throw new Error('connectNodes: handle bounding box not found') |
| 49 | + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) |
| 50 | + await page.mouse.down() |
| 51 | + await page.mouse.move(targetBox.x + targetBox.width / 2, targetBox.y + targetBox.height / 2, { steps: 10 }) |
| 52 | + await page.mouse.up() |
| 53 | +} |
| 54 | + |
| 55 | +async function clickCanvasNode(page: import('@playwright/test').Page, panel: import('@playwright/test').Locator, label: string) { |
| 56 | + const node = panel.locator('.react-flow__node').filter({ hasText: label }) |
| 57 | + const box = await node.boundingBox() |
| 58 | + if (!box) throw new Error(`clickCanvasNode: node "${label}" has no bounding box`) |
| 59 | + const candidates = [ |
| 60 | + { x: box.x + 10, y: box.y + 10 }, |
| 61 | + { x: box.x + box.width - 10, y: box.y + 10 }, |
| 62 | + { x: box.x + box.width / 2, y: box.y + box.height / 2 }, |
| 63 | + { x: box.x + 10, y: box.y + box.height - 10 }, |
| 64 | + ] |
| 65 | + for (const point of candidates) { |
| 66 | + const insideNode = await page.evaluate(({ x, y }) => { |
| 67 | + const el = document.elementFromPoint(x, y) |
| 68 | + return !!el?.closest('.react-flow__node') |
| 69 | + }, point) |
| 70 | + if (insideNode) { |
| 71 | + await page.mouse.click(point.x, point.y) |
| 72 | + return |
| 73 | + } |
| 74 | + } |
| 75 | + throw new Error(`clickCanvasNode: no point for node "${label}" resolved inside its own card`) |
| 76 | +} |
| 77 | + |
| 78 | +test('Configuring a typed List: add a column, add a row, both persist', async ({ page }) => { |
| 79 | + await page.goto('/') |
| 80 | + await page.getByRole('link', { name: 'Configure' }).click() |
| 81 | + await page.getByRole('tab', { name: 'Lists' }).click() |
| 82 | + |
| 83 | + await page.getByTestId('new-list').click() |
| 84 | + await page.getByLabel('Label').fill('E2E typed list UI') |
| 85 | + await page.getByTestId('list-column-key').fill('sku') |
| 86 | + await page.getByRole('button', { name: 'Save list' }).click() |
| 87 | + |
| 88 | + await expect(page.getByTestId('list-rows-editor')).toBeVisible() |
| 89 | + await page.getByTestId('add-list-row').click() |
| 90 | + const row = page.getByTestId('list-row') |
| 91 | + await expect(row).toBeVisible() |
| 92 | + await row.getByRole('textbox').fill('SKU-1') |
| 93 | + await row.getByTestId('save-list-row').click() |
| 94 | + |
| 95 | + await page.getByRole('button', { name: 'Close' }).click() |
| 96 | + const listRow = page.locator('[data-testid="inventory-row"][data-entity="list"]', { has: page.getByText('E2E typed list UI', { exact: true }) }) |
| 97 | + await expect(listRow).toBeVisible() |
| 98 | + await expect(listRow).toContainText('1 columns, 1 rows') |
| 99 | + |
| 100 | + // Clean up. |
| 101 | + await clickRowAction(page, listRow, 'Delete') |
| 102 | + await expect(listRow).toHaveCount(0) |
| 103 | +}) |
| 104 | + |
| 105 | +test('list-search node: configuring a real match parameter through the Inspector, then running it end to end', async ({ page }) => { |
| 106 | + await page.goto('/') |
| 107 | + await page.getByRole('link', { name: 'Workflows' }).click() |
| 108 | + await page.getByTestId('new-workflow').click() |
| 109 | + const panel = activePanel(page) |
| 110 | + await panel.getByLabel('Label').fill('E2E list-search config test') |
| 111 | + |
| 112 | + // Deliberately no apply-clipboard-write-text terminal node -- this |
| 113 | + // test proves the list-search Inspector's own authoring/execution, |
| 114 | + // not clipboard I/O, and a clipboard apply step has no clipboard on |
| 115 | + // a headless Linux CI runner (docs/SPEC.md §1.3; the same fix |
| 116 | + // applied to the seeded "Example: Country lookup (search)" workflow |
| 117 | + // after a real CI failure, builtinworkflows_list.go). Ending at |
| 118 | + // list-search itself is an accepted, warn-only Process leaf |
| 119 | + // (ADR-0028). |
| 120 | + await panel.getByTestId('toggle-palette').click() |
| 121 | + await dragPaletteItemToCanvas(page, 'list-search') |
| 122 | + await expect(panel.locator('.react-flow__node')).toHaveCount(2) |
| 123 | + await connectNodes(page, 'Trigger: manual', 'List: search') |
| 124 | + |
| 125 | + await clickCanvasNode(page, panel, 'List: search') |
| 126 | + const inspector = panel.getByTestId('composition-inspector') |
| 127 | + |
| 128 | + // Pick the seeded typed List via the live entity picker (ADR-0009). |
| 129 | + await inspector.getByTestId('entity-ref-field').selectOption({ label: 'Example: Country codes' }) |
| 130 | + |
| 131 | + const editor = inspector.getByTestId('list-search-params-editor') |
| 132 | + await expect(editor).toBeVisible() |
| 133 | + await editor.getByTestId('add-list-search-param').click() |
| 134 | + // The column Select now offers the real List's own columns (code, name). |
| 135 | + await editor.getByTestId('list-search-param-column').selectOption({ label: 'Code' }) |
| 136 | + await editor.getByLabel('Value literal value').fill('US') |
| 137 | + |
| 138 | + // outputAttribute is a plain generic ConfigField (not owned by the |
| 139 | + // bespoke editor) -- filled via its own normal labeled text input. |
| 140 | + await inspector.getByLabel('Output attribute').fill('searchResult') |
| 141 | + |
| 142 | + await panel.getByTestId('save-workflow').click() |
| 143 | + |
| 144 | + const row = workflowRow(page, 'E2E list-search config test') |
| 145 | + await expect(row).toBeVisible() |
| 146 | + await row.click() |
| 147 | + |
| 148 | + // Manual trigger, no declared Attributes -- Run fires immediately, |
| 149 | + // no test-input dialog (docs/adr/0008 only opens one when Attributes |
| 150 | + // are declared). |
| 151 | + await activePanel(page).getByTestId('canvas-run').click() |
| 152 | + const bar = activePanel(page).getByTestId('current-step-bar') |
| 153 | + await expect(bar).toContainText('SUCCESS', { timeout: 15_000 }) |
| 154 | + |
| 155 | + // Clean up. |
| 156 | + await page.getByRole('link', { name: 'Workflows' }).click() |
| 157 | + const wfRow = workflowRow(page, 'E2E list-search config test') |
| 158 | + await clickRowAction(page, wfRow, 'Delete') |
| 159 | + await expect(wfRow).toHaveCount(0) |
| 160 | +}) |
0 commit comments