diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx
new file mode 100644
index 0000000000..91bef2c8bb
--- /dev/null
+++ b/apps/web/modules/shell/user-dropdown/UserDropdown.test.tsx
@@ -0,0 +1,250 @@
+import { render, waitFor } from "@testing-library/react";
+import React from "react";
+import { vi, describe, it, expect, beforeEach, afterEach } from "vitest";
+
+vi.mock("next-auth/react", () => ({
+ signOut: vi.fn(),
+}));
+
+vi.mock("next/navigation", () => ({
+ usePathname: () => "/settings",
+}));
+
+vi.mock("@calcom/lib/hooks/useLocale", () => ({
+ useLocale: () => ({
+ t: (key: string) => key,
+ }),
+}));
+
+const mockUseMeQuery = vi.fn();
+vi.mock("@calcom/trpc/react/hooks/useMeQuery", () => ({
+ default: () => mockUseMeQuery(),
+}));
+
+vi.mock("@calcom/web/components/settings/platform/hooks/useGetUserAttributes", () => ({
+ useGetUserAttributes: () => ({
+ isPlatformUser: false,
+ }),
+}));
+
+vi.mock("@calcom/web/modules/ee/support/lib/freshchat/FreshChatProvider", () => ({
+ default: ({ children }: { children: React.ReactNode }) =>
{children}
,
+}));
+
+vi.mock("@calcom/ui/components/avatar", () => ({
+ Avatar: () => Avatar
,
+}));
+
+vi.mock("@calcom/ui/components/icon", () => ({
+ Icon: ({ name }: { name: string }) => {name},
+}));
+
+vi.mock("@calcom/ui/components/dropdown", () => ({
+ Dropdown: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownItem: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuContent: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuItem: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuPortal: ({ children }: { children: React.ReactNode }) => {children}
,
+ DropdownMenuSeparator: () =>
,
+ DropdownMenuTrigger: ({ children }: { children: React.ReactNode }) => {children}
,
+}));
+
+vi.mock("@calcom/ui/classNames", () => ({
+ default: (...args: string[]) => args.filter(Boolean).join(" "),
+}));
+
+describe("UserDropdown", () => {
+ let mockBeacon: ReturnType;
+ let mockSupportOpen: ReturnType;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ mockBeacon = vi.fn();
+ mockSupportOpen = vi.fn();
+
+ Object.defineProperty(window, "Beacon", {
+ value: mockBeacon,
+ writable: true,
+ configurable: true,
+ });
+
+ Object.defineProperty(window, "Support", {
+ value: { open: mockSupportOpen, shouldShowTriggerButton: vi.fn() },
+ writable: true,
+ configurable: true,
+ });
+
+ Object.defineProperty(window, "screen", {
+ value: { width: 1920, height: 1080 },
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ delete window.Beacon;
+ delete window.Support;
+ });
+
+ describe("Beacon session-data functionality", () => {
+ it("should call Beacon with session-data when Beacon is available and user has username", async () => {
+ mockUseMeQuery.mockReturnValue({
+ data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null },
+ isPending: false,
+ });
+
+ const { UserDropdown } = await import("./UserDropdown");
+ render();
+
+ await waitFor(() => {
+ expect(mockBeacon).toHaveBeenCalledWith("session-data", {
+ username: "testuser",
+ screenResolution: "1920x1080",
+ });
+ });
+ });
+
+ it("should call Beacon with 'Unknown' username when user has no username", async () => {
+ mockUseMeQuery.mockReturnValue({
+ data: { username: null, name: "Test User", avatarUrl: null, avatar: null },
+ isPending: false,
+ });
+
+ const { UserDropdown } = await import("./UserDropdown");
+ render();
+
+ await waitFor(() => {
+ expect(mockBeacon).toHaveBeenCalledWith("session-data", {
+ username: "Unknown",
+ screenResolution: "1920x1080",
+ });
+ });
+ });
+
+ it("should not throw error when Beacon is undefined", async () => {
+ delete window.Beacon;
+
+ mockUseMeQuery.mockReturnValue({
+ data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null },
+ isPending: false,
+ });
+
+ const { UserDropdown } = await import("./UserDropdown");
+
+ // Should not throw
+ expect(() => render()).not.toThrow();
+ });
+
+ it("should call Beacon when it loads lazily after mount", async () => {
+ // Start with Beacon undefined (simulating lazy load)
+ delete window.Beacon;
+
+ mockUseMeQuery.mockReturnValue({
+ data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null },
+ isPending: false,
+ });
+
+ const { UserDropdown } = await import("./UserDropdown");
+ render();
+
+ // Beacon should not have been called yet
+ expect(mockBeacon).not.toHaveBeenCalled();
+
+ // Simulate Beacon loading after mount
+ Object.defineProperty(window, "Beacon", {
+ value: mockBeacon,
+ writable: true,
+ configurable: true,
+ });
+
+ // Wait for the polling interval to detect Beacon
+ await waitFor(
+ () => {
+ expect(mockBeacon).toHaveBeenCalledWith("session-data", {
+ username: "testuser",
+ screenResolution: "1920x1080",
+ });
+ },
+ { timeout: 2000 }
+ );
+ });
+
+ it("should update Beacon session-data when username changes", async () => {
+ const { rerender } = render();
+
+ // First render with initial username
+ mockUseMeQuery.mockReturnValue({
+ data: { username: "user1", name: "User One", avatarUrl: null, avatar: null },
+ isPending: false,
+ });
+
+ const { UserDropdown } = await import("./UserDropdown");
+ const { rerender: rerenderComponent } = render();
+
+ await waitFor(() => {
+ expect(mockBeacon).toHaveBeenCalledWith("session-data", {
+ username: "user1",
+ screenResolution: "1920x1080",
+ });
+ });
+
+ // Clear mock to track new calls
+ mockBeacon.mockClear();
+
+ // Update username
+ mockUseMeQuery.mockReturnValue({
+ data: { username: "user2", name: "User Two", avatarUrl: null, avatar: null },
+ isPending: false,
+ });
+
+ rerenderComponent();
+
+ await waitFor(() => {
+ expect(mockBeacon).toHaveBeenCalledWith("session-data", {
+ username: "user2",
+ screenResolution: "1920x1080",
+ });
+ });
+ });
+ });
+
+ describe("Component rendering", () => {
+ it("should return null when user is not available and not pending", async () => {
+ mockUseMeQuery.mockReturnValue({
+ data: null,
+ isPending: false,
+ });
+
+ const { UserDropdown } = await import("./UserDropdown");
+ const { container } = render();
+
+ // Component should return null
+ expect(container.firstChild).toBeNull();
+ });
+
+ it("should render dropdown when user is available", async () => {
+ mockUseMeQuery.mockReturnValue({
+ data: { username: "testuser", name: "Test User", avatarUrl: null, avatar: null },
+ isPending: false,
+ });
+
+ const { UserDropdown } = await import("./UserDropdown");
+ const { getByTestId } = render();
+
+ expect(getByTestId("dropdown")).toBeInTheDocument();
+ });
+
+ it("should render dropdown when isPending is true (loading state)", async () => {
+ mockUseMeQuery.mockReturnValue({
+ data: null,
+ isPending: true,
+ });
+
+ const { UserDropdown } = await import("./UserDropdown");
+ const { getByTestId } = render();
+
+ expect(getByTestId("dropdown")).toBeInTheDocument();
+ });
+ });
+});
diff --git a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx
index de6fd2d062..65f509db6a 100644
--- a/apps/web/modules/shell/user-dropdown/UserDropdown.tsx
+++ b/apps/web/modules/shell/user-dropdown/UserDropdown.tsx
@@ -28,9 +28,17 @@ declare global {
open: () => void;
shouldShowTriggerButton: (showTrigger: boolean) => void;
};
+ Beacon?: BeaconFunction;
}
}
+// NOTE: This interface only includes types for commands we currently use.
+type BeaconFunction = {
+ (command: "session-data", data: Record): void;
+ // Catch-all for other methods - add explicit types above if using new commands
+ (...args: unknown[]): void;
+};
+
interface UserDropdownProps {
small?: boolean;
}
@@ -43,16 +51,31 @@ export function UserDropdown({ small }: UserDropdownProps) {
const isPlatformPages = pathname?.startsWith("/settings/platform");
useEffect(() => {
- // eslint-disable-next-line @typescript-eslint/ban-ts-comment
- //@ts-ignore
- const Beacon = window.Beacon;
- // window.Beacon is defined when user actually opens up HelpScout and username is available here. On every re-render update session info, so that it is always latest.
- Beacon &&
- Beacon("session-data", {
- username: user?.username || "Unknown",
- screenResolution: `${screen.width}x${screen.height}`,
- });
- });
+ if (typeof window === "undefined") return;
+
+ const sendSessionData = () => {
+ const Beacon = window.Beacon;
+ if (Beacon) {
+ Beacon("session-data", {
+ username: user?.username || "Unknown",
+ screenResolution: `${screen.width}x${screen.height}`,
+ });
+ return true;
+ }
+ return false;
+ };
+
+ // Try immediately, then poll if Beacon isn't loaded yet
+ if (!sendSessionData()) {
+ const intervalId = setInterval(() => {
+ if (sendSessionData()) {
+ clearInterval(intervalId);
+ }
+ }, 1000);
+
+ return () => clearInterval(intervalId);
+ }
+ }, [user?.username]);
const [menuOpen, setMenuOpen] = useState(false);
const [openSupportAfterClose, setOpenSupportAfterClose] = useState(false);
@@ -66,6 +89,7 @@ export function UserDropdown({ small }: UserDropdownProps) {
};
useEffect(() => {
+ if (typeof window === "undefined") return;
if (!menuOpen && openSupportAfterClose) {
setTimeout(() => {
window.Support?.open();