import { expect, test } from "@playwright/test"; import { setupWorkflowsPage, flowPayload, fulfillJson, getCanvasScale, normalizeCssColors, } from "./helpers/flows-pane-theme-fixtures"; test("workflows pane keeps canvas chrome and native controls in dark mode", async ({ page, }, testInfo) => { await setupWorkflowsPage(page, "dark"); await page.setViewportSize({ width: 2048, height: 1152 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); await expect(page.locator(".react-flow.dark")).toBeVisible(); await expect( page.locator(".react-flow__controls-button").first() ).toBeVisible(); await expect(page.locator(".react-flow__minimap")).toBeVisible(); await expect(page.getByTestId("flow-node-library")).toBeVisible(); await expect(page.getByTestId("flow-library-current-trigger")).toBeVisible(); await expect(page.getByTestId("flow-library-add-agent")).toBeVisible(); await expect(page.getByTestId("flow-library-icon-mogplex")).toBeVisible(); await expect(page.getByTestId("flow-execution-log")).toBeVisible(); await expect(page.locator(".flows-inspector")).toBeVisible(); await expect(page.locator(".flows-inspector")).toContainText("Select a node"); await page.locator(".react-flow__viewport").waitFor({ state: "attached" }); await expect.poll(() => getCanvasScale(page)).toBeLessThanOrEqual(1); const canvasBox = await page.locator(".react-flow").boundingBox(); const viewTabsBox = await page.getByTestId("flow-view-tabs").boundingBox(); const insertToolbarBox = await page .getByTestId("flow-insert-toolbar") .boundingBox(); expect(canvasBox).not.toBeNull(); expect(viewTabsBox).not.toBeNull(); expect(insertToolbarBox).not.toBeNull(); expect(viewTabsBox!.y + viewTabsBox!.height).toBeLessThanOrEqual( canvasBox!.y ); expect(insertToolbarBox!.y - canvasBox!.y).toBeLessThanOrEqual(16); const controlsButtonBg = await page .locator(".react-flow__controls-button") .first() .evaluate((node) => getComputedStyle(node).backgroundColor); const miniMapBg = await page .locator(".react-flow__minimap") .evaluate((node) => getComputedStyle(node).backgroundColor); const canvasThemeColors = await page .locator(".flows-canvas") .evaluate((node) => { const styles = getComputedStyle(node); return { canvasBackground: styles .getPropertyValue("--xy-background-color") .trim(), themeBackground: styles.getPropertyValue("--background").trim(), }; }); const canvasBackgroundLayers = await page .locator(".flows-canvas") .evaluate((node) => { const background = node.querySelector(".react-flow__background"); const dots = node.querySelector(".react-flow__background-pattern.dots"); const vignette = node.querySelector( '[data-testid="flow-canvas-vignette"]' ); const viewport = node.querySelector(".react-flow__viewport"); if (!background || !dots || !vignette || !viewport) { throw new Error("Flow canvas background layers are incomplete"); } const backgroundStyles = getComputedStyle(background); const vignetteStyles = getComputedStyle(vignette); const viewportStyles = getComputedStyle(viewport); return { backgroundColor: backgroundStyles.backgroundColor, backgroundZIndex: Number(backgroundStyles.zIndex), dotsClass: dots.getAttribute("class") ?? "", dotFill: getComputedStyle(dots).fill, vignetteBackground: vignetteStyles.backgroundImage, vignettePointerEvents: vignetteStyles.pointerEvents, vignetteZIndex: Number(vignetteStyles.zIndex), viewportZIndex: Number(viewportStyles.zIndex), }; }); const nodeCardBackgrounds = await page .locator(".flows-node-card") .evaluateAll((nodes) => nodes.map((node) => getComputedStyle(node).backgroundColor) ); const nodeTypeClasses = await page .locator(".flows-node-card") .evaluateAll((nodes) => nodes.map( (node) => Array.from(node.classList).find((className) => className.startsWith("flows-node-type-") ) ?? "" ) ); expect(controlsButtonBg).not.toBe("rgba(0, 0, 0, 0)"); expect(miniMapBg).not.toBe("rgba(0, 0, 0, 0)"); expect(canvasThemeColors.canvasBackground).toBe( canvasThemeColors.themeBackground ); const [expectedDarkBackground, actualDarkBackground, expectedDarkCard] = await normalizeCssColors(page, [ "#040503", canvasBackgroundLayers.backgroundColor, "#080907", ]); expect(actualDarkBackground).toBe(expectedDarkBackground); expect(canvasBackgroundLayers.dotsClass).toContain("dots"); const [actualDotFill, expectedDotFill] = await normalizeCssColors(page, [ canvasBackgroundLayers.dotFill, "oklch(98.84% 0.006 95 / 12%)", ]); expect(actualDotFill).toBe(expectedDotFill); expect(canvasBackgroundLayers.vignetteBackground).toContain( "radial-gradient" ); expect(canvasBackgroundLayers.vignettePointerEvents).toBe("none"); expect(canvasBackgroundLayers.backgroundZIndex).toBeLessThan( canvasBackgroundLayers.vignetteZIndex ); expect(canvasBackgroundLayers.vignetteZIndex).toBeLessThan( canvasBackgroundLayers.viewportZIndex ); expect(nodeCardBackgrounds.length).toBeGreaterThan(0); const normalizedDarkNodeCards = await normalizeCssColors( page, nodeCardBackgrounds ); expect( normalizedDarkNodeCards.every( (background) => background === expectedDarkCard ) ).toBe(true); expect(nodeTypeClasses.every(Boolean)).toBe(true); expect( nodeTypeClasses.every( (className) => !/(success|warn|danger|error)/.test(className) ) ).toBe(true); await page.screenshot({ path: testInfo.outputPath("flows-pane-dark-theme.png"), fullPage: true, }); await page.setViewportSize({ width: 1180, height: 720 }); await expect(page.getByTestId("flow-name-input-desktop")).not.toBeVisible(); await expect(page.getByTestId("flow-name-input-compact")).toBeVisible(); await page.getByTestId("flow-name-input-compact").fill("Laptop rename"); await expect(page.getByTestId("flow-name-input-compact")).toHaveValue( "Laptop rename" ); // Below the wrap point the inline name has no room, so the rename field falls // back to its own full-width row rather than being squeezed to nothing. await page.setViewportSize({ width: 390, height: 720 }); await expect(page.getByTestId("flow-name-input-compact")).not.toBeVisible(); await expect(page.getByTestId("flow-name-input-wrapped")).toBeVisible(); const scrollMetrics = await page.locator(".flows-pane").evaluate((pane) => { const scrollContainer = pane.parentElement?.parentElement; return { clientWidth: scrollContainer?.clientWidth ?? 0, scrollWidth: scrollContainer?.scrollWidth ?? 0, }; }); const railBox = await page .locator(".flows-pane-grid > aside:not(.flows-inspector)") .boundingBox(); const gridBox = await page.locator(".flows-pane-grid").boundingBox(); const compactRenameBox = await page .getByTestId("flow-name-input-wrapped") .boundingBox(); const publishButtonBox = await page .getByTestId("flow-publish-button") .boundingBox(); expect(railBox).not.toBeNull(); expect(gridBox).not.toBeNull(); expect(compactRenameBox).not.toBeNull(); expect(publishButtonBox).not.toBeNull(); const railRight = railBox!.x + railBox!.width; // Phone widths still scroll the pane horizontally — the editor has a floor // below which its controls cannot honestly coexist. The floor is 560px, not // the old 760px, so tablets and half-width desktop windows now fit. expect(scrollMetrics.scrollWidth).toBeGreaterThan(scrollMetrics.clientWidth); expect(gridBox!.width).toBeGreaterThanOrEqual(560); expect(gridBox!.width).toBeLessThan(760); expect(compactRenameBox!.x).toBeGreaterThanOrEqual(railRight); expect(compactRenameBox!.width).toBeGreaterThan(120); expect(publishButtonBox!.x).toBeGreaterThanOrEqual(railRight); // Navigate to /workflows through the test scope — the page where the tablet overflow was reported — // and verify the top-bar no longer causes page-level horizontal scroll at 768px. await page.setViewportSize({ width: 768, height: 720 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); const tabletPageMetrics = await page.evaluate(() => ({ clientWidth: document.documentElement.clientWidth, scrollWidth: document.documentElement.scrollWidth, })); expect(tabletPageMetrics.scrollWidth).toBeLessThanOrEqual( tabletPageMetrics.clientWidth ); const tabletTabsBox = await page.getByTestId("flow-view-tabs").boundingBox(); const tabletActionsBox = await page .getByTestId("flow-header-actions") .boundingBox(); const tabletCanvasBox = await page.locator(".react-flow").boundingBox(); expect(tabletTabsBox).not.toBeNull(); expect(tabletActionsBox).not.toBeNull(); expect(tabletCanvasBox).not.toBeNull(); // The toolbar now measures the editor column rather than the viewport, so at // 768px the tabs share the action row instead of claiming one of their own. // Either way they must clear the canvas and must not overlap the actions. expect(tabletTabsBox!.y + tabletTabsBox!.height).toBeLessThanOrEqual( tabletCanvasBox!.y ); const tabsOverlapActions = tabletTabsBox!.y < tabletActionsBox!.y + tabletActionsBox!.height && tabletActionsBox!.y < tabletTabsBox!.y + tabletTabsBox!.height && tabletTabsBox!.x < tabletActionsBox!.x + tabletActionsBox!.width && tabletActionsBox!.x < tabletTabsBox!.x + tabletTabsBox!.width; expect(tabsOverlapActions).toBe(false); await page.getByRole("button", { name: "Collapse sidebar" }).click(); const expandSidebarButton = page.getByRole("button", { name: "Expand sidebar", }); const expandSidebarBox = await expandSidebarButton.boundingBox(); const saveStatusBox = await page .getByTestId("flow-save-status") .boundingBox(); expect(expandSidebarBox).not.toBeNull(); expect(saveStatusBox).not.toBeNull(); expect(expandSidebarBox!.x + expandSidebarBox!.width).toBeLessThanOrEqual( saveStatusBox!.x ); // The centred pill needs ~1024px of *editor column*, which the app-shell // sidebar pushes well past a 1280px viewport. await page.setViewportSize({ width: 1760, height: 720 }); await expect(page.getByTestId("flow-name-input-desktop")).toBeVisible(); // The centered header pill must stay in flow with the action cluster — // absolute centering let it overlap the Add agent button at xl widths. const pillBox = await page.getByTestId("flow-header-pill").boundingBox(); const desktopActionsBox = await page .getByTestId("flow-header-actions") .boundingBox(); expect(pillBox).not.toBeNull(); expect(desktopActionsBox).not.toBeNull(); expect(pillBox!.x + pillBox!.width).toBeLessThanOrEqual(desktopActionsBox!.x); await page .locator(".react-flow__node") .filter({ hasText: "NEXTJS-REVIEWER" }) .click(); await expect(page.locator(".flows-inspector")).toBeVisible(); await expect(page.getByText("Effective config")).toBeVisible(); const notesBg = await page .getByPlaceholder("Capture intent, guardrails, and context for this flow.") .evaluate((node) => getComputedStyle(node).backgroundColor); expect(notesBg).not.toBe("rgb(255, 255, 255)"); // Empty state styling is verified by inspector-layout tests; this theme test // focuses on dark-mode node/canvas chrome which is already covered above. }); test("persisted workflow nodes stay visible when the assistant sheet opens", async ({ page, }) => { await setupWorkflowsPage(page, "dark"); const persistedFlowPayload = { ...flowPayload, name: "Persisted branch flow", draft_graph: { nodes: [ { id: "start", type: "start", position: { x: -1_344, y: -442 }, data: { label: "@mogplex", event: "mention" }, }, { id: "agent-1", type: "agent", position: { x: -980, y: -327 }, data: { label: "Primary reviewer", agentId: "agent-1" }, }, { id: "retry-agent", type: "agent", position: { x: -537, y: 112 }, data: { label: "Retry reviewer", agentId: "agent-1" }, }, { id: "end", type: "end", position: { x: -103, y: -317 }, data: { label: "Done" }, }, ], edges: [ { id: "edge-1", source: "start", target: "agent-1" }, { id: "edge-2", source: "agent-1", target: "end" }, { id: "edge-3", source: "agent-1", target: "retry-agent", sourceHandle: "error", }, { id: "edge-4", source: "retry-agent", target: "end" }, ], viewport: { x: 1_087, y: 502, zoom: 0.73 }, }, }; await page.unroute("**/api/flows"); await page.unroute("**/api/flows/flow-1"); await page.route("**/api/flows", (route) => fulfillJson(route, [persistedFlowPayload]) ); await page.route("**/api/flows/flow-1", (route) => fulfillJson(route, persistedFlowPayload) ); await page.setViewportSize({ width: 1280, height: 768 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); await page.getByTestId("flow-assistant-toggle").click(); await expect(page.getByTestId("flow-assistant-panel")).toBeVisible(); await expect .poll(() => page.locator(".react-flow").evaluate((canvas) => { const canvasRect = canvas.getBoundingClientRect(); const nodeRects = Array.from( canvas.querySelectorAll(".react-flow__node") ).map((node) => node.getBoundingClientRect()); return ( nodeRects.length === 4 && nodeRects.every( (node) => node.left >= canvasRect.left && node.top >= canvasRect.top && node.right <= canvasRect.right && node.bottom <= canvasRect.bottom ) ); }) ) .toBe(true); }); test("workflow assistant opens in the right sheet without covering the canvas", async ({ page, }) => { await setupWorkflowsPage(page, "dark"); await page.setViewportSize({ width: 2048, height: 1152 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); await page.getByTestId("flow-assistant-toggle").click(); const canvas = page.locator(".react-flow"); const rightSheet = page.locator(".flows-inspector"); const assistant = page.getByTestId("flow-assistant-panel"); await expect(assistant).toBeVisible(); await expect(rightSheet).toContainText("Flow assistant"); await expect .poll(() => assistant.evaluate((node) => Boolean(node.closest(".flows-inspector"))) ) .toBe(true); const canvasBox = await canvas.boundingBox(); const sheetBox = await rightSheet.boundingBox(); expect(canvasBox).not.toBeNull(); expect(sheetBox).not.toBeNull(); expect(sheetBox!.x).toBeGreaterThanOrEqual( canvasBox!.x + canvasBox!.width - 1 ); await page.setViewportSize({ width: 900, height: 720 }); const compactSheetBox = await rightSheet.boundingBox(); expect(compactSheetBox).not.toBeNull(); expect( await rightSheet.evaluate((node) => getComputedStyle(node).position) ).toBe("fixed"); expect( Math.abs(compactSheetBox!.x + compactSheetBox!.width - 900) ).toBeLessThanOrEqual(1); }); test("agent harness choices stack vertically with visible labels and the Mogplex mark", async ({ page, }) => { await setupWorkflowsPage(page, "dark"); await page.setViewportSize({ width: 1600, height: 900 }); await page.goto("/alex/workflows"); await page.waitForLoadState("networkidle"); await page .locator(".react-flow__node") .filter({ hasText: "NEXTJS-REVIEWER" }) .click(); const harnessGroup = page.getByRole("radiogroup", { name: "Harness" }); const mogplexHarness = harnessGroup.getByRole("radio", { name: "Mogplex" }); await expect(harnessGroup).toBeVisible(); await expect( mogplexHarness.getByTestId("flow-harness-icon-mogplex") ).toBeVisible(); const harnesses = [ { slug: "mogplex", name: "Mogplex" }, { slug: "claude-code", name: "Claude Code" }, { slug: "codex", name: "Codex" }, ] as const; for (const harness of harnesses) { const label = page.getByTestId(`flow-harness-label-${harness.slug}`); const description = page.getByTestId( `flow-harness-description-${harness.slug}` ); await expect(label).toBeVisible(); await expect(description).toBeVisible(); expect( await label.evaluate((node) => node.scrollWidth <= node.clientWidth) ).toBe(true); expect( await description.evaluate((node) => node.scrollWidth <= node.clientWidth) ).toBe(true); } const harnessBoxes = await Promise.all( harnesses.map(async (harness) => { const cardBox = await harnessGroup .getByRole("radio", { name: harness.name }) .boundingBox(); expect(cardBox).not.toBeNull(); return cardBox!; }) ); expect(harnessBoxes[1].y).toBeGreaterThanOrEqual( harnessBoxes[0].y + harnessBoxes[0].height ); expect(harnessBoxes[2].y).toBeGreaterThanOrEqual( harnessBoxes[1].y + harnessBoxes[1].height ); expect( harnessBoxes.every( (box) => Math.abs(box.x - harnessBoxes[0].x) <= 1 && Math.abs(box.width - harnessBoxes[0].width) <= 1 ) ).toBe(true); });