| 1 | import { AsyncResult } from "effect/reactivity"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import { act } from "react"; |
| 4 | import { create, type ReactTestRenderer } from "react-test-renderer"; |
| 5 | import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; |
| 6 | |
| 7 | const state = vi.hoisted(() => ({ |
| 8 | navigate: vi.fn(), |
| 9 | params: { environmentId: "local", threadId: "active" } as Record<string, string>, |
| 10 | canPreview: true, |
| 11 | configured: true, |
| 12 | open: vi.fn(), |
| 13 | external: vi.fn(), |
| 14 | showBrowser: vi.fn(), |
| 15 | toast: vi.fn(), |
| 16 | })); |
| 17 | vi.mock("@tanstack/react-router", () => ({ |
| 18 | useNavigate: () => state.navigate, |
| 19 | useParams: ({ select }: { select: (value: object) => unknown }) => select(state.params), |
| 20 | })); |
| 21 | vi.mock("../../state/environments", () => ({ |
| 22 | usePrimaryEnvironmentId: () => "local", |
| 23 | useEnvironment: () => ({ |
| 24 | serverConfig: { |
| 25 | mergebot: state.configured |
| 26 | ? { origin: "https://mergebot.example", repositories: ["work/app"] } |
| 27 | : undefined, |
| 28 | }, |
| 29 | }), |
| 30 | })); |
| 31 | vi.mock("../../state/session", () => ({ useEnvironmentScope: () => state.canPreview })); |
| 32 | vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); |
| 33 | vi.mock("../../state/preview", () => ({ previewEnvironment: { open: {} } })); |
| 34 | vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open })); |
| 35 | vi.mock("../../rightPanelStore", () => ({ |
| 36 | useRightPanelStore: { getState: () => ({ openBrowser: state.showBrowser }) }, |
| 37 | })); |
| 38 | vi.mock("../../previewStateStore", () => ({ readThreadPreviewState: () => ({ sessions: {} }) })); |
| 39 | vi.mock("../../localApi", () => ({ |
| 40 | readLocalApi: () => ({ shell: { openExternal: state.external } }), |
| 41 | })); |
| 42 | vi.mock("../ui/toast", () => ({ toastManager: { add: state.toast } })); |
| 43 | vi.mock("../ui/sidebar", () => ({ |
| 44 | useSidebar: () => ({ isMobile: false, setOpenMobile: vi.fn() }), |
| 45 | SidebarMenuItem: "div", |
| 46 | SidebarMenuButton: "button", |
| 47 | })); |
| 48 | vi.mock("../ui/tooltip", () => ({ |
| 49 | Tooltip: "div", |
| 50 | TooltipPopup: "aside", |
| 51 | TooltipTrigger: ({ render }: { render: unknown }) => render, |
| 52 | })); |
| 53 | |
| 54 | import { SidebarMergebotButton } from "./SidebarMergebotButton"; |
| 55 | let renderer: ReactTestRenderer; |
| 56 | async function mount() { |
| 57 | await act(() => { |
| 58 | renderer = create(<SidebarMergebotButton />); |
| 59 | }); |
| 60 | } |
| 61 | const click = () => renderer.root.findByType("button").props.onClick(); |
| 62 | beforeEach(() => { |
| 63 | vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); |
| 64 | state.params = { environmentId: "local", threadId: "active" }; |
| 65 | state.canPreview = true; |
| 66 | state.configured = true; |
| 67 | state.open.mockReset().mockResolvedValue(AsyncResult.success({ tabId: "mergebot" })); |
| 68 | state.navigate.mockReset().mockResolvedValue(undefined); |
| 69 | state.external.mockReset().mockResolvedValue(undefined); |
| 70 | state.showBrowser.mockReset(); |
| 71 | state.toast.mockReset(); |
| 72 | }); |
| 73 | afterEach(async () => { |
| 74 | await act(() => renderer?.unmount()); |
| 75 | vi.unstubAllGlobals(); |
| 76 | }); |
| 77 | |
| 78 | it("opens the live queue beside the active chat, and rapid clicks create one tab", async () => { |
| 79 | let finish!: (result: unknown) => void; |
| 80 | state.open.mockReturnValue( |
| 81 | new Promise((resolve) => { |
| 82 | finish = resolve; |
| 83 | }), |
| 84 | ); |
| 85 | await mount(); |
| 86 | await act(() => { |
| 87 | click(); |
| 88 | click(); |
| 89 | }); |
| 90 | expect(state.open).toHaveBeenCalledTimes(1); |
| 91 | expect(state.open.mock.calls[0]![0]).toMatchObject({ |
| 92 | url: "https://mergebot.example/", |
| 93 | threadRef: { environmentId: "local", threadId: "active" }, |
| 94 | }); |
| 95 | await act(async () => { |
| 96 | finish(AsyncResult.success({ tabId: "mergebot" })); |
| 97 | }); |
| 98 | expect(state.showBrowser).toHaveBeenCalledExactlyOnceWith( |
| 99 | { environmentId: "local", threadId: "active" }, |
| 100 | "mergebot", |
| 101 | ); |
| 102 | expect(state.external).not.toHaveBeenCalled(); |
| 103 | }); |
| 104 | it("opens the queue inside the PR page without an active chat", async () => { |
| 105 | state.params = {}; |
| 106 | await mount(); |
| 107 | await act(() => click()); |
| 108 | expect(state.open).not.toHaveBeenCalled(); |
| 109 | expect(state.navigate).toHaveBeenCalledExactlyOnceWith({ |
| 110 | to: "/pull-requests", |
| 111 | search: { involvement: "all", state: "open", mergebot: "local" }, |
| 112 | }); |
| 113 | expect(state.external).not.toHaveBeenCalled(); |
| 114 | }); |
| 115 | it("reports an integrated-browser failure and allows retry", async () => { |
| 116 | state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("offline")))); |
| 117 | await mount(); |
| 118 | await act(() => click()); |
| 119 | expect(state.toast).toHaveBeenCalledWith( |
| 120 | expect.objectContaining({ type: "error", description: "offline" }), |
| 121 | ); |
| 122 | expect(state.external).not.toHaveBeenCalled(); |
| 123 | await act(() => click()); |
| 124 | expect(state.open).toHaveBeenCalledTimes(2); |
| 125 | expect(state.showBrowser).toHaveBeenCalledTimes(1); |
| 126 | }); |
| 127 | |
| 128 | it("hides the shortcut when the destination has no Mergebot configuration", async () => { |
| 129 | state.configured = false; |
| 130 | await mount(); |
| 131 | expect(renderer.toJSON()).toBeNull(); |
| 132 | expect(state.open).not.toHaveBeenCalled(); |
| 133 | expect(state.external).not.toHaveBeenCalled(); |
| 134 | }); |