| 1 | import { EnvironmentId, type ThreadPullRequestLink } from "@t3tools/contracts"; |
| 2 | import type { DesktopPreviewFavicon, PreviewSessionSnapshot } from "@t3tools/contracts"; |
| 3 | import { renderToStaticMarkup } from "react-dom/server"; |
| 4 | import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; |
| 5 | import { describe, expect, it } from "vite-plus/test"; |
| 6 | |
| 7 | import { |
| 8 | RightPanelTabs, |
| 9 | resolvePullRequestTabLink, |
| 10 | shouldOpenDefaultBrowserProfileFromMenuClick, |
| 11 | surfaceShortcutActionForKey, |
| 12 | surfaceShortcutTargetsTypingContext, |
| 13 | tabMuteMenuItem, |
| 14 | } from "./RightPanelTabs"; |
| 15 | |
| 16 | describe("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 | |
| 24 | function 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 | |
| 39 | const previewSurface = { |
| 40 | id: "browser:tab-1" as const, |
| 41 | kind: "preview" as const, |
| 42 | resourceId: "tab-1", |
| 43 | }; |
| 44 | const secondSurface = { |
| 45 | id: "browser:tab-2" as const, |
| 46 | kind: "preview" as const, |
| 47 | resourceId: "tab-2", |
| 48 | }; |
| 49 | const 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 | |
| 68 | const favicon = (dataUrl: string, pageUrl: string): DesktopPreviewFavicon => ({ |
| 69 | dataUrl, |
| 70 | pageUrl, |
| 71 | capturedAt: 1, |
| 72 | }); |
| 73 | |
| 74 | function 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 | |
| 93 | function 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 | |
| 149 | describe("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 | |
| 172 | describe("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 | |
| 198 | describe("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 | |
| 225 | describe("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 | |
| 263 | describe("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 | |
| 295 | describe("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 | }); |