Files
kandev/apps/web/e2e/tests/pr/pr-multi-popover.spec.ts
T
José Almeida 42556971ef feat(web): tabbed multi-PR CI popover for topbar button and chat status chip (#1356)
* feat(web): tabbed multi-PR CI popover for topbar button and chat status chip

* test(e2e): cover multi-PR CI popover tabs, chip aggregate, and dropdown

* fix(web): address multi-PR popover review feedback

- suppress hover popover while the PR dropdown is open (no stacked overlays)
- repo-scope tab testids so same-number PRs on different repos stay unique
- guard non-array taskPRs store value in PRStatusChip (same as PRTaskIcon)
- add tablist/tab/aria-selected semantics to the popover tabs

* fix(web): warm default PR only, skip terminal PRs, add tabpanel ARIA

- chip background-sync warms pickDefaultPR (the tab the popover opens), not list head
- PRFeedbackWarmer skips merged/closed PRs (no wasted fetches in topbar path)
- tabpanel role + aria-controls/labelledby wire tabs to the panel

* fix(web): complete ARIA tabs keyboard model in multi-PR popover

- roving tabindex: only the active tab in the page tab order
- ArrowLeft/ArrowRight move selection + focus within the tablist (wrapping)
- stable useId-scoped tabpanel id so every tab's aria-controls resolves
- document topbar-only scoping of prCheckGroup e2e helpers

* refactor(web): skip duplicate rank call for first PR in pickDefaultPR
2026-06-12 19:34:27 +01:00

207 lines
6.8 KiB
TypeScript

import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
import type { ApiClient } from "../../helpers/api-client";
const OWNER = "acme";
type SeedResult = {
workflowId: string;
workingStepId: string;
doneStepId: string;
taskId: string;
};
/**
* Stand up a workspace + workflow + task that reaches the Done column
* immediately (auto-start + on_turn_complete moves it), mirroring
* pr-topbar-popover.spec.ts.
*/
async function seedTask(
apiClient: ApiClient,
workspaceId: string,
agentProfileId: string,
repositoryId: string,
title: string,
): Promise<SeedResult> {
const workflow = await apiClient.createWorkflow(workspaceId, `${title} Workflow`);
const inbox = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
const working = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
const done = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
await apiClient.updateWorkflowStep(working.id, {
prompt: 'e2e:message("done")\n{{task_prompt}}',
events: {
on_enter: [{ type: "auto_start_agent" }],
on_turn_complete: [{ type: "move_to_step", config: { step_id: done.id } }],
},
});
await apiClient.saveUserSettings({
workspace_id: workspaceId,
workflow_filter_id: workflow.id,
enable_preview_on_click: false,
});
await apiClient.mockGitHubReset();
await apiClient.mockGitHubSetUser("test-user");
const task = await apiClient.createTask(workspaceId, title, {
workflow_id: workflow.id,
workflow_step_id: inbox.id,
agent_profile_id: agentProfileId,
repository_ids: [repositoryId],
});
return {
workflowId: workflow.id,
workingStepId: working.id,
doneStepId: done.id,
taskId: task.id,
};
}
/** Associate two open PRs with the task: web#42 failing, api#77 all green. */
async function associateTwoPRs(apiClient: ApiClient, taskId: string) {
await apiClient.mockGitHubAssociateTaskPR({
task_id: taskId,
owner: OWNER,
repo: "web",
pr_number: 42,
pr_url: `https://github.com/${OWNER}/web/pull/42`,
pr_title: "Failing web PR",
head_branch: "feat/web",
base_branch: "main",
author_login: "test-user",
state: "open",
checks_state: "failure",
checks_total: 6,
checks_passing: 4,
review_state: "changes_requested",
review_count: 1,
});
await apiClient.mockGitHubAssociateTaskPR({
task_id: taskId,
owner: OWNER,
repo: "api",
pr_number: 77,
pr_url: `https://github.com/${OWNER}/api/pull/77`,
pr_title: "Green api PR",
head_branch: "feat/api",
base_branch: "main",
author_login: "test-user",
state: "open",
checks_state: "success",
checks_total: 4,
checks_passing: 4,
review_state: "approved",
review_count: 2,
required_reviews: 2,
mergeable_state: "clean",
});
}
async function openTaskAndWait(
testPage: import("@playwright/test").Page,
apiClient: ApiClient,
seed: SeedResult,
title: string,
): Promise<SessionPage> {
const kanban = new KanbanPage(testPage);
await kanban.goto();
await apiClient.moveTask(seed.taskId, seed.workflowId, seed.workingStepId);
await expect(kanban.taskCardInColumn(title, seed.doneStepId)).toBeVisible({ timeout: 45_000 });
await kanban.taskCardInColumn(title, seed.doneStepId).click();
await expect(testPage).toHaveURL(/\/[st]\//, { timeout: 15_000 });
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 });
return session;
}
test.describe("Multi-PR CI popover", () => {
test("hover opens tabbed popover defaulting to the worst-status PR; tab switch swaps CI detail", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const title = "Multi Popover Tabs";
const seed = await seedTask(
apiClient,
seedData.workspaceId,
seedData.agentProfileId,
seedData.repositoryId,
title,
);
await associateTwoPRs(apiClient, seed.taskId);
const session = await openTaskAndWait(testPage, apiClient, seed, title);
await expect(session.prTopbarButton()).toHaveAttribute("data-pr-count", "2");
await session.hoverPRTopbar();
await expect(session.prTopbarPopoverAggregate()).toBeVisible({ timeout: 10_000 });
// Default tab = worst status: failing web#42, not first-created green api#77.
await expect(session.prMultiPopoverTab("web", 42)).toHaveAttribute("data-active", "true");
await expect(session.prMultiPopoverTab("api", 77)).toHaveAttribute("data-active", "false");
// Body shows the failing PR's bucket groups (failed group present).
await expect(session.prCheckGroup("failed")).toBeVisible();
// Switch to the green PR: failed group disappears, passed shows 4.
await session.prMultiPopoverTab("api", 77).click();
await expect(session.prMultiPopoverTab("api", 77)).toHaveAttribute("data-active", "true");
await expect(session.prCheckGroup("failed")).toHaveCount(0);
await expect(session.prCheckGroupCount("passed")).toHaveText("4");
});
test("chat-input chip aggregates multiple PRs and opens the tabbed popover", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const title = "Multi Popover Chip";
const seed = await seedTask(
apiClient,
seedData.workspaceId,
seedData.agentProfileId,
seedData.repositoryId,
title,
);
await associateTwoPRs(apiClient, seed.taskId);
const session = await openTaskAndWait(testPage, apiClient, seed, title);
const chip = session.prStatusChip();
await expect(chip).toBeVisible();
await expect(chip).toHaveAttribute("data-pr-count", "2");
// web#42 is failing, so the aggregate (worst-of) status is failed.
await expect(chip).toHaveAttribute("data-status", "failed");
await chip.hover();
await expect(session.prTopbarPopoverAggregate()).toBeVisible({ timeout: 10_000 });
await expect(session.prMultiPopoverTab("web", 42)).toHaveAttribute("data-active", "true");
});
test("clicking the multi-PR button still opens the dropdown with per-PR rows", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const title = "Multi Popover Dropdown";
const seed = await seedTask(
apiClient,
seedData.workspaceId,
seedData.agentProfileId,
seedData.repositoryId,
title,
);
await associateTwoPRs(apiClient, seed.taskId);
const session = await openTaskAndWait(testPage, apiClient, seed, title);
await session.prTopbarButton().click();
await expect(testPage.getByTestId("pr-topbar-menu-item-42")).toBeVisible();
await expect(testPage.getByTestId("pr-topbar-menu-item-77")).toBeVisible();
});
});