1import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts";
2import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts";
3import { renderToStaticMarkup } from "react-dom/server";
4import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";
5import { describe, expect, it } from "vite-plus/test";
6
7import {
8 RightPanelTabs,
9 resolvePullRequestTabLink,
10 shouldOpenDefaultBrowserProfileFromMenuClick,
11 surfaceShortcutActionForKey,
12 surfaceShortcutTargetsTypingContext,
13 tabMuteMenuItem,
14} from "./RightPanelTabs";
15
16describe("browser profile submenu", () => {
17 it("reserves touch clicks for opening the choices while mouse clicks use the default", () => {
18 expect(shouldOpenDefaultBrowserProfileFromMenuClick("touch")).toBe(false);
19 expect(shouldOpenDefaultBrowserProfileFromMenuClick("mouse")).toBe(true);
20 expect(shouldOpenDefaultBrowserProfileFromMenuClick(undefined)).toBe(true);
21 });
22});
23
24function shortcutEvent(
25 key: string,
26 overrides: Partial<Parameters<typeof surfaceShortcutActionForKey>[1]> = {},
27): Parameters<typeof surfaceShortcutActionForKey>[1] {
28 return {
29 key,
30 altKey: false,
31 ctrlKey: false,
32 defaultPrevented: false,
33 isComposing: false,
34 metaKey: false,
35 ...overrides,
36 };
37}
38
39const previewSurface = {
40 id: "browser:tab-1" as const,
41 kind: "preview" as const,
42 resourceId: "tab-1",
43};
44const secondSurface = {
45 id: "browser:tab-2" as const,
46 kind: "preview" as const,
47 resourceId: "tab-2",
48};
49const sessions: Readonly<Record<string, PreviewSessionSnapshot>> = {
50 "tab-1": {
51 threadId: "thread-1",
52 tabId: "tab-1",
53 navStatus: { _tag: "Success", url: "http://24x.xf.local/", title: "Local site" },
54 canGoBack: false,
55 canGoForward: false,
56 updatedAt: "2026-08-09T00:00:00.000Z",
57 },
58 "tab-2": {
59 threadId: "thread-1",
60 tabId: "tab-2",
61 navStatus: { _tag: "Success", url: "http://24x.xf.local/admin", title: "Admin" },
62 canGoBack: false,
63 canGoForward: false,
64 updatedAt: "2026-08-09T00:00:00.000Z",
65 },
66};
67
68const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({
69 dataUrl,
70 pageUrl,
71 capturedAt: 1,
72});
73
74function overlay(
75 icon: DesktopPreviewFavicon | null,
76 audio?: { audible?: boolean; audioMuted?: boolean },
77) {
78 return {
79 hasWebContents: true,
80 canGoBack: false,
81 canGoForward: false,
82 loading: false,
83 zoomFactor: 1,
84 pictureInPicture: false,
85 colorScheme: "system" as const,
86 audioMuted: audio?.audioMuted ?? false,
87 audible: audio?.audible ?? false,
88 controller: "none" as const,
89 favicon: icon,
90 };
91}
92
93function renderTabs(
94 first: DesktopPreviewFavicon | null,
95 second?: DesktopPreviewFavicon,
96 audio?: { audible?: boolean; audioMuted?: boolean },
97 previewRuntimeTabId: ((tabId: string) => string) | null = (tabId) => `runtime:${tabId}`,
98) {
99 return renderToStaticMarkup(
100 <RightPanelTabs
101 mode="inline"
102 keybindings={DEFAULT_RESOLVED_KEYBINDINGS}
103 getShortcutContext={() => ({
104 terminalFocus: false,
105 terminalOpen: false,
106 previewFocus: false,
107 previewOpen: false,
108 isWeb: true,
109 isDesktop: false,
110 })}
111 surfaces={second ? [previewSurface, secondSurface] : [previewSurface]}
112 environmentId={null}
113 activeSurfaceId={previewSurface.id}
114 pendingSurfaceIds={new Set()}
115 previewSessions={sessions}
116 desktopByTabId={{
117 "tab-1": overlay(first, audio),
118 ...(second ? { "tab-2": overlay(second) } : {}),
119 }}
120 {...(previewRuntimeTabId ? { previewRuntimeTabId } : {})}
121 terminalLabelsById={new Map()}
122 onActivate={() => undefined}
123 onCloseSurface={() => undefined}
124 onCloseOtherSurfaces={() => undefined}
125 onCloseSurfacesToRight={() => undefined}
126 onCloseAllSurfaces={() => undefined}
127 onCopyFilePath={() => undefined}
128 onAddBrowser={() => undefined}
129 onAddBrowserInProfile={() => undefined}
130 onAddTerminal={() => undefined}
131 onAddPullRequest={() => undefined}
132 onAddPullRequests={() => undefined}
133 onAddDiff={() => undefined}
134 onAddFiles={() => undefined}
135 onAddDevice={() => undefined}
136 browserAvailable
137 terminalAvailable={false}
138 diffAvailable={false}
139 filesAvailable={false}
140 pullRequestAvailable={false}
141 pullRequestsAvailable={false}
142 deviceAvailable={false}
143 >
144 <div>content</div>
145 </RightPanelTabs>,
146 );
147}
148
149describe("RightPanelTabs preview favicon", () => {
150 it("prefers a live capture and never asks Google about a private hostname", () => {
151 const captured = renderTabs(favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"));
152 expect(captured).toContain("data:image/png;base64,AAAA");
153 expect(captured).not.toContain("s2/favicons");
154 expect(renderTabs(null)).not.toContain("s2/favicons");
155 });
156
157 it("keeps route-specific captures isolated between live tabs on one origin", () => {
158 const html = renderTabs(
159 favicon("data:image/png;base64,AAAA", "http://24x.xf.local/"),
160 favicon("data:image/png;base64,BBBB", "http://24x.xf.local/admin"),
161 );
162 expect(html).toContain("data:image/png;base64,AAAA");
163 expect(html).toContain("data:image/png;base64,BBBB");
164 });
165
166 it("hides a capture while the server session still describes another origin", () => {
167 const html = renderTabs(favicon("data:image/png;base64,AAAA", "https://example.com/"));
168 expect(html).not.toContain("data:image/png;base64,AAAA");
169 });
170});
171
172describe("surface shortcuts", () => {
173 const actions = [
174 { shortcut: "B", available: true, label: "Browser" },
175 { shortcut: "D", available: false, label: "Diff" },
176 ] as const;
177
178 it("matches available surface shortcuts case-insensitively", () => {
179 expect(surfaceShortcutActionForKey(actions, shortcutEvent("b"))).toBe(actions[0]);
180 expect(surfaceShortcutActionForKey(actions, shortcutEvent("B"))).toBe(actions[0]);
181 });
182
183 it("does not activate unavailable surfaces", () => {
184 expect(surfaceShortcutActionForKey(actions, shortcutEvent("d"))).toBeNull();
185 });
186
187 it("leaves modified, composing, and already-handled key events alone", () => {
188 expect(surfaceShortcutActionForKey(actions, shortcutEvent("b", { metaKey: true }))).toBeNull();
189 expect(
190 surfaceShortcutActionForKey(actions, shortcutEvent("b", { isComposing: true })),
191 ).toBeNull();
192 expect(
193 surfaceShortcutActionForKey(actions, shortcutEvent("b", { defaultPrevented: true })),
194 ).toBeNull();
195 });
196});
197
198describe("surface shortcut typing contexts", () => {
199 // Selector-aware stub: closest() answers only tokens the combined selector
200 // would actually match, mirroring how the browser resolves it.
201 const makeTarget = (matches: string | null) => ({
202 closest(selectors: string) {
203 if (matches === null || !selectors.includes(matches)) return null;
204 return {};
205 },
206 });
207
208 it("treats form fields and every editable region as typing contexts", () => {
209 expect(surfaceShortcutTargetsTypingContext(makeTarget("input"))).toBe(true);
210 expect(surfaceShortcutTargetsTypingContext(makeTarget("textarea"))).toBe(true);
211 expect(surfaceShortcutTargetsTypingContext(makeTarget("select"))).toBe(true);
212 // The chat composer is a contenteditable that sits empty until a draft
213 // exists; launcher letters claimed from it redirected prompts into shells.
214 // The :not clause sees past contenteditable="false" islands to an editable
215 // host around them, so nested editors stay protected too.
216 expect(surfaceShortcutTargetsTypingContext(makeTarget("[contenteditable]"))).toBe(true);
217 });
218
219 it("claims letters when focus sits outside any editable region", () => {
220 expect(surfaceShortcutTargetsTypingContext(null)).toBe(false);
221 expect(surfaceShortcutTargetsTypingContext(makeTarget(null))).toBe(false);
222 });
223});
224
225describe("RightPanelTabs audio indicator", () => {
226 // A muted tab only shows the indicator while it is actually making sound:
227 // arming mute on a quiet tab is deliberate and stays invisible until there
228 // is something to suppress.
229 const cases = [
230 { audible: false, audioMuted: false, label: null },
231 { audible: false, audioMuted: true, label: null },
232 { audible: true, audioMuted: false, label: "Mute Local site" },
233 { audible: true, audioMuted: true, label: "Unmute Local site" },
234 ] as const;
235
236 it.each(cases)("audible=$audible muted=$audioMuted", ({ audible, audioMuted, label }) => {
237 const html = renderTabs(null, undefined, { audible, audioMuted });
238 if (label === null) {
239 expect(html).not.toContain("Mute Local site");
240 expect(html).not.toContain("Unmute Local site");
241 } else {
242 expect(html).toContain(`aria-label="${label}"`);
243 }
244 });
245
246 it("addresses the desktop by runtime tab id, never the server session id", () => {
247 // Session ids are only unique per server process; sending one to the
248 // Electron manager raises PreviewTabNotFoundError and silently no-ops.
249 const seen: string[] = [];
250 renderTabs(null, undefined, { audible: true }, (tabId) => {
251 seen.push(tabId);
252 return `runtime:${tabId}`;
253 });
254 expect(seen).toContain("tab-1");
255 });
256
257 it("hides the toggle when no runtime tab id can be resolved", () => {
258 const html = renderTabs(null, undefined, { audible: true }, null);
259 expect(html).not.toContain("Mute Local site");
260 });
261});
262
263describe("tabMuteMenuItem", () => {
264 const overlay = (audioMuted: boolean) =>
265 ({ audioMuted, audible: false }) as Parameters<typeof tabMuteMenuItem>[0]["overlay"];
266
267 it("stays disabled until the desktop tab exists", () => {
268 // The server session id resolves before the preview manager finishes
269 // createTab. Muting in that window fails with an error nobody surfaces.
270 expect(tabMuteMenuItem({ overlay: null, canResolveRuntimeTabId: true })).toEqual({
271 label: "Mute tab",
272 disabled: true,
273 });
274 });
275
276 it("stays disabled when no runtime tab id can be resolved", () => {
277 expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: false })).toEqual({
278 label: "Mute tab",
279 disabled: true,
280 });
281 });
282
283 it("offers mute and unmute once the tab is addressable", () => {
284 expect(tabMuteMenuItem({ overlay: overlay(false), canResolveRuntimeTabId: true })).toEqual({
285 label: "Mute tab",
286 disabled: false,
287 });
288 expect(tabMuteMenuItem({ overlay: overlay(true), canResolveRuntimeTabId: true })).toEqual({
289 label: "Unmute tab",
290 disabled: false,
291 });
292 });
293});
294
295describe("pull request tab snapshots", () => {
296 const environmentId = EnvironmentId.make("local");
297 const link: ThreadPullRequestLink = {
298 host: "github.com",
299 repository: "acme/api",
300 number: 7,
301 url: "https://github.com/acme/api/pull/7",
302 source: "manual",
303 linkedAt: "2026-01-01T00:00:00Z",
304 stack: null,
305 snapshot: null,
306 };
307 it("keeps unknown linked state authoritative and scopes matches to environment and host", () => {
308 const threads = [{ environmentId, pullRequests: [link] }];
309 expect(resolvePullRequestTabLink(threads, environmentId, "github.com", link)).toBe(link);
310 expect(
311 resolvePullRequestTabLink(threads, EnvironmentId.make("remote"), "github.com", link),
312 ).toBeUndefined();
313 expect(
314 resolvePullRequestTabLink(threads, environmentId, "github.enterprise.test", link),
315 ).toBeUndefined();
316 });
317 it("uses the newest snapshot when several threads link the same PR", () => {
318 const snapshot = {
319 state: "merged" as const,
320 title: "API",
321 headBranch: "api",
322 baseBranch: "main",
323 isDraft: false,
324 updatedAt: null,
325 syncedAt: "2026-02-01T00:00:00Z",
326 };
327 const newer = { ...link, snapshot };
328 expect(
329 resolvePullRequestTabLink(
330 [{ environmentId, pullRequests: [link, newer] }],
331 environmentId,
332 "github.com",
333 link,
334 ),
335 ).toBe(newer);
336 });
337});