tests/e2e/flows-pane-theme-dark.spec.ts
Rama main · réplica de lectura del repositorio original.
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);
});