// @vitest-environment jsdom import { act } from "react"; import { createRoot } from "react-dom/client"; import type { AnchorHTMLAttributes, ReactElement, ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { MemoryRouter } from "react-router-dom"; import type { IssueRetryNowOutcome, IssueScheduledRetry } from "@paperclipai/shared"; import { IssueBlockedNotice } from "./IssueBlockedNotice"; import { ToastProvider } from "../context/ToastContext"; const retryNowMock = vi.hoisted(() => vi.fn()); vi.mock("@/lib/router", () => ({ Link: ({ children, to, ...props }: AnchorHTMLAttributes & { to: string }) => ( {children} ), })); vi.mock("../api/issues", () => ({ issuesApi: { retryScheduledRetryNow: retryNowMock, }, })); // eslint-disable-next-line @typescript-eslint/no-explicit-any (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; let root: ReturnType | null = null; let container: HTMLDivElement | null = null; let dateNowSpy: ReturnType | null = null; const SYSTEM_NOW = new Date("2026-04-18T20:00:00.000Z").getTime(); const baseRetry: IssueScheduledRetry = { runId: "retry-run-1", status: "scheduled_retry", agentId: "agent-1", agentName: "CodexCoder", retryOfRunId: "source-run-1", scheduledRetryAt: "2026-04-19T20:00:00.000Z", scheduledRetryAttempt: 1, scheduledRetryReason: "max_turns_continuation", retryExhaustedReason: null, error: null, errorCode: null, }; function buildRetryResponse(outcome: IssueRetryNowOutcome) { return { outcome, message: outcome === "promoted" ? "Promoted scheduled retry" : outcome === "already_promoted" ? "Scheduled retry already promoted" : outcome === "no_scheduled_retry" ? "No scheduled retry" : "Promotion suppressed by gate", scheduledRetry: outcome === "promoted" || outcome === "already_promoted" ? { ...baseRetry, status: "queued" as const } : null, }; } beforeEach(() => { dateNowSpy = vi.spyOn(Date, "now").mockReturnValue(SYSTEM_NOW); retryNowMock.mockReset(); }); afterEach(() => { if (root) { act(() => root?.unmount()); } root = null; container?.remove(); container = null; dateNowSpy?.mockRestore(); dateNowSpy = null; }); function withProviders(node: ReactNode) { const client = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0, staleTime: 0 }, mutations: { retry: false }, }, }); return ( {node} ); } function render(element: ReactElement) { container = document.createElement("div"); document.body.appendChild(container); root = createRoot(container); act(() => root?.render(withProviders(element))); return container; } describe("IssueBlockedNotice", () => { it("renders a successful-run next-step notice without requiring blockers", () => { const node = render( , ); expect(node.textContent).toContain("This issue still needs a next step."); expect(node.textContent).toContain("Corrective wake queued for CodexCoder"); expect(node.textContent).toContain("Detected progress: Updated the plan"); expect(node.textContent).not.toContain("Retry now"); expect(node.textContent).not.toContain("Work on this issue is blocked until"); expect(node.querySelector('[data-successful-run-handoff="required"]')).not.toBeNull(); }); it("shows retry-now action for next-step notices with a scheduled retry", async () => { retryNowMock.mockResolvedValue(buildRetryResponse("promoted")); const node = render( , ); expect(node.textContent).toContain("Corrective wake scheduled in 1d"); const button = node.querySelector('[data-testid="issue-next-step-retry-now"]'); expect(button).not.toBeNull(); expect(button!.textContent ?? "").toContain("Retry now"); await act(async () => { button!.click(); await Promise.resolve(); }); await vi.waitFor(() => { expect(retryNowMock).toHaveBeenCalledWith("issue-1"); expect(button!.textContent ?? "").toContain("Promoted"); expect(button!.disabled).toBe(true); }); }); it("does not render when the issue is done even if a stale handoff state is required", () => { const node = render( , ); expect(node.textContent).toBe(""); }); it("does not render when the issue is cancelled even if blockers remain", () => { const node = render( , ); expect(node.textContent).toBe(""); }); it("renders a recovery indicator on a blocker chip when the blocker has an active recovery action", () => { const node = render( , ); const indicator = node.querySelector( '[data-testid="issue-blocked-notice-recovery-indicator"]', ); expect(indicator).not.toBeNull(); expect(indicator?.getAttribute("data-recovery-state")).toBe("needed"); expect(indicator?.textContent).toContain("Recovery needed"); }); });