1import { AsyncResult } from "effect/reactivity";
2import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
3
4const 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}));
18vi.mock("react", () => ({
19 useCallback: (callback: unknown) => callback,
20 useRef: (current: unknown) => ({ current }),
21}));
22vi.mock("@react-navigation/native", () => ({
23 useNavigation: () => ({ navigate: state.navigate, dispatch: state.dispatch }),
24 useRoute: () => ({ params: state.params }),
25 StackActions: {},
26 CommonActions: {},
27}));
28vi.mock("react-native", () => ({ Platform: { OS: "ios" }, Alert: { alert: state.alert } }));
29vi.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}));
41vi.mock("../state/entities", () => ({ useThreadShell: () => null }));
42vi.mock("../state/server", () => ({ environmentServerConfigsAtom: {} }));
43vi.mock("../state/session", () => ({ readEnvironmentScope: () => state.allowed }));
44vi.mock("../state/preview", () => ({ previewEnvironment: { open: {}, list: {} } }));
45vi.mock("../state/use-atom-command", () => ({ useAtomCommand: () => state.open }));
46vi.mock("../state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => state.list }));
47vi.mock("../lib/openExternalUrl", () => ({ tryOpenExternalUrl: state.external }));
48import { useOpenPullRequest } from "./useOpenPullRequest";
49
50beforeEach(() => {
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});
61describe("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});