1import { AsyncResult } from "effect/reactivity";
2import * as Cause from "effect/Cause";
3import { EnvironmentId, ThreadId } from "@t3tools/contracts";
4import { act } from "react";
5import { create, type ReactTestRenderer } from "react-test-renderer";
6import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test";
7
8const state = vi.hoisted(() => ({
9 listError: null as string | null,
10 refresh: vi.fn(),
11 available: true,
12 canOperate: true,
13 thread: null as { settledOverride: "settled" | null; archivedAt: string | null } | null,
14 preview: {
15 listLoaded: false,
16 sessions: {} as Record<string, { tabId: string; navStatus: { _tag: "Success"; url: string } }>,
17 recentlySeenUrls: [] as string[],
18 },
19 close: vi.fn(),
20 onReady: undefined as ((tabId: string) => boolean) | undefined,
21 open: vi.fn(),
22}));
23vi.mock("~/browser/previewRuntime", () => ({ usePreviewAvailable: () => state.available }));
24vi.mock("~/previewStateStore", () => ({
25 useThreadPreviewState: () => state.preview,
26 readThreadPreviewState: () => state.preview,
27}));
28vi.mock("~/state/entities", () => ({ useThreadShell: () => state.thread }));
29vi.mock("~/state/session", () => ({ useEnvironmentScope: () => state.canOperate }));
30vi.mock("~/state/preview", () => ({
31 previewEnvironment: { open: {}, close: {}, list: () => ({}) },
32}));
33vi.mock("~/state/query", () => ({
34 useEnvironmentQuery: () => ({ error: state.listError, refresh: state.refresh }),
35}));
36vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() }));
37vi.mock("../preview/usePreviewSession", () => ({ usePreviewSession: () => {} }));
38vi.mock("../preview/closePreviewSession", () => ({ closePreviewSession: state.close }));
39vi.mock("../preview/openPreviewSession", () => ({ openPreviewSession: state.open }));
40vi.mock("../preview/PreviewPanel", () => ({
41 PreviewPanel: ({ tabId }: { tabId: string }) => <div data-browser-tab={tabId} />,
42}));
43vi.mock("../ui/button", () => ({ Button: "button" }));
44import { MergebotBrowserPanel } from "./MergebotBrowserPanel";
45
46const threadRef = {
47 environmentId: EnvironmentId.make("environment"),
48 threadId: ThreadId.make("thread"),
49};
50const url = "https://mergebot.example/Work/App/pull/12";
51let renderer: ReactTestRenderer | null = null;
52const render = async (owner = threadRef) => {
53 await act(() => {
54 if (renderer)
55 renderer.update(<MergebotBrowserPanel threadRef={owner} url={url} onReady={state.onReady} />);
56 else
57 renderer = create(
58 <MergebotBrowserPanel threadRef={owner} url={url} onReady={state.onReady} />,
59 );
60 });
61};
62beforeEach(() => {
63 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
64 state.onReady = undefined;
65 state.close.mockReset().mockResolvedValue(AsyncResult.success(undefined));
66 state.listError = null;
67 state.refresh.mockReset();
68 state.available = true;
69 state.canOperate = true;
70 state.thread = { settledOverride: null, archivedAt: null };
71 state.preview = { listLoaded: false, sessions: {}, recentlySeenUrls: [] };
72 state.open.mockReset().mockImplementation(async () => {
73 state.preview.sessions.opened = { tabId: "opened", navStatus: { _tag: "Success", url } };
74 state.preview.recentlySeenUrls.push(url);
75 return AsyncResult.success({ tabId: "opened" });
76 });
77});
78afterEach(async () => {
79 await act(() => renderer?.unmount());
80 renderer = null;
81 vi.unstubAllGlobals();
82});
83
84it("waits for the authoritative tab list before opening the owned Mergebot view", async () => {
85 await render();
86 expect(state.open).not.toHaveBeenCalled();
87 state.preview.listLoaded = true;
88 await render();
89 expect(state.open).toHaveBeenCalledExactlyOnceWith(expect.objectContaining({ threadRef, url }));
90 expect(renderer!.root.findByProps({ "data-browser-tab": "opened" })).toBeDefined();
91});
92it("reuses an existing URL after remount without opening duplicate tabs", async () => {
93 state.preview.listLoaded = true;
94 state.preview.sessions.existing = { tabId: "existing", navStatus: { _tag: "Success", url } };
95 await render();
96 await act(() => renderer?.unmount());
97 renderer = null;
98 await render();
99 expect(state.open).not.toHaveBeenCalled();
100 expect(renderer!.root.findByProps({ "data-browser-tab": "existing" })).toBeDefined();
101});
102it("does not reopen tabs closed by Settle, including after remount", async () => {
103 state.preview.listLoaded = true;
104 await render();
105 state.preview.sessions = {};
106 state.thread = { settledOverride: "settled", archivedAt: null };
107 await render();
108 await act(() => renderer?.unmount());
109 renderer = null;
110 await render();
111 expect(state.open).toHaveBeenCalledTimes(1);
112});
113it("keeps a settled chat closed until the reader explicitly opens Mergebot", async () => {
114 state.preview.listLoaded = true;
115 state.thread = { settledOverride: "settled", archivedAt: null };
116 await render();
117 expect(state.open).not.toHaveBeenCalled();
118 await act(() => renderer!.root.findByType("button").props.onClick());
119 expect(state.open).toHaveBeenCalledOnce();
120});
121it("shows a retryable failure without opening externally", async () => {
122 state.preview.listLoaded = true;
123 state.open.mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("offline"))));
124 await render();
125 expect(renderer!.root.findByProps({ role: "alert" }).children).toContain("offline");
126 await act(() => renderer!.root.findByType("button").props.onClick());
127 expect(state.open).toHaveBeenCalledTimes(2);
128});
129it("does not request a browser when permission or support is unavailable", async () => {
130 state.preview.listLoaded = true;
131 state.canOperate = false;
132 await render();
133 expect(state.open).not.toHaveBeenCalled();
134 expect(renderer!.root.findAllByType("button")).toHaveLength(0);
135});
136
137it("retries a failed authoritative list instead of opening duplicate unlisted tabs", async () => {
138 state.listError = "Connection unavailable";
139 await render();
140 expect(renderer!.root.findByProps({ role: "alert" }).children).toContain(
141 "Connection unavailable",
142 );
143 await act(() => renderer!.root.findByType("button").props.onClick());
144 expect(state.refresh).toHaveBeenCalledOnce();
145 expect(state.open).not.toHaveBeenCalled();
146});
147
148it("waits for a real owner’s lifecycle state before automatically opening tabs", async () => {
149 state.thread = null;
150 state.preview.listLoaded = true;
151 await render();
152 expect(state.open).not.toHaveBeenCalled();
153 state.thread = { settledOverride: null, archivedAt: null };
154 await render();
155 expect(state.open).toHaveBeenCalledOnce();
156});
157
158it("closes a newly created tab when its PR surface was closed while opening", async () => {
159 state.preview.listLoaded = true;
160 let finish!: (value: unknown) => void;
161 state.open.mockReturnValue(
162 new Promise((resolve) => {
163 finish = resolve;
164 }),
165 );
166 const ready = vi.fn(() => true);
167 state.onReady = ready;
168 await render();
169 ready.mockReturnValue(false);
170 await act(async () => {
171 finish(AsyncResult.success({ tabId: "late" }));
172 });
173 expect(state.close).toHaveBeenCalledWith(
174 expect.objectContaining({ threadRef, tabId: "late", snapshot: { tabId: "late" } }),
175 );
176});
177it("hands an existing tab to the browser surface without reopening or closing it", async () => {
178 state.preview.listLoaded = true;
179 state.preview.sessions.existing = { tabId: "existing", navStatus: { _tag: "Success", url } };
180 const ready = vi.fn(() => true);
181 state.onReady = ready;
182 await render();
183 expect(ready).toHaveBeenCalledWith("existing");
184 expect(state.open).not.toHaveBeenCalled();
185 expect(state.close).not.toHaveBeenCalled();
186});
187
188it("does not close a browser already handed off by its published snapshot", async () => {
189 state.preview.listLoaded = true;
190 let finish!: (value: unknown) => void;
191 state.open.mockReturnValue(
192 new Promise((resolve) => {
193 finish = resolve;
194 }),
195 );
196 let placeholderPresent = true;
197 const ready = vi.fn(() => {
198 if (!placeholderPresent) return false;
199 placeholderPresent = false;
200 return true;
201 });
202 state.onReady = ready;
203 await render();
204 state.preview.sessions.published = {
205 tabId: "published",
206 navStatus: { _tag: "Success", url },
207 };
208 await render();
209 expect(ready).toHaveBeenCalledTimes(1);
210 expect(placeholderPresent).toBe(false);
211 await act(async () => {
212 finish(AsyncResult.success({ tabId: "published" }));
213 });
214 expect(ready).toHaveBeenCalledTimes(1);
215 expect(state.close).not.toHaveBeenCalled();
216});
217
218it("reopens a previously closed URL for a fresh explicit chat PR placeholder", async () => {
219 state.preview.listLoaded = true;
220 state.preview.recentlySeenUrls = [url];
221 state.onReady = vi.fn(() => true);
222 await render();
223 expect(state.open).toHaveBeenCalledOnce();
224});
225
226it("does not reopen a settled chat's URL for a fresh PR placeholder", async () => {
227 state.preview.listLoaded = true;
228 state.preview.recentlySeenUrls = [url];
229 state.thread = { settledOverride: "settled", archivedAt: null };
230 state.onReady = vi.fn(() => true);
231 await render();
232 expect(state.open).not.toHaveBeenCalled();
233});
234
235it("does not reopen an archived chat's URL for a fresh PR placeholder", async () => {
236 state.preview.listLoaded = true;
237 state.preview.recentlySeenUrls = [url];
238 state.thread = { settledOverride: null, archivedAt: "2026-10-14T10:00:00Z" };
239 state.onReady = vi.fn(() => true);
240 await render();
241 expect(state.open).not.toHaveBeenCalled();
242});
243
244it("keeps a closed standalone PR embed closed on remount", async () => {
245 state.thread = null;
246 state.preview.listLoaded = true;
247 state.preview.recentlySeenUrls = [url];
248 state.onReady = vi.fn(() => true);
249 const owner = { ...threadRef, threadId: ThreadId.make("mergebot-browser:work/app:12") };
250 await render(owner);
251 await act(() => renderer?.unmount());
252 renderer = null;
253 await render(owner);
254 expect(state.open).not.toHaveBeenCalled();
255});