tests/e2e/flows-pane-context-menu.spec.ts
Rama main · réplica de lectura del repositorio original.
import { expect, test } from "@playwright/test";
import {
E2E_SCOPE_USER,
enableScopedE2EAuth,
scopedPath,
} from "./helpers/auth";
import { linkedVercelCapability } from "./helpers/activation-fixtures";
import { selectAppOption } from "./helpers/app-select";
import type { Locator, Page, Route } from "@playwright/test";
const connectedUser = {
id: E2E_SCOPE_USER.id,
email: "alex@example.com",
username: "alex",
name: "Alex",
avatar_url: "https://example.com/avatar.png",
github_connected: true,
github_app_connected: true,
github_app_available: true,
github_connection_mode: "app" as const,
vercel: linkedVercelCapability,
};
async function fulfillJson(route: Route, data: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(data),
});
}
async function dispatchContextMenu(
target: Locator,
position: { xRatio?: number; yRatio?: number } = {}
) {
await expect(target).toBeVisible();
const bounds = await target.boundingBox();
if (!bounds) throw new Error("Context-menu target has no bounding box");
const xRatio = position.xRatio ?? 0.5;
const yRatio = position.yRatio ?? 0.5;
const clientX =
bounds.x + Math.max(1, Math.min(bounds.width - 1, bounds.width * xRatio));
const clientY =
bounds.y + Math.max(1, Math.min(bounds.height - 1, bounds.height * yRatio));
await target.dispatchEvent("contextmenu", {
clientX,
clientY,
button: 2,
bubbles: true,
cancelable: true,
});
}
async function stubFlowsPage(page: Page) {
await enableScopedE2EAuth(page);
await page.addInitScript(() => {
window.localStorage.setItem("theme", "dark");
});
await page.route("**/api/auth/user", (route) =>
fulfillJson(route, { user: connectedUser })
);
await page.route("**/api/settings", (route) =>
fulfillJson(route, { default_model: "minimax/minimax-m2.5", theme: "dark" })
);
await page.route("**/api/models", (route) =>
fulfillJson(route, {
models: [
{
id: "minimax/minimax-m2.5",
provider: "minimax",
name: "MiniMax M2.5",
context_length: 200000,
capabilities: ["text"],
is_available: true,
},
{
id: "openai/gpt-5.4",
provider: "openai",
name: "GPT-5.4",
context_length: 200000,
capabilities: ["text"],
is_available: true,
},
],
catalog: [
{
id: "minimax/minimax-m2.5",
provider: "minimax",
name: "MiniMax M2.5",
context_length: 200000,
capabilities: ["text"],
is_available: true,
is_enabled: true,
},
{
id: "openai/gpt-5.4",
provider: "openai",
name: "GPT-5.4",
context_length: 200000,
capabilities: ["text"],
is_available: true,
is_enabled: true,
},
],
})
);
await page.route("**/api/agents", (route) =>
fulfillJson(route, [
{
id: "agent-a",
name: "Reviewer A",
slug: "reviewer-a",
model: "minimax/minimax-m2.5",
},
{
id: "agent-b",
name: "Reviewer B",
slug: "reviewer-b",
model: "minimax/minimax-m2.5",
},
])
);
await page.route("**/api/github/installations", (route) =>
fulfillJson(route, [
{
id: "inst-1",
installation_id: 101,
account_login: "webrenew",
repositories: [{ id: "repo-1", full_name: "webrenew/blackbox" }],
},
])
);
let currentFlow = {
id: "flow-1",
installation_id: 101,
name: "Review Flow",
description: "Flow description",
notes: "Flow notes",
source_kind: "github",
status: "active",
last_run_status: "success",
published_version_id: "version-1",
published_version: {
id: "version-1",
flow_id: "flow-1",
version_number: 1,
graph: {},
created_at: "2026-03-28T17:00:00.000Z",
},
draft_graph: {
nodes: [
{
id: "start",
type: "start",
position: { x: 120, y: 160 },
data: { label: "PR opened", event: "pr_opened" },
},
{
id: "agent-a",
type: "agent",
position: { x: 380, y: 160 },
data: { label: "Reviewer A", agentId: "agent-a" },
},
{
id: "agent-b",
type: "agent",
position: { x: 640, y: 160 },
data: { label: "Reviewer B", agentId: "agent-b" },
},
{
id: "end",
type: "end",
position: { x: 900, y: 160 },
data: { label: "Done" },
},
],
edges: [
{ id: "edge-1", source: "start", target: "agent-a" },
{ id: "edge-2", source: "agent-a", target: "agent-b" },
{ id: "edge-3", source: "agent-b", target: "end" },
],
viewport: { x: 0, y: 0, zoom: 1 },
},
};
currentFlow.published_version.graph = structuredClone(
currentFlow.draft_graph
);
await page.route("**/api/flows", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, [currentFlow]);
return;
}
const payload = route.request().postDataJSON() as typeof currentFlow;
currentFlow = {
...currentFlow,
...payload,
draft_graph: payload.draft_graph,
};
await fulfillJson(route, currentFlow, 201);
});
await page.route("**/api/flows/flow-1", async (route) => {
if (route.request().method() === "GET") {
await fulfillJson(route, currentFlow);
return;
}
const payload = route.request().postDataJSON() as Partial<
typeof currentFlow
> & { draft_graph?: typeof currentFlow.draft_graph };
currentFlow = {
...currentFlow,
...payload,
draft_graph: payload.draft_graph ?? currentFlow.draft_graph,
};
await fulfillJson(route, currentFlow);
});
}
test("canvas context menu adds and configures workflow nodes, then closes on escape", async ({
page,
}) => {
// Width must be >= 1520 so the flows container (minus ~240px sidebar) exceeds
// the 1280px dock-mode threshold.
await page.setViewportSize({ width: 1600, height: 900 });
await stubFlowsPage(page);
await page.goto(scopedPath("workflows"));
await page.waitForLoadState("networkidle");
const pane = page.locator(".react-flow__pane");
await dispatchContextMenu(pane, { xRatio: 0.35, yRatio: 0.25 });
const menu = page.getByTestId("flow-context-menu");
await expect(menu).toBeVisible();
await page.getByTestId("flow-context-add-agent").click();
await expect(page.locator(".react-flow__node")).toHaveCount(5);
await expect(menu).toBeHidden();
// Click on canvas to deselect before opening next context menu
await pane.click({ position: { x: 50, y: 50 } });
await dispatchContextMenu(pane, { xRatio: 0.55, yRatio: 0.3 });
await expect(menu).toBeVisible();
await page.getByTestId("flow-context-add-condition").click();
await expect(page.locator(".react-flow__node")).toHaveCount(6);
// Click on canvas to deselect before opening next context menu
await pane.click({ position: { x: 50, y: 50 } });
await dispatchContextMenu(pane, { xRatio: 0.7, yRatio: 0.35 });
await expect(menu).toBeVisible();
await page.getByTestId("flow-context-add-transform").click();
await expect(page.locator(".react-flow__node")).toHaveCount(7);
await expect(
page.getByText("Transform operator", { exact: true })
).toBeVisible();
await page.getByTestId("flow-transform-key-0").fill("tests_changed");
await selectAppOption(
page.getByTestId("flow-transform-operation-0"),
"files_match_glob"
);
await page.getByTestId("flow-transform-argument-0").fill("**/*.test.ts");
await expect(page.getByTestId("flow-transform-source-0")).toHaveValue(
"metadata.changed_files"
);
await dispatchContextMenu(pane, { xRatio: 0.75, yRatio: 0.4 });
await expect(menu).toBeVisible();
await page.keyboard.press("Escape");
await expect(menu).toBeHidden();
});
test("agent node context menu duplicates and deletes nodes", async ({
page,
}) => {
await stubFlowsPage(page);
await page.goto(scopedPath("workflows"));
await page.waitForLoadState("networkidle");
const agentNode = page
.locator(".react-flow__node")
.filter({ hasText: "Reviewer B" })
.first();
await dispatchContextMenu(agentNode);
await expect(page.getByTestId("flow-context-node-duplicate")).toBeVisible();
await page.getByTestId("flow-context-node-duplicate").click();
await expect(page.locator(".react-flow__node")).toHaveCount(5);
// Wait for the duplicated node to be selected and stable
const selectedNode = page.locator(".react-flow__node.selected").first();
await expect(selectedNode).toBeVisible();
await dispatchContextMenu(selectedNode);
await expect(page.getByTestId("flow-context-node-delete")).toBeVisible();
await page.getByTestId("flow-context-node-delete").click();
await expect(page.locator(".react-flow__node")).toHaveCount(4);
});
test("an open node context menu owns destructive canvas shortcuts", async ({
page,
}) => {
await stubFlowsPage(page);
await page.goto(scopedPath("workflows"));
await page.waitForLoadState("networkidle");
const agentNode = page
.locator(".react-flow__node")
.filter({ hasText: "Reviewer B" })
.first();
await dispatchContextMenu(agentNode);
const menu = page.getByTestId("flow-context-menu");
await expect(menu).toBeVisible();
await expect(agentNode).toHaveClass(/selected/);
// Backspace closes the menu but blocks the delete action (menu "owns" the shortcut)
await page.keyboard.press("Backspace");
await expect(menu).toBeHidden();
// Node must NOT be deleted - the context menu blocked the destructive action
await expect(
page.locator('.react-flow__node[data-id="agent-b"]')
).toHaveCount(1);
await expect(page.locator(".react-flow__node")).toHaveCount(4);
});
test("edge insertion menu can insert an agent into an existing path", async ({
page,
}) => {
await stubFlowsPage(page);
await page.goto(scopedPath("workflows"));
await page.waitForLoadState("networkidle");
const insertButton = page.getByTestId("flow-edge-insert-edge-2");
await expect(insertButton).toBeVisible();
await insertButton.click();
const menu = page.getByTestId("flow-context-menu");
await expect(menu).toBeVisible();
await menu.getByTestId("flow-context-edge-add-agent").click();
await expect(page.locator(".react-flow__node")).toHaveCount(5);
await expect(
page.locator(".react-flow__node").filter({ hasText: "Reviewer A" })
).toHaveCount(2);
});
test("structural nodes do not expose destructive context actions", async ({
page,
}) => {
await stubFlowsPage(page);
await page.goto(scopedPath("workflows"));
await page.waitForLoadState("networkidle");
await dispatchContextMenu(page.locator('.react-flow__node[data-id="start"]'));
const menu = page.getByTestId("flow-context-menu");
await expect(menu).toBeVisible();
await expect(menu.getByTestId("flow-context-node-duplicate")).toHaveCount(0);
await expect(menu.getByTestId("flow-context-node-delete")).toHaveCount(0);
await expect(
menu.getByTestId("flow-context-node-clear-selection")
).toBeVisible();
});