|
| 1 | +import type { Page } from '@playwright/test' |
| 2 | +import { test, expect } from './fixtures/server' |
| 3 | +import { connectMCPClient } from './mcpTestClient' |
| 4 | +import { clickRowAction } from './inventoryRow' |
| 5 | + |
| 6 | +// Goal 0017's flagship scenario, direct from the audit's own root |
| 7 | +// cause: before this goal, ONLY mcpsvc emitted mill-data-changed -- |
| 8 | +// ConfigureService/CompositionService/GuardrailService emitted |
| 9 | +// NOTHING, so a direct-UI or MCP-authored mutation never reached an |
| 10 | +// already-open OTHER surface (only the exact tab that made the change |
| 11 | +// ever refreshed itself, via its own local refetch call). This spec |
| 12 | +// proves the fix at the two surfaces the audit named as the P0s: |
| 13 | +// Configure's inventories (list/mcpserver were actively MISROUTED to |
| 14 | +// refreshRequests()+refreshWorkflows(), App.tsx:242-244 before the |
| 15 | +// fix) and a canvas entity picker seeing a workflow created elsewhere. |
| 16 | + |
| 17 | +// Normalizes to write-gate ON + per-write approval OFF (unattended) -- |
| 18 | +// same local helper canvas-live-sync.spec.ts already uses (kept local |
| 19 | +// there too, not promoted to mcpTestClient.ts, since that module's own |
| 20 | +// enableMCPWritesWithApprovalRequired deliberately leaves approval ON |
| 21 | +// for specs that want to exercise the approval banner instead). |
| 22 | +async function enableUnattendedMCPWrites(page: Page): Promise<void> { |
| 23 | + await page.goto('/') |
| 24 | + await page.getByRole('button', { name: 'Settings' }).click() |
| 25 | + const writeCheckbox = page.getByTestId('mcp-write-enabled-checkbox') |
| 26 | + await expect(writeCheckbox).toBeEnabled() |
| 27 | + if (!(await writeCheckbox.isChecked())) { |
| 28 | + await writeCheckbox.click() |
| 29 | + await expect(writeCheckbox).toBeChecked() |
| 30 | + } |
| 31 | + const approvalCheckbox = page.getByTestId('mcp-write-approval-checkbox') |
| 32 | + await expect(approvalCheckbox).toBeEnabled() |
| 33 | + if (await approvalCheckbox.isChecked()) { |
| 34 | + await approvalCheckbox.click() |
| 35 | + await expect(approvalCheckbox).not.toBeChecked() |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +async function restoreMCPWriteDefaults(page: Page): Promise<void> { |
| 40 | + await page.goto('/') |
| 41 | + await page.getByRole('button', { name: 'Settings' }).click() |
| 42 | + const approvalCheckbox = page.getByTestId('mcp-write-approval-checkbox') |
| 43 | + if (await approvalCheckbox.count() && !(await approvalCheckbox.isChecked())) { |
| 44 | + await approvalCheckbox.click() |
| 45 | + await expect(approvalCheckbox).toBeChecked() |
| 46 | + } |
| 47 | + const writeCheckbox = page.getByTestId('mcp-write-enabled-checkbox') |
| 48 | + if (await writeCheckbox.isChecked()) { |
| 49 | + await writeCheckbox.click() |
| 50 | + await expect(writeCheckbox).not.toBeChecked() |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +test('Configure > Lists open: an MCP-authored import_list appears live, no reload (P0-2/P1-1)', async ({ page }, testInfo) => { |
| 55 | + await enableUnattendedMCPWrites(page) |
| 56 | + |
| 57 | + await page.getByRole('link', { name: 'Configure' }).click() |
| 58 | + await page.getByRole('tab', { name: 'Lists' }).click() |
| 59 | + await expect(page.getByTestId('configure-lists')).toBeVisible() |
| 60 | + |
| 61 | + const label = 'E2E cross-surface list' |
| 62 | + const row = page.locator('[data-testid="inventory-row"][data-entity="list"]', { has: page.getByText(label, { exact: true }) }) |
| 63 | + await expect(row).toHaveCount(0) |
| 64 | + |
| 65 | + const client = await connectMCPClient(testInfo.parallelIndex) |
| 66 | + try { |
| 67 | + const result = await client.callTool({ |
| 68 | + name: 'import_list', |
| 69 | + arguments: { json: JSON.stringify({ label, description: '', columns: [{ Key: 'k', Label: 'K', Type: 'text' }] }) }, |
| 70 | + }) |
| 71 | + if (result.isError) throw new Error(`import_list failed: ${JSON.stringify(result.content)}`) |
| 72 | + |
| 73 | + // No page.reload() -- ConfigureLists.tsx now reads the shared |
| 74 | + // configureEntityStore (shared/configureEntityStore.ts), which |
| 75 | + // App.tsx's mill-data-changed{entity:"list"} handler refreshes. |
| 76 | + // Before the fix, 'list' routed to refreshRequests()+ |
| 77 | + // refreshWorkflows() -- neither of which touches this page's own |
| 78 | + // (then-local) state at all, so this row would never have appeared |
| 79 | + // without navigating away and back. |
| 80 | + await expect(row).toBeVisible({ timeout: 10_000 }) |
| 81 | + } finally { |
| 82 | + await client.close() |
| 83 | + } |
| 84 | + |
| 85 | + await clickRowAction(page, row, 'Delete') |
| 86 | + await restoreMCPWriteDefaults(page) |
| 87 | +}) |
| 88 | + |
| 89 | +test('a direct-UI workflow create in one window reaches a canvas picker open in another (P0-1)', async ({ page }) => { |
| 90 | + // Two real pages against the SAME worker server -- the actual "two |
| 91 | + // open surfaces" the goal names, not one page simulating it. Neither |
| 92 | + // one drives the other; both independently subscribe to the same |
| 93 | + // backend's mill-data-changed broadcast. |
| 94 | + const page2 = await page.context().newPage() |
| 95 | + try { |
| 96 | + await page.goto('/') |
| 97 | + await page.getByRole('link', { name: 'Configure' }).click() |
| 98 | + await page.getByRole('tab', { name: 'Attributes' }).click() |
| 99 | + const select = page.getByTestId('attributes-workflow-select') |
| 100 | + await expect(select).toBeVisible() |
| 101 | + |
| 102 | + const label = 'E2E cross-surface workflow' |
| 103 | + await expect(select.locator('option', { hasText: label })).toHaveCount(0) |
| 104 | + |
| 105 | + // A genuinely direct-UI create (the Workflows page's own "New |
| 106 | + // workflow" button + Save, no MCP involved at all) on the SECOND |
| 107 | + // page -- proves CompositionService.CreateWorkflow's own new |
| 108 | + // dataevent.Emit call (compositionservice.go), not mcpsvc's. |
| 109 | + await page2.goto('/') |
| 110 | + await page2.getByRole('link', { name: 'Workflows' }).click() |
| 111 | + await page2.getByTestId('new-workflow').click() |
| 112 | + await page2.locator('[role="tabpanel"]:not([hidden])').last().getByLabel('Label').fill(label) |
| 113 | + await page2.locator('[role="tabpanel"]:not([hidden])').last().getByTestId('save-workflow').click() |
| 114 | + |
| 115 | + // No page.reload() on page (the first page/window) -- ConfigureAttributes.tsx |
| 116 | + // now reads shared/store.ts's workflows store, refreshed by |
| 117 | + // App.tsx's mill-data-changed{entity:"workflow"} handler. |
| 118 | + await expect(select.locator('option', { hasText: label })).toHaveCount(1, { timeout: 10_000 }) |
| 119 | + |
| 120 | + await page2.getByRole('link', { name: 'Workflows' }).click() |
| 121 | + const row = page2.locator('[data-testid="inventory-row"][data-entity="workflow"]', { has: page2.getByText(label, { exact: true }) }) |
| 122 | + await clickRowAction(page2, row, 'Delete') |
| 123 | + } finally { |
| 124 | + await page2.close() |
| 125 | + } |
| 126 | +}) |
0 commit comments