Files
kandev/apps/web/components/github/pr-status-chip.auto-fix-rounds.test.tsx
T
2026-09-07 10:15:24 +01:00

268 lines
9.1 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ReactNode } from "react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { StateProvider } from "@/components/state-provider";
import { ToastProvider } from "@/components/toast-provider";
import { PRStatusChip } from "./pr-status-chip";
import type { AppState } from "@/lib/state/store";
import type { TaskCIAutomationOptions, TaskPR } from "@/lib/types/github";
const AUTO_FIX_BADGE_TESTID = "pr-status-auto-fix-chip";
const CHIP_TESTID = "pr-status-chip";
const ROUND_HELP_TESTID = "ci-auto-fix-round-help";
const ROUND_EXPLANATION_TESTID = "ci-auto-fix-round-explanation";
const testConstants = vi.hoisted(() => ({
defaultCIFixPrompt: "Default CI fix prompt",
}));
const responsiveMock = vi.hoisted(() => ({
breakpoint: "desktop" as "mobile" | "tablet" | "compactDesktop" | "desktop",
isFinePointer: true,
}));
vi.mock("@/hooks/use-responsive-breakpoint", () => ({
useResponsiveBreakpoint: () => ({
breakpoint: responsiveMock.breakpoint,
isMobile: responsiveMock.breakpoint === "mobile",
isTablet: responsiveMock.breakpoint === "tablet",
isDesktop:
responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop",
isCompactDesktop: responsiveMock.breakpoint === "compactDesktop",
isFullDesktop: responsiveMock.breakpoint === "desktop",
isFinePointer: responsiveMock.isFinePointer,
usesDesktopWorkbench:
responsiveMock.breakpoint === "compactDesktop" || responsiveMock.breakpoint === "desktop",
}),
}));
vi.mock("@/lib/api/domains/github-api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api/domains/github-api")>();
return {
...actual,
getPRFeedback: vi.fn().mockResolvedValue(null),
getTaskCIAutomationOptions: vi.fn().mockResolvedValue(makeCIOptions()),
listWorkspaceTaskPRs: vi.fn().mockResolvedValue({ task_prs: {} }),
};
});
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: vi.fn(() => null),
}));
function renderWithStore(initialState: Partial<AppState>, ui: ReactNode) {
return render(
<StateProvider initialState={initialState}>
<ToastProvider>
<TooltipProvider>{ui}</TooltipProvider>
</ToastProvider>
</StateProvider>,
);
}
function makePR(overrides: Partial<TaskPR> = {}): TaskPR {
return {
id: "pr-id",
workspace_id: "workspace-1",
task_id: "task-1",
owner: "acme",
repo: "demo",
pr_number: 42,
pr_url: "https://github.com/acme/demo/pull/42",
pr_title: "Test PR",
head_branch: "feat",
base_branch: "main",
author_login: "alice",
state: "open",
review_state: "approved",
checks_state: "success",
mergeable_state: "clean",
review_count: 1,
pending_review_count: 0,
comment_count: 0,
unresolved_review_threads: 0,
checks_total: 2,
checks_passing: 2,
additions: 0,
deletions: 0,
created_at: "",
merged_at: null,
closed_at: null,
last_synced_at: null,
updated_at: "",
...overrides,
};
}
// auto_fix_enabled is per-PR; pr_options defaults to one entry matching
// makePR()'s identity (task-1 / repository_id "" / #42) so overrides like
// `makeCIOptions({ auto_fix_enabled: true })` still drive the rendered chip.
function makeCIOptions(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: testConstants.defaultCIFixPrompt,
using_default_prompt: true,
updated_at: "2026-06-18T10:00:00Z",
auto_fix_max_rounds: 10,
pr_states: [],
...overrides,
};
return {
...base,
pr_options: overrides.pr_options ?? [
{
task_id: base.task_id,
repository_id: "",
pr_number: 42,
auto_fix_enabled: base.auto_fix_enabled,
auto_merge_enabled: base.auto_merge_enabled,
prompt_on_review_requested: false,
prompt_on_merged: false,
prompt_on_closed: false,
created_at: "",
updated_at: "",
},
],
};
}
function makeCIPrState(roundCount: number, exhausted = false) {
return {
task_id: "task-1",
repository_id: "",
pr_number: 42,
last_fix_signature: "",
last_fix_checkpoint_json: "",
last_fix_enqueued_at: null,
last_fix_session_id: null,
auto_fix_round_count: roundCount,
auto_fix_exhausted_at: exhausted ? "2026-06-18T11:00:00Z" : null,
last_merge_signature: "",
last_merge_attempt_at: null,
last_merge_result: "" as const,
last_error: exhausted ? "CI auto-fix paused after 10 rounds for this PR" : null,
last_error_kind: "",
created_at: "2026-06-18T10:00:00Z",
updated_at: "2026-06-18T10:00:00Z",
};
}
function stateWithAutoFix(
roundCount: number,
exhausted = false,
maxRounds = 10,
): Partial<AppState> {
return {
taskPRs: { byTaskId: { "task-1": [makePR()] } },
taskCIAutomation: {
byTaskId: {
"task-1": makeCIOptions({
auto_fix_enabled: true,
auto_fix_max_rounds: maxRounds,
pr_states: [makeCIPrState(roundCount, exhausted)],
}),
},
loading: {},
saving: {},
errors: {},
},
};
}
beforeEach(() => {
responsiveMock.breakpoint = "desktop";
responsiveMock.isFinePointer = true;
});
afterEach(() => {
cleanup();
});
describe("PRStatusChip auto-fix round display", () => {
it("shows the CI auto-fix round count in the enabled chip", () => {
renderWithStore(stateWithAutoFix(3), <PRStatusChip taskId="task-1" />);
expect(screen.getByTestId(AUTO_FIX_BADGE_TESTID).textContent).toBe("Auto-fix 3/10");
});
it("shows exhausted auto-fix state as a warning chip", () => {
renderWithStore(stateWithAutoFix(10, true), <PRStatusChip taskId="task-1" />);
const chip = screen.getByTestId(AUTO_FIX_BADGE_TESTID);
expect(chip.textContent).toBe("Auto-fix paused 10/10");
expect(chip.getAttribute("data-auto-fix-exhausted")).toBe("true");
});
it("does not mark 10/10 as exhausted until the backend pauses auto-fix", () => {
renderWithStore(stateWithAutoFix(10), <PRStatusChip taskId="task-1" />);
const chip = screen.getByTestId(AUTO_FIX_BADGE_TESTID);
expect(chip.textContent).toBe("Auto-fix 10/10");
expect(chip.getAttribute("data-auto-fix-exhausted")).toBe("false");
});
it("uses the max round count returned by the backend", () => {
renderWithStore(stateWithAutoFix(3, false, 12), <PRStatusChip taskId="task-1" />);
expect(screen.getByTestId(AUTO_FIX_BADGE_TESTID).textContent).toBe("Auto-fix 3/12");
});
it("explains the auto-fix round count only after hovering the round help icon", async () => {
renderWithStore(stateWithAutoFix(1), <PRStatusChip taskId="task-1" />);
fireEvent.mouseEnter(screen.getByTestId(CHIP_TESTID));
expect(screen.queryByTestId(ROUND_EXPLANATION_TESTID)).toBeNull();
const roundHelp = await screen.findByTestId(ROUND_HELP_TESTID);
fireEvent.pointerMove(roundHelp, { pointerType: "mouse" });
fireEvent.pointerEnter(roundHelp, { pointerType: "mouse" });
fireEvent.mouseMove(roundHelp);
const [explanation] = await screen.findAllByTestId(ROUND_EXPLANATION_TESTID);
expect(explanation.textContent).toContain("Auto-fix has used 1 of 10 rounds");
expect(explanation.textContent).toContain(
"Kandev waits for all PR checks to finish before starting a new CI auto-fix turn",
);
expect(explanation.textContent).toContain(
"Updating an already queued auto-fix message does not use another round",
);
expect(explanation.textContent).toContain(
"pauses auto-fix for this PR so it cannot loop forever",
);
expect(explanation.textContent).toContain(
"If a turn ends without a recorded outcome, Kandev can retry the same settled feedback",
);
expect(explanation.textContent).toContain(
"After an action-taken outcome, Kandev waits for provider progress before retrying",
);
expect(explanation.textContent).toContain(
"A non-actionable or blocked outcome acknowledges unchanged feedback and does not retry it",
);
});
it("opens the auto-fix round explanation from the mobile drawer help icon", async () => {
responsiveMock.breakpoint = "mobile";
responsiveMock.isFinePointer = false;
renderWithStore(stateWithAutoFix(2), <PRStatusChip taskId="task-1" />);
act(() => {
fireEvent.click(screen.getByTestId(CHIP_TESTID));
});
expect(screen.queryByTestId(ROUND_EXPLANATION_TESTID)).toBeNull();
fireEvent.click(screen.getByTestId(ROUND_HELP_TESTID));
const explanation = await screen.findByTestId(ROUND_EXPLANATION_TESTID);
expect(explanation.textContent).toContain("Auto-fix has used 2 of 10 rounds");
expect(explanation.textContent).toContain(
"Kandev waits for all PR checks to finish before starting a new CI auto-fix turn",
);
expect(explanation.textContent).toContain(
"If a turn ends without a recorded outcome, Kandev can retry the same settled feedback",
);
});
});