Ctrl K
ComunidadGuías…

tests/e2e/flows-pane-context-menu.spec.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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();
});