Files
kandev/apps/web/components/github/pr-ci-popover.automation.test.tsx
T

616 lines
21 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useState } from "react";
import { cleanup, render, screen, fireEvent, waitFor } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
const hookMocks = vi.hoisted(() => ({
error: null as string | null,
options: null as TaskCIAutomationOptions | null,
refreshMock: vi.fn(),
updateMock: vi.fn(),
resetPromptMock: vi.fn(),
}));
const responsiveMock = vi.hoisted(() => ({
isFinePointer: true,
isMobile: false,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({
isFinePointer: responsiveMock.isFinePointer,
isMobile: responsiveMock.isMobile,
}),
}));
vi.mock("@/hooks/domains/github/use-pr-ci-popover", () => ({
usePRFeedbackBackgroundSync: vi.fn(),
usePRCIPopover: () => ({
feedback: null,
isFetching: false,
isRefreshing: false,
lastUpdatedAt: null,
refetch: vi.fn(),
}),
}));
vi.mock("@/hooks/domains/github/use-task-ci-options", () => ({
useTaskCIAutomationOptions: () => ({
options: hookMocks.options ?? makeOptions(),
loading: false,
saving: false,
error: hookMocks.error,
refresh: hookMocks.refreshMock,
update: hookMocks.updateMock,
resetPrompt: hookMocks.resetPromptMock,
}),
}));
import { PRCIPopover } from "./pr-ci-popover";
import { MultiPRCIPopover } from "./multi-pr-ci-popover";
const AUTO_FIX_LABEL = "Auto-fix CI and address comments";
const AUTO_MERGE_LABEL = "Auto-merge or requeue when ready";
const MERGED_PROMPT_LABEL = "PR merged";
const CLOSED_PROMPT_LABEL = "PR closed without merging";
const REVIEW_REQUEST_PROMPT_LABEL = "Your review is requested";
const REVIEW_FOLLOW_UP_TRIGGER = "ci-review-follow-up-trigger";
const REMOVE_FIRST_PR_LABEL = "Remove r #1 from task";
const BACKEND_UNAVAILABLE = "backend unavailable";
// The five automation switches are per-PR; pr_options defaults to one entry
// for makePR()'s default identity (task-1 / repository_id "" / #1) mirroring
// whichever top-level switch overrides the caller passed, so existing
// `makeOptions({ prompt_on_merged: true })`-style overrides still drive the
// rendered switch state without every call site building pr_options by hand.
function makeOptions(overrides: Partial<TaskCIAutomationOptions> = {}): TaskCIAutomationOptions {
const base = {
task_id: "task-1",
auto_fix_enabled: false,
auto_merge_enabled: false,
auto_fix_prompt_override: null,
effective_auto_fix_prompt: "Default CI fix prompt",
using_default_prompt: true,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
updated_at: "2026-06-18T10:00:00Z",
pr_states: [],
...overrides,
};
return {
...base,
pr_options: overrides.pr_options ?? [
{
task_id: base.task_id,
repository_id: "",
pr_number: 1,
auto_fix_enabled: base.auto_fix_enabled,
auto_merge_enabled: base.auto_merge_enabled,
prompt_on_review_requested: base.prompt_on_review_requested,
prompt_on_merged: base.prompt_on_merged,
prompt_on_closed: base.prompt_on_closed,
created_at: "",
updated_at: "",
},
],
};
}
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "o",
repo: "r",
pr_number: 1,
pr_url: "https://github.com/o/r/pull/1",
pr_title: "Test PR",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
state: "open",
review_state: "",
checks_state: "failure",
mergeable_state: "blocked",
review_count: 0,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 1,
checks_total: 2,
checks_passing: 1,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
};
}
function renderPopover(pr: TaskPR = makePR()) {
return render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<PRCIPopover pr={pr} enabled={true} />
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
}
function makePROption(prNumber: number, enabled: boolean) {
return {
task_id: "task-1",
repository_id: "",
pr_number: prNumber,
auto_fix_enabled: enabled,
auto_merge_enabled: enabled,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
};
}
function resetHookMocks() {
hookMocks.error = null;
hookMocks.options = null;
hookMocks.refreshMock.mockReset();
hookMocks.updateMock.mockReset();
hookMocks.resetPromptMock.mockReset();
responsiveMock.isFinePointer = true;
responsiveMock.isMobile = false;
}
describe("PRCIPopover automation toggles", () => {
beforeEach(() => {
resetHookMocks();
});
afterEach(() => {
cleanup();
});
it("renders explanatory info and toggles task automation options", async () => {
renderPopover();
expect(screen.getByLabelText("Explain CI automation options")).not.toBeNull();
fireEvent.click(screen.getByLabelText(AUTO_FIX_LABEL));
fireEvent.click(screen.getByLabelText(AUTO_MERGE_LABEL));
fireEvent.click(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER));
fireEvent.click(screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL));
fireEvent.click(screen.getByLabelText(MERGED_PROMPT_LABEL));
fireEvent.click(screen.getByLabelText(CLOSED_PROMPT_LABEL));
// Every switch patch carries this PR's identity so the backend applies
// the change to this PR only, instead of fanning out to every linked PR.
const identity = { repository_id: "", pr_number: 1 };
expect(hookMocks.updateMock).toHaveBeenCalledWith({ ...identity, auto_fix_enabled: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({ ...identity, auto_merge_enabled: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({
...identity,
prompt_on_review_requested: true,
});
expect(hookMocks.updateMock).toHaveBeenCalledWith({ ...identity, prompt_on_merged: true });
expect(hookMocks.updateMock).toHaveBeenCalledWith({ ...identity, prompt_on_closed: true });
});
it("keeps review follow-up collapsed until opened while auto-fix and auto-merge stay primary", () => {
renderPopover();
const trigger = screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER);
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(screen.getByLabelText(AUTO_FIX_LABEL)).not.toBeNull();
expect(screen.getByLabelText(AUTO_MERGE_LABEL)).not.toBeNull();
expect(screen.queryByLabelText(REVIEW_REQUEST_PROMPT_LABEL)).toBeNull();
expect(screen.queryByLabelText(MERGED_PROMPT_LABEL)).toBeNull();
expect(screen.queryByLabelText(CLOSED_PROMPT_LABEL)).toBeNull();
fireEvent.click(trigger);
expect(trigger.getAttribute("aria-expanded")).toBe("true");
expect(screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL)).not.toBeNull();
expect(screen.getByLabelText(MERGED_PROMPT_LABEL)).not.toBeNull();
expect(screen.getByLabelText(CLOSED_PROMPT_LABEL)).not.toBeNull();
expect(
screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL).getAttribute("aria-describedby"),
).toBe("task-pr-review-requested-prompt-task-1-none-1-description");
expect(screen.getByLabelText(MERGED_PROMPT_LABEL).getAttribute("aria-describedby")).toBe(
"task-pr-terminal-help-task-1-none-1",
);
expect(screen.getByLabelText(CLOSED_PROMPT_LABEL).getAttribute("aria-describedby")).toBe(
"task-pr-terminal-help-task-1-none-1",
);
expect(
screen.getByText("Wake the agent for any new request, including re-review after changes."),
).not.toBeNull();
expect(
screen.getByText("Wake the agent when review work ends. Choose either or both outcomes."),
).not.toBeNull();
expect(screen.getByTestId("ci-review-requested-help")).not.toBeNull();
expect(screen.getByTestId("ci-pr-terminal-help")).not.toBeNull();
});
it("opens review follow-up when lifecycle automation is enabled", () => {
hookMocks.options = makeOptions({ prompt_on_merged: true });
renderPopover();
expect(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER).getAttribute("aria-expanded")).toBe("true");
expect(screen.getByLabelText(MERGED_PROMPT_LABEL)).not.toBeNull();
});
it("keeps the review follow-up trigger compact for desktop and touch-sized otherwise", () => {
renderPopover();
const desktopTrigger = screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER);
expect(desktopTrigger.classList.contains("min-h-7")).toBe(true);
expect(desktopTrigger.classList.contains("min-h-11")).toBe(false);
cleanup();
responsiveMock.isMobile = true;
renderPopover();
expect(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER).classList.contains("min-h-11")).toBe(true);
cleanup();
responsiveMock.isMobile = false;
responsiveMock.isFinePointer = false;
renderPopover();
expect(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER).classList.contains("min-h-11")).toBe(true);
});
});
describe("PRCIPopover automation status", () => {
beforeEach(() => {
resetHookMocks();
});
afterEach(() => {
cleanup();
});
it("renders the connected-account review label and selected PR error", () => {
hookMocks.options = makeOptions({
pr_states: [
{
task_id: "task-1",
repository_id: "",
pr_number: 1,
last_fix_signature: "",
last_fix_checkpoint_json: "",
last_fix_enqueued_at: null,
last_fix_session_id: null,
auto_fix_round_count: 0,
auto_fix_exhausted_at: null,
last_merge_signature: "",
last_merge_attempt_at: null,
last_merge_result: "",
last_error: "No promptable task session is available.",
last_error_kind: "",
created_at: "",
updated_at: "",
},
],
});
renderPopover();
expect(screen.getByRole("alert").textContent).toContain(
"No promptable task session is available.",
);
fireEvent.click(screen.getByTestId(REVIEW_FOLLOW_UP_TRIGGER));
expect(screen.getByLabelText(REVIEW_REQUEST_PROMPT_LABEL)).not.toBeNull();
expect(screen.queryByLabelText(/edit.*review/i)).toBeNull();
});
it("shows active queue context without adding another switch", () => {
renderPopover(
makePR({
merge_queue_state: "queued",
merge_queue_entry_id: "entry-a",
head_sha: "head-a",
}),
);
expect(screen.getByText("Merge queue automation")).not.toBeNull();
expect(screen.getByText("PR #1 is in the merge queue")).not.toBeNull();
expect(screen.getByTestId("ci-merge-queue-recovery-status").textContent).toContain(
"Active merge queue attempt",
);
expect(screen.getAllByRole("switch")).toHaveLength(2);
});
it("shows classified recovery state and generic copy for unknown causes", () => {
hookMocks.options = makeOptions({
pr_states: [
{
task_id: "task-1",
repository_id: "",
pr_number: 1,
last_fix_signature: "",
last_fix_checkpoint_json: "",
last_fix_enqueued_at: null,
last_fix_session_id: null,
auto_fix_round_count: 0,
auto_fix_exhausted_at: null,
last_merge_signature: "",
last_merge_attempt_at: null,
last_merge_result: "",
last_queue_attempt_head_sha: "head-a",
last_queue_fix_event_id: "",
last_queue_removal_cause: "provider_changed",
last_error: null,
last_error_kind: "",
created_at: "",
updated_at: "",
},
],
});
renderPopover(
makePR({
head_sha: "head-a",
merge_queue_last_removal_id: "removal-a",
merge_queue_last_removal_reason: "provider changed this",
}),
);
expect(screen.getByText("Merge queue recovery")).not.toBeNull();
expect(screen.getByText("PR #1 was removed from the merge queue")).not.toBeNull();
expect(screen.getByTestId("ci-merge-queue-recovery-status").textContent).toContain(
"No automatic repair",
);
expect(screen.queryByText("provider changed this")).toBeNull();
});
});
describe("PRCIPopover linked PR status", () => {
beforeEach(() => {
resetHookMocks();
});
afterEach(() => {
cleanup();
});
it("uses the PR title in the header and omits the redundant detail link", () => {
renderPopover();
expect(screen.getByTestId("pr-popover-title").textContent).toBe("#1 Test PR");
expect(screen.queryByText("CI status")).toBeNull();
expect(screen.queryByText("Open PR details")).toBeNull();
expect(screen.queryByLabelText("View all checks on GitHub")).toBeNull();
expect(screen.getByLabelText("View pull request on GitHub")).not.toBeNull();
});
it("opens the in-app detail panel from the selected multi-PR title", () => {
const onOpenDetailPanel = vi.fn();
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<MultiPRCIPopover
prs={[
makePR({ id: "a", pr_number: 1, pr_title: "First PR", checks_state: "success" }),
makePR({ id: "b", pr_number: 2, pr_title: "Second PR" }),
]}
enabled={true}
onOpenDetailPanel={onOpenDetailPanel}
/>
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Open #2 Second PR details" }));
expect(onOpenDetailPanel).toHaveBeenCalledTimes(1);
expect(onOpenDetailPanel).toHaveBeenCalledWith(expect.objectContaining({ id: "b" }));
expect(screen.queryByText("Open PR details")).toBeNull();
});
it("keeps each linked PR's automation switches independent across tabs (AC1-AC3)", () => {
hookMocks.options = makeOptions({
pr_options: [makePROption(1, true), makePROption(2, false)],
});
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<MultiPRCIPopover
prs={[
makePR({ id: "a", pr_number: 1, pr_title: "First PR" }),
makePR({ id: "b", pr_number: 2, pr_title: "Second PR" }),
]}
enabled={true}
/>
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
const checkedState = () => [
screen.getByLabelText(AUTO_FIX_LABEL).getAttribute("aria-checked"),
screen.getByLabelText(AUTO_MERGE_LABEL).getAttribute("aria-checked"),
];
// PR #1's tab is selected by default (worst status) — its switches are on.
expect(checkedState()).toEqual(["true", "true"]);
// Switching to PR #2's tab shows its own, independently off, state.
fireEvent.click(screen.getByTestId("pr-popover-tab-o-r-2"));
expect(checkedState()).toEqual(["false", "false"]);
});
});
describe("MultiPRCIPopover unlink", () => {
beforeEach(() => {
resetHookMocks();
});
afterEach(() => {
cleanup();
});
it("unlinks one multi-PR tab while keeping its sibling visible", async () => {
const onCollapseFocus = vi.fn();
function RemovablePopover() {
const [prs, setPrs] = useState([
makePR({ id: "a", pr_number: 1, pr_title: "First PR" }),
makePR({ id: "b", pr_number: 2, pr_title: "Second PR", checks_state: "success" }),
]);
return (
<MultiPRCIPopover
prs={prs}
enabled={true}
onRemovePR={async (pr) =>
setPrs((current) => current.filter((item) => item.id !== pr.id))
}
onCollapseFocus={onCollapseFocus}
/>
);
}
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<RemovablePopover />
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
fireEvent.click(screen.getByRole("button", { name: REMOVE_FIRST_PR_LABEL }));
await waitFor(() =>
expect(screen.queryByRole("button", { name: REMOVE_FIRST_PR_LABEL })).toBeNull(),
);
expect(screen.getByRole("tab", { name: "r #2" })).not.toBeNull();
expect(onCollapseFocus).toHaveBeenCalledWith(expect.objectContaining({ id: "b" }));
});
it("keeps a failed unlink tab and reports the error", async () => {
const onRemovePR = vi.fn().mockRejectedValue(new Error(BACKEND_UNAVAILABLE));
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<MultiPRCIPopover
prs={[makePR({ id: "a", pr_number: 1 }), makePR({ id: "b", pr_number: 2 })]}
enabled={true}
onRemovePR={onRemovePR}
/>
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
fireEvent.click(screen.getByRole("button", { name: REMOVE_FIRST_PR_LABEL }));
await waitFor(() =>
expect(screen.getByTestId("toast-message").textContent).toContain(BACKEND_UNAVAILABLE),
);
expect(screen.getByRole("button", { name: REMOVE_FIRST_PR_LABEL })).not.toBeNull();
expect(onRemovePR).toHaveBeenCalledWith(expect.objectContaining({ id: "a" }));
});
it("focuses the next adjacent tab when a selected tab is removed from a larger set", async () => {
function RemovablePopover() {
const [prs, setPrs] = useState([
makePR({ id: "a", pr_number: 1, pr_title: "First PR", checks_state: "success" }),
makePR({ id: "b", pr_number: 2, pr_title: "Second PR" }),
makePR({ id: "c", pr_number: 3, pr_title: "Third PR", checks_state: "success" }),
]);
return (
<MultiPRCIPopover
prs={prs}
enabled={true}
onRemovePR={async (pr) =>
setPrs((current) => current.filter((item) => item.id !== pr.id))
}
/>
);
}
render(
<TooltipProvider>
<StateProvider>
<ToastProvider>
<RemovablePopover />
</ToastProvider>
</StateProvider>
</TooltipProvider>,
);
expect(screen.getByRole("tab", { name: "r #2" }).getAttribute("aria-selected")).toBe("true");
fireEvent.click(screen.getByRole("button", { name: "Remove r #2 from task" }));
await waitFor(() => expect(screen.queryByRole("tab", { name: "r #2" })).toBeNull());
const nextTab = screen.getByRole("tab", { name: "r #3" });
expect(nextTab.getAttribute("aria-selected")).toBe("true");
expect(document.activeElement).toBe(nextTab);
});
});
describe("PRCIPopover task prompts", () => {
beforeEach(() => {
resetHookMocks();
});
afterEach(() => {
cleanup();
});
it("opens a task prompt dialog with a settings link and saves overrides", async () => {
renderPopover();
fireEvent.click(screen.getByLabelText("Edit auto-fix prompt for this task"));
expect(screen.getByRole("dialog").textContent).toContain("Auto-fix prompt");
expect(screen.getByRole("link", { name: "Edit default prompt" }).getAttribute("href")).toBe(
"/settings/prompts",
);
expect(screen.getByRole("dialog").textContent).toContain("{{pr.feedback}}");
expect(screen.getByRole("dialog").textContent).toContain("new or changed failing checks");
expect(screen.getByRole("dialog").textContent).toContain("review comments");
const textarea = screen.getByLabelText("Task auto-fix prompt");
fireEvent.click(screen.getByRole("button", { name: "Insert PR feedback" }));
expect((textarea as HTMLTextAreaElement).value).toContain("{{pr.feedback}}");
fireEvent.change(textarea, { target: { value: "Please fix this PR." } });
fireEvent.click(screen.getByRole("button", { name: "Save prompt" }));
await waitFor(() => {
expect(hookMocks.updateMock).toHaveBeenCalledWith({
auto_fix_prompt_override: "Please fix this PR.",
});
});
});
it("uses the default prompt when requested", async () => {
renderPopover();
fireEvent.click(screen.getByLabelText("Edit auto-fix prompt for this task"));
fireEvent.click(screen.getByRole("button", { name: "Use default" }));
await waitFor(() => {
expect(hookMocks.resetPromptMock).toHaveBeenCalledTimes(1);
});
expect(hookMocks.updateMock).not.toHaveBeenCalled();
});
it("offers refresh after CI automation options fail to load", () => {
hookMocks.error = BACKEND_UNAVAILABLE;
renderPopover();
expect(screen.getByText(BACKEND_UNAVAILABLE)).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Refresh" }));
expect(hookMocks.refreshMock).toHaveBeenCalledTimes(1);
});
});