import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { createElement, useEffect } from "react"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { StateProvider } from "@/components/state-provider"; import type { AppState } from "@/lib/state/store"; import { afterEach, describe, it, expect, vi } from "vitest"; import { PRDetailContent, shouldHideApproveButton, shouldShowReRequestReviewAction, } from "./pr-detail-panel"; import { clearPRReviewRequestRegistryForTests, usePRScopedReviewRequest, } from "./use-pr-scoped-review-request"; import type { TaskPR, PRFeedback, GitHubPR, PRReview } from "@/lib/types/github"; import { activateLocale, i18n } from "@/lib/i18n"; const feedbackMocks = vi.hoisted(() => ({ refresh: vi.fn(), value: null as PRFeedback | null, })); const reviewMocks = vi.hoisted(() => ({ requestReviewers: vi.fn(), submitReview: vi.fn() })); const RE_REQUEST_BUTTON = "change-request-review-action-rerequest-review-octocat"; const PENDING_REVIEWER = "change-request-pending-reviewer-octocat"; const DISMISSED_REVIEWED_AT = "2026-01-01T00:00:00Z"; const WORKSPACE_ID = "workspace-1"; vi.mock("@/hooks/domains/github/use-pr-feedback", () => ({ usePRFeedback: () => ({ feedback: feedbackMocks.value, loading: false, refresh: feedbackMocks.refresh, }), })); vi.mock("@/lib/api/domains/github-review-api", () => ({ requestPRReviewers: reviewMocks.requestReviewers, submitPRReview: reviewMocks.submitReview, })); vi.mock("@/lib/state/slices/comments", () => ({ isPRFeedbackComment: () => false, useCommentsStore: ( selector: (state: { addComment: () => void; pendingForChat: string[]; byId: object; }) => unknown, ) => selector({ addComment: vi.fn(), pendingForChat: [], byId: {} }), })); vi.mock("@/components/toast-provider", () => ({ useToast: () => ({ toast: vi.fn() }) })); vi.mock("@/hooks/domains/github/use-github-status", () => ({ useGitHubStatus: () => ({ status: null }), })); vi.mock("./pr-merge-button", () => ({ PRMergeButton: () => null })); vi.mock("./pr-mergeability-notice", () => ({ PRMergeabilityNotice: () => null, buildConflictResolutionMessage: () => "", })); vi.mock("./pr-checks-section", () => ({ ChecksSection: () => null })); vi.mock("./pr-comments-section", () => ({ CommentsSection: () => null })); afterEach(async () => { vi.useRealTimers(); cleanup(); feedbackMocks.refresh.mockReset(); feedbackMocks.value = null; reviewMocks.requestReviewers.mockReset(); reviewMocks.submitReview.mockReset(); clearPRReviewRequestRegistryForTests(); await activateLocale("en"); }); function makeTaskPR(overrides: Partial = {}): TaskPR { return { id: "id", workspace_id: WORKSPACE_ID, task_id: "task", owner: "o", repo: "r", pr_number: 1, pr_url: "", pr_title: "Test PR", head_branch: "feat", base_branch: "main", author_login: "alice", state: "open", review_state: "", checks_state: "", mergeable_state: "", review_count: 0, pending_review_count: 0, comment_count: 0, unresolved_review_threads: 0, checks_total: 0, checks_passing: 0, additions: 0, deletions: 0, created_at: "", merged_at: null, closed_at: null, last_synced_at: null, updated_at: "", ...overrides, }; } function makeGitHubPR(overrides: Partial = {}): GitHubPR { return { number: 1, title: "Test PR", url: "", html_url: "", state: "open", head_branch: "feat", base_branch: "main", author_login: "alice", repo_owner: "o", repo_name: "r", draft: false, mergeable: true, additions: 0, deletions: 0, ...overrides, } as GitHubPR; } function makeFeedback( overrides: { pr?: Partial; reviews?: PRReview[]; } = {}, ): PRFeedback { return { pr: makeGitHubPR(overrides.pr), reviews: overrides.reviews ?? [], comments: [], checks: [], has_issues: false, }; } function ReviewRequestHarness({ onReady, requestedReviewers = [], reviews = [], workspaceId = WORKSPACE_ID, }: { onReady: (reviewRequest: ReturnType) => void; requestedReviewers?: { login: string; type: "user" }[]; reviews?: PRReview[]; workspaceId?: string | null; }) { const reviewRequest = usePRScopedReviewRequest(makeTaskPR(), { workspaceId, requestedReviewers, reviews, refresh: vi.fn(), toast: vi.fn(), }); useEffect(() => onReady(reviewRequest), [onReady, reviewRequest]); return null; } function dismissedReview(id = 1): PRReview { return { id, author: "octocat", author_avatar: "", state: "DISMISSED", body: "", created_at: DISMISSED_REVIEWED_AT, }; } const TEST_INITIAL_STATE = { workspaces: { activeId: WORKSPACE_ID }, } as unknown as Partial; function prDetailTree(taskPR = makeTaskPR()) { return createElement(StateProvider, { initialState: TEST_INITIAL_STATE, children: createElement( TooltipProvider, undefined, createElement(PRDetailContent, { taskPR, sessionId: "session-1" }), ), }); } function renderPRDetail(taskPR = makeTaskPR()) { return render(prDetailTree(taskPR)); } describe("PRDetailContent shared presentation", () => { it("renders GitHub through the provider-neutral change request detail", () => { feedbackMocks.value = makeFeedback({ pr: { body: "Shared review body" } }); renderPRDetail(); expect(screen.getByTestId("change-request-detail")).not.toBeNull(); expect(screen.getByText("Test PR")).not.toBeNull(); }); it("localizes review actions instead of embedding English in the detail model", async () => { await activateLocale("pseudo"); feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); renderPRDetail(); expect(screen.getByTestId(RE_REQUEST_BUTTON).textContent).toBe( i18n.t("github:reRequestReview"), ); expect(screen.getByTestId(RE_REQUEST_BUTTON).textContent).not.toBe("Re-request review"); }); }); describe("shouldHideApproveButton", () => { it("hides when PR is closed", () => { expect(shouldHideApproveButton(makeTaskPR({ state: "closed" }), null, "bob")).toBe(true); }); it("hides when PR is merged", () => { expect(shouldHideApproveButton(makeTaskPR({ state: "merged" }), null, "bob")).toBe(true); }); // Regression: pre-fix this returned false (button shown), so the green // Approve button appeared on every PR, including the viewer's own, during // the brief window before /api/v1/github/status resolved client-side. it("hides when current user is unknown (status not loaded yet)", () => { expect(shouldHideApproveButton(makeTaskPR({ author_login: "alice" }), null, null)).toBe(true); expect(shouldHideApproveButton(makeTaskPR({ author_login: "alice" }), null, "")).toBe(true); expect(shouldHideApproveButton(makeTaskPR({ author_login: "alice" }), null, " ")).toBe(true); }); it("allows App-attributed approval when a mutation actor is available", () => { expect(shouldHideApproveButton(makeTaskPR({ author_login: "alice" }), null, null, true)).toBe( false, ); }); it("hides when current user authored the PR (case-insensitive)", () => { expect(shouldHideApproveButton(makeTaskPR({ author_login: "Alice" }), null, "alice")).toBe( true, ); expect(shouldHideApproveButton(makeTaskPR({ author_login: "alice" }), null, "ALICE")).toBe( true, ); }); it("hides when current user has already approved", () => { const feedback = makeFeedback({ pr: { author_login: "alice" }, reviews: [ { id: 1, author: "bob", author_avatar: "", state: "APPROVED", body: "", created_at: "", }, ], }); expect(shouldHideApproveButton(makeTaskPR({ author_login: "alice" }), feedback, "bob")).toBe( true, ); }); it("shows when current user is a different open reviewer", () => { expect(shouldHideApproveButton(makeTaskPR({ author_login: "alice" }), null, "bob")).toBe(false); }); it("prefers feedback.pr.author_login over taskPR.author_login when both present", () => { // taskPR may be stale; live feedback wins. Here the stored author looks // like a different user but feedback says it is actually us, so it must hide. const feedback = makeFeedback({ pr: { author_login: "bob" } }); expect(shouldHideApproveButton(makeTaskPR({ author_login: "alice" }), feedback, "bob")).toBe( true, ); }); }); describe("shouldShowReRequestReviewAction", () => { it("only permits dismissed reviews on an open PR", () => { expect(shouldShowReRequestReviewAction("open", "DISMISSED")).toBe(true); expect(shouldShowReRequestReviewAction("open", "APPROVED")).toBe(false); expect(shouldShowReRequestReviewAction("closed", "DISMISSED")).toBe(false); expect(shouldShowReRequestReviewAction("merged", "DISMISSED")).toBe(false); }); }); describe("PRDetailContent deferred re-request", () => { it("keeps a successfully re-requested reviewer pending when feedback refresh is deferred", async () => { feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); renderPRDetail(); await act(async () => { fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); }); await waitFor(() => { expect(screen.getByTestId(PENDING_REVIEWER)).not.toBeNull(); }); expect(reviewMocks.requestReviewers).toHaveBeenCalledWith( "o", "r", 1, ["octocat"], WORKSPACE_ID, ); expect(screen.queryByTestId(RE_REQUEST_BUTTON)).toBeNull(); }); }); describe("PRDetailContent remount lifecycle", () => { it("keeps an in-flight request locked across a same-PR remount", () => { feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); reviewMocks.requestReviewers.mockReturnValue(new Promise(() => undefined)); const firstPanel = renderPRDetail(); fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); firstPanel.unmount(); renderPRDetail(); fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); expect(reviewMocks.requestReviewers).toHaveBeenCalledOnce(); }); }); describe("PRDetailContent optimistic review reconciliation", () => { it("reclaims optimism when a newer submitted review bypasses requested-reviewer feedback", async () => { feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); const view = renderPRDetail(); await act(async () => { fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); }); feedbackMocks.value = makeFeedback({ reviews: [ { id: 2, author: "octocat", author_avatar: "", state: "APPROVED", body: "Looks good", created_at: "2026-01-02T00:00:00Z", }, ], }); view.rerender(prDetailTree()); await waitFor(() => { expect(screen.queryByTestId(PENDING_REVIEWER)).toBeNull(); }); expect(screen.getByTestId("change-request-submitted-review-octocat")).not.toBeNull(); }); }); describe("PRDetailContent same-timestamp review reconciliation", () => { it("uses the review ID to resolve same-timestamp reviewer state", async () => { feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); const view = renderPRDetail(); await act(async () => fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON))); feedbackMocks.value = makeFeedback({ reviews: [ dismissedReview(), { id: 2, author: "octocat", author_avatar: "", state: "APPROVED", body: "Looks good", created_at: DISMISSED_REVIEWED_AT, }, ], }); view.rerender(prDetailTree()); await waitFor(() => expect(screen.queryByTestId(PENDING_REVIEWER)).toBeNull()); expect(screen.getByTestId("change-request-submitted-review-octocat")).not.toBeNull(); }); }); describe("PRDetailContent confirmed review request", () => { it("reclaims confirmed pending state from the persistent registry", async () => { feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()], }); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); const view = renderPRDetail(); await act(async () => { fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); }); feedbackMocks.value = makeFeedback({ pr: { requested_reviewers: [{ login: "octocat", type: "user" }] }, reviews: [ { id: 1, author: "octocat", author_avatar: "", state: "DISMISSED", body: "", created_at: DISMISSED_REVIEWED_AT, }, ], }); view.rerender(prDetailTree()); await waitFor(() => { expect(screen.getByTestId(PENDING_REVIEWER)).not.toBeNull(); }); feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); view.rerender(prDetailTree()); await waitFor(() => { expect(screen.getByTestId(RE_REQUEST_BUTTON)).not.toBeNull(); }); }); }); describe("PRDetailContent same-tick re-request", () => { it("posts once when invoked twice before React has a chance to re-render", () => { reviewMocks.requestReviewers.mockReturnValue(new Promise(() => undefined)); let reRequest: (reviewer: string) => Promise = async () => undefined; render( createElement(ReviewRequestHarness, { onReady: (request) => { reRequest = request.reRequest; }, }), ); act(() => { void reRequest("octocat"); void reRequest("octocat"); }); expect(reviewMocks.requestReviewers).toHaveBeenCalledOnce(); }); }); describe("PRDetailContent workspace-scoped requests", () => { it("does not issue a cross-workspace request when no active workspace is available", async () => { let reRequest: (reviewer: string) => Promise = async () => undefined; render( createElement(ReviewRequestHarness, { workspaceId: null, onReady: (request) => { reRequest = request.reRequest; }, }), ); await act(async () => reRequest("octocat")); expect(reviewMocks.requestReviewers).not.toHaveBeenCalled(); }); it("keeps same-PR requests and optimistic reconciliation isolated by workspace", async () => { reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); let reviewRequest: ReturnType | undefined; const onReady = (value: ReturnType) => { reviewRequest = value; }; const view = render( createElement(ReviewRequestHarness, { onReady, workspaceId: "workspace-a" }), ); await act(async () => reviewRequest?.reRequest("octocat")); expect(reviewRequest?.requestedReviewers).toEqual([{ login: "octocat", type: "user" }]); view.rerender(createElement(ReviewRequestHarness, { onReady, workspaceId: "workspace-b" })); expect(reviewRequest?.requestedReviewers).toEqual([]); await act(async () => reviewRequest?.reRequest("octocat")); expect(reviewMocks.requestReviewers).toHaveBeenNthCalledWith( 1, "o", "r", 1, ["octocat"], "workspace-a", ); expect(reviewMocks.requestReviewers).toHaveBeenNthCalledWith( 2, "o", "r", 1, ["octocat"], "workspace-b", ); view.rerender( createElement(ReviewRequestHarness, { onReady, workspaceId: "workspace-b", requestedReviewers: [{ login: "octocat", type: "user" }], }), ); view.rerender(createElement(ReviewRequestHarness, { onReady, workspaceId: "workspace-a" })); expect(reviewRequest?.requestedReviewers).toEqual([{ login: "octocat", type: "user" }]); }); }); describe("PRDetailContent persistent request expiry", () => { it("keeps an unresolved request locked beyond the optimistic expiry window", () => { vi.useFakeTimers(); feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); const deferred = new Promise(() => undefined); reviewMocks.requestReviewers.mockReturnValue(deferred); const view = renderPRDetail(); fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); act(() => vi.advanceTimersByTime(5 * 60 * 1000)); view.rerender(prDetailTree()); expect(screen.getByTestId(RE_REQUEST_BUTTON).hasAttribute("disabled")).toBe(true); vi.useRealTimers(); }); it("expires successful optimism and updates the panel without a rerender", async () => { vi.useFakeTimers(); feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); renderPRDetail(); await act(async () => fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON))); act(() => vi.advanceTimersByTime(5 * 60 * 1000)); expect(screen.getByTestId(RE_REQUEST_BUTTON).hasAttribute("disabled")).toBe(false); vi.useRealTimers(); }); it("clears the expiry timer when feedback confirms the request", async () => { vi.useFakeTimers(); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); let reviewRequest: ReturnType | undefined; const onReady = (value: ReturnType) => { reviewRequest = value; }; const view = render( createElement(ReviewRequestHarness, { onReady, reviews: [dismissedReview()], }), ); const timersBeforeRequest = vi.getTimerCount(); await act(async () => reviewRequest?.reRequest("octocat")); expect(vi.getTimerCount()).toBe(timersBeforeRequest + 1); view.rerender( createElement(ReviewRequestHarness, { onReady, requestedReviewers: [{ login: "octocat", type: "user" }], }), ); await act(async () => undefined); expect(vi.getTimerCount()).toBe(timersBeforeRequest); vi.useRealTimers(); }); it("reschedules to the next optimistic expiry after the first expires", async () => { vi.useFakeTimers(); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); let reviewRequest: ReturnType | undefined; render( createElement(ReviewRequestHarness, { onReady: (value) => { reviewRequest = value; }, }), ); await act(async () => reviewRequest?.reRequest("octocat")); act(() => vi.advanceTimersByTime(60 * 1000)); await act(async () => reviewRequest?.reRequest("hubot")); act(() => vi.advanceTimersByTime(4 * 60 * 1000)); expect(reviewRequest?.requestedReviewers).toEqual([{ login: "hubot", type: "user" }]); vi.useRealTimers(); }); }); describe("PRDetailContent request operation ownership", () => { it("does not let an old completion change a newer request", async () => { let resolveFirst: () => void = () => undefined; const firstRequest = new Promise((resolve) => { resolveFirst = resolve; }); const secondRequest = new Promise(() => undefined); reviewMocks.requestReviewers .mockReturnValueOnce(firstRequest) .mockReturnValueOnce(secondRequest); let reviewRequest: ReturnType | undefined; const onReady = (value: ReturnType) => { reviewRequest = value; }; const view = render(createElement(ReviewRequestHarness, { onReady })); act(() => void reviewRequest?.reRequest("octocat")); view.rerender( createElement(ReviewRequestHarness, { onReady, requestedReviewers: [{ login: "octocat", type: "user" }], }), ); view.rerender(createElement(ReviewRequestHarness, { onReady })); act(() => void reviewRequest?.reRequest("octocat")); await act(async () => resolveFirst()); expect(reviewRequest?.requestingReviewers).toEqual(["octocat"]); }); }); describe("PRDetailContent failed feedback refresh", () => { it("keeps the successful re-request optimistic", async () => { feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); feedbackMocks.refresh.mockImplementation(() => { throw new Error("Feedback refresh failed"); }); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); renderPRDetail(); await act(async () => { fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); }); expect(screen.getByTestId(PENDING_REVIEWER)).not.toBeNull(); }); }); describe("PRDetailContent failed re-request", () => { it("clears failed writes so the dismissed reviewer can be retried", async () => { feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); reviewMocks.requestReviewers.mockRejectedValue(new Error("GitHub denied request")); renderPRDetail(); await act(async () => { fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); }); await waitFor(() => { expect(screen.getByTestId(RE_REQUEST_BUTTON).hasAttribute("disabled")).toBe(false); }); await act(async () => { fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); }); expect(reviewMocks.requestReviewers).toHaveBeenCalledTimes(2); }); }); describe("PRDetailContent PR identity changes", () => { it("does not retain optimistic reviewers after the repository PR identity changes", async () => { feedbackMocks.value = makeFeedback({ reviews: [dismissedReview()] }); reviewMocks.requestReviewers.mockResolvedValue({ requested: true }); const view = renderPRDetail(); await act(async () => { fireEvent.click(screen.getByTestId(RE_REQUEST_BUTTON)); }); await waitFor(() => { expect(screen.getByTestId(PENDING_REVIEWER)).not.toBeNull(); }); view.rerender(prDetailTree(makeTaskPR({ pr_number: 2 }))); expect(screen.getByTestId(RE_REQUEST_BUTTON)).not.toBeNull(); }); });