| 1 | import { AsyncResult } from "effect/reactivity"; |
| 2 | import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; |
| 3 | |
| 4 | const state = vi.hoisted(() => ({ |
| 5 | navigate: vi.fn(), |
| 6 | dispatch: vi.fn(), |
| 7 | external: vi.fn(), |
| 8 | alert: vi.fn(), |
| 9 | open: vi.fn(), |
| 10 | list: vi.fn(), |
| 11 | serverBrowser: true, |
| 12 | allowed: true, |
| 13 | params: { environmentId: "environment", projectId: "project", threadId: "thread" } as Record< |
| 14 | string, |
| 15 | string |
| 16 | >, |
| 17 | })); |
| 18 | vi.mock("react", () => ({ |
| 19 | useCallback: (callback: unknown) => callback, |
| 20 | useRef: (current: unknown) => ({ current }), |
| 21 | })); |
| 22 | vi.mock("@react-navigation/native", () => ({ |
| 23 | useNavigation: () => ({ navigate: state.navigate, dispatch: state.dispatch }), |
| 24 | useRoute: () => ({ params: state.params }), |
| 25 | StackActions: {}, |
| 26 | CommonActions: {}, |
| 27 | })); |
| 28 | vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Alert: { alert: state.alert } })); |
| 29 | vi.mock("@effect/atom-react", () => ({ |
| 30 | useAtomValue: () => |
| 31 | new Map([ |
| 32 | [ |
| 33 | "environment", |
| 34 | { |
| 35 | mergebot: { origin: "https://mergebot.example", repositories: ["Work/App"] }, |
| 36 | environment: { capabilities: { serverBrowser: state.serverBrowser } }, |
| 37 | }, |
| 38 | ], |
| 39 | ]), |
| 40 | })); |
| 41 | vi.mock("../state/entities", () => ({ useThreadShell: () => null })); |
| 42 | vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} })); |
| 43 | vi.mock("../state/session", () => ({ readEnvironmentScope: () => state.allowed })); |
| 44 | vi.mock("../state/preview", () => ({ previewEnvironment: { open: {}, list: {} } })); |
| 45 | vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.open })); |
| 46 | vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.list })); |
| 47 | vi.mock("../lib/openExternalUrl", () => ({ tryOpenExternalUrl: state.external })); |
| 48 | import { useOpenPullRequest } from "./useOpenPullRequest"; |
| 49 | |
| 50 | beforeEach(() => { |
| 51 | state.params = { environmentId: "environment", projectId: "project", threadId: "thread" }; |
| 52 | state.serverBrowser = true; |
| 53 | state.allowed = true; |
| 54 | state.navigate.mockReset(); |
| 55 | state.dispatch.mockReset(); |
| 56 | state.alert.mockReset(); |
| 57 | state.external.mockReset(); |
| 58 | state.open.mockReset().mockResolvedValue(AsyncResult.success({ tabId: "opened" })); |
| 59 | state.list.mockReset().mockResolvedValue(AsyncResult.success({ sessions: [] })); |
| 60 | }); |
| 61 | describe("Mergebot PR navigation", () => { |
| 62 | it.each(["https://github.com/work/app/pull/12", "https://mergebot.example/work/app/pull/12"])( |
| 63 | "opens %s in the existing in-app browser with chat ownership", |
| 64 | async (url) => { |
| 65 | await expect(useOpenPullRequest()(url)).resolves.toBe(true); |
| 66 | expect(state.open).toHaveBeenCalledWith( |
| 67 | expect.objectContaining({ |
| 68 | input: expect.objectContaining({ |
| 69 | threadId: "thread", |
| 70 | runtime: "server", |
| 71 | url: "https://mergebot.example/Work/App/pull/12", |
| 72 | }), |
| 73 | }), |
| 74 | ); |
| 75 | expect(state.navigate).toHaveBeenCalledExactlyOnceWith("ThreadBrowserPreview", { |
| 76 | environmentId: "environment", |
| 77 | threadId: "thread", |
| 78 | tabId: "opened", |
| 79 | }); |
| 80 | expect(state.external).not.toHaveBeenCalled(); |
| 81 | }, |
| 82 | ); |
| 83 | it("reuses a matching server tab and supplies a stable owner without a chat", async () => { |
| 84 | delete state.params.threadId; |
| 85 | state.list.mockResolvedValue( |
| 86 | AsyncResult.success({ |
| 87 | sessions: [ |
| 88 | { |
| 89 | tabId: "existing", |
| 90 | runtime: "server", |
| 91 | navStatus: { _tag: "Success", url: "https://mergebot.example/Work/App/pull/12" }, |
| 92 | }, |
| 93 | ], |
| 94 | }), |
| 95 | ); |
| 96 | await useOpenPullRequest()("https://github.com/work/app/pull/12"); |
| 97 | expect(state.open).not.toHaveBeenCalled(); |
| 98 | expect(state.navigate).toHaveBeenCalledWith("ThreadBrowserPreview", { |
| 99 | environmentId: "environment", |
| 100 | threadId: "mergebot-browser", |
| 101 | tabId: "existing", |
| 102 | }); |
| 103 | }); |
| 104 | it("shows unavailable browser access without silently opening native work details", async () => { |
| 105 | state.serverBrowser = false; |
| 106 | await expect(useOpenPullRequest()("https://github.com/work/app/pull/12")).resolves.toBe(true); |
| 107 | expect(state.alert).toHaveBeenCalledOnce(); |
| 108 | expect(state.navigate).not.toHaveBeenCalled(); |
| 109 | expect(state.external).not.toHaveBeenCalled(); |
| 110 | }); |
| 111 | it("reports a recognized link failure once and marks it handled for callers", async () => { |
| 112 | state.open.mockRejectedValueOnce(new Error("offline")); |
| 113 | const handled = await useOpenPullRequest()("https://mergebot.example/work/app/pull/12"); |
| 114 | if (!handled) state.alert("Generic PR failure"); |
| 115 | expect(handled).toBe(true); |
| 116 | expect(state.alert).toHaveBeenCalledExactlyOnceWith("Could not open Mergebot", "offline"); |
| 117 | }); |
| 118 | it("leaves unrelated repositories on the existing native PR path", async () => { |
| 119 | await useOpenPullRequest()("https://github.com/other/repo/pull/12"); |
| 120 | expect(state.open).not.toHaveBeenCalled(); |
| 121 | expect(state.navigate).toHaveBeenCalledWith( |
| 122 | "PullRequest", |
| 123 | expect.objectContaining({ repository: "other/repo", host: "github.com", number: 12 }), |
| 124 | ); |
| 125 | }); |
| 126 | }); |