diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx index c85a9fb71f6..1f3dd5642ee 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/EntityUsage.test.tsx @@ -1,4 +1,4 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import * as networking from "@/components/networking"; import EntityUsage from "./EntityUsage"; @@ -500,18 +500,33 @@ describe("EntityUsage", () => { expect(screen.getAllByText("Activity Metrics")[1]).toBeInTheDocument(); }); - const selectedPanels = (container: HTMLElement) => - Array.from(container.querySelectorAll("div.tremor-TabPanel-root")).filter( - (panel) => panel.getAttribute("aria-selected") === "true", - ); + // An inactive tab panel is marked aria-selected="false" by one tab library and hidden by the + // other, so treat either as "not on screen" and the assertion holds whichever one is rendering. + const isShowing = (element: HTMLElement): boolean => { + for (let node: HTMLElement | null = element; node; node = node.parentElement) { + if (node.hasAttribute("hidden")) return false; + if (node.getAttribute("aria-selected") === "false") return false; + } + return true; + }; - it.each([ + const showingCount = (marker: string): number => screen.queryAllByText(marker).filter(isShowing).length; + + const showingText = (text: string): HTMLElement => { + const [element] = screen.getAllByText(text).filter(isShowing); + expect(element).toBeDefined(); + return element; + }; + + const NON_TEAM_PANELS: [string, string][] = [ ["Cost", "Tag Spend Overview"], ["Model Activity", "metrics-source:model_groups"], ["Key Activity", "metrics-source:api_keys"], ["Endpoint Activity", "Endpoint Usage Panel"], - ])("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => { - const { container } = render(); + ]; + + it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => { + render(); await waitFor(() => { expect(mockTagDailyActivityCall).toHaveBeenCalled(); @@ -521,19 +536,23 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText(tabLabel)); }); - const selected = selectedPanels(container); - expect(selected).toHaveLength(1); - expect(selected[0].textContent).toContain(marker); + expect(showingCount(marker)).toBeGreaterThan(0); + for (const [otherLabel, otherMarker] of NON_TEAM_PANELS) { + if (otherLabel === tabLabel) continue; + expect(showingCount(otherMarker)).toBe(0); + } }); - it.each([ + const TEAM_PANELS: [string, string][] = [ ["Cost", "Team Spend Overview"], ["Model Activity", "metrics-source:model_groups"], ["Agent Activity", "metrics-source:entities"], ["Key Activity", "metrics-source:api_keys"], ["Endpoint Activity", "Endpoint Usage Panel"], - ])("shows only the %s panel for the team entity type", async (tabLabel, marker) => { - const { container } = render(); + ]; + + it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => { + render(); await waitFor(() => { expect(mockTeamDailyActivityCall).toHaveBeenCalled(); @@ -543,9 +562,11 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText(tabLabel)); }); - const selected = selectedPanels(container); - expect(selected).toHaveLength(1); - expect(selected[0].textContent).toContain(marker); + expect(showingCount(marker)).toBeGreaterThan(0); + for (const [otherLabel, otherMarker] of TEAM_PANELS) { + if (otherLabel === tabLabel) continue; + expect(showingCount(otherMarker)).toBe(0); + } }); it("should handle empty data gracefully", async () => { @@ -615,20 +636,19 @@ describe("EntityUsage", () => { fireEvent.click(screen.getByText("Model Activity")); }); - const modelActivityPanel = () => selectedPanels(container)[0] as HTMLElement; - expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups"); + expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0); act(() => { - fireEvent.click(within(modelActivityPanel()).getByText("Litellm Model Name")); + fireEvent.click(showingText("Litellm Model Name")); }); - expect(modelActivityPanel().textContent).toContain("metrics-source:models"); + expect(showingCount("metrics-source:models")).toBeGreaterThan(0); act(() => { - fireEvent.click(within(modelActivityPanel()).getByText("Public Model Name")); + fireEvent.click(showingText("Public Model Name")); }); - expect(modelActivityPanel().textContent).toContain("metrics-source:model_groups"); + expect(showingCount("metrics-source:model_groups")).toBeGreaterThan(0); }); it("should display Top Agents title for agent entity type", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx index 2770bac47d9..961b19ab27a 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/EntityUsage/TopModelView.test.tsx @@ -10,6 +10,14 @@ describe("TopModelView", () => { mockSetTopModelsLimit.mockClear(); }); + // Which element a control library gives its label to is its own business, so drive the + // control by its visible text and judge the result by what the panel renders. + const clickControl = async (user: ReturnType, label: string) => { + await user.click(screen.getByText(label)); + }; + + const showsChart = (container: HTMLElement) => container.querySelector(".recharts-wrapper") !== null; + it("should render", () => { render(); expect(screen.getByText("Table View")).toBeInTheDocument(); @@ -17,12 +25,12 @@ describe("TopModelView", () => { it("should display table view button", () => { render(); - expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument(); + expect(screen.getByText("Table View")).toBeInTheDocument(); }); it("should display chart view button", () => { render(); - expect(screen.getByRole("button", { name: "Chart View" })).toBeInTheDocument(); + expect(screen.getByText("Chart View")).toBeInTheDocument(); }); it("should display all table column headers", () => { @@ -60,27 +68,32 @@ describe("TopModelView", () => { expect(screen.getByText("50,000")).toBeInTheDocument(); }); + const oneModel = [{ key: "gpt-4", spend: 150.5, successful_requests: 100, failed_requests: 5, tokens: 50000 }]; + it("should switch to chart view when chart view button is clicked", async () => { const user = userEvent.setup(); - render(); + const { container } = render( + , + ); - const chartViewButton = screen.getByRole("button", { name: "Chart View" }); - await user.click(chartViewButton); + expect(showsChart(container)).toBe(false); + await clickControl(user, "Chart View"); - expect(chartViewButton).toHaveClass("bg-blue-100"); + expect(showsChart(container)).toBe(true); + expect(screen.queryByText("Spend (USD)")).not.toBeInTheDocument(); }); it("should switch to table view when table view button is clicked", async () => { const user = userEvent.setup(); - render(); + const { container } = render( + , + ); - const chartViewButton = screen.getByRole("button", { name: "Chart View" }); - const tableViewButton = screen.getByRole("button", { name: "Table View" }); + await clickControl(user, "Chart View"); + await clickControl(user, "Table View"); - await user.click(chartViewButton); - await user.click(tableViewButton); - - expect(tableViewButton).toHaveClass("bg-blue-100"); + expect(showsChart(container)).toBe(false); + expect(screen.getByText("Spend (USD)")).toBeInTheDocument(); }); it("renders one cyan bar per model with model names on the axis in chart view", async () => { @@ -108,7 +121,7 @@ describe("TopModelView", () => { />, ); - await user.click(screen.getByRole("button", { name: "Chart View" })); + await clickControl(user, "Chart View"); const bars = container.querySelectorAll("path.recharts-rectangle"); expect(bars).toHaveLength(2); @@ -118,19 +131,11 @@ describe("TopModelView", () => { expect(screen.getAllByText("claude-3").length).toBeGreaterThan(0); }); - it("should call setTopModelsLimit when limit is changed via Segmented control", async () => { + it("should call setTopModelsLimit when the limit control is changed", async () => { const user = userEvent.setup(); render(); - const limit10Radio = screen.getByRole("radio", { name: "10" }); - const limit10Label = limit10Radio.closest("label"); - if (limit10Label) { - await user.click(limit10Label); - } else { - // Fallback: click the div with title="10" - const limit10Div = screen.getByTitle("10"); - await user.click(limit10Div); - } + await clickControl(user, "10"); expect(mockSetTopModelsLimit).toHaveBeenCalledWith(10); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx index 54bf4fc25ce..d971f5d0506 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageAIChatPanel.test.tsx @@ -37,7 +37,10 @@ describe("UsageAIChatPanel", () => { it("should render model selector", () => { renderWithProviders(); - expect(screen.getByText("Select a model (optional, defaults to gpt-4o-mini)")).toBeInTheDocument(); + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what to pick. + const prompt = "Select a model (optional, defaults to gpt-4o-mini)"; + expect(screen.queryAllByText(prompt).length + screen.queryAllByPlaceholderText(prompt).length).toBeGreaterThan(0); }); it("should render empty state message when no conversation", () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx index 9085cf961a9..9cd1e53c9b4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsagePageView.test.tsx @@ -4,6 +4,7 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser"; import { useInfiniteUsers } from "@/app/(dashboard)/hooks/users/useUsers"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "@/../tests/test-utils"; import type { Organization } from "@/components/networking"; @@ -143,207 +144,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useUsers", () => ({ useInfiniteUsers: vi.fn(), })); -vi.mock("antd", async (importOriginal) => { - const React = await import("react"); - const actual = await importOriginal(); - - function Select(props: any) { - const { value, onChange, options, ...rest } = props; - return React.createElement( - "select", - { - ...rest, - value, - onChange: (e: any) => onChange?.(e.target.value), - role: "combobox", - }, - options?.map((opt: any) => React.createElement("option", { key: opt.value, value: opt.value }, opt.label)), - ); - } - (Select as any).displayName = "AntdSelect"; - - function Alert(props: any) { - const { message, description, type, closable, onClose, ...rest } = props; - return React.createElement( - "div", - { ...rest, "data-testid": "antd-alert", "data-type": type }, - message && React.createElement("div", null, message), - description && React.createElement("div", null, description), - closable && React.createElement("button", { onClick: onClose, "aria-label": "Close" }, "×"), - ); - } - (Alert as any).displayName = "AntdAlert"; - - function Badge(props: any) { - const { count, color, children, ...rest } = props; - return React.createElement( - "div", - { ...rest, "data-testid": "antd-badge", "data-color": color }, - count && React.createElement("span", { "data-testid": "antd-badge-count" }, count), - children, - ); - } - (Badge as any).displayName = "AntdBadge"; - - function Table({ columns, dataSource, ...rest }: any) { - return React.createElement( - "div", - { ...rest, "data-testid": "antd-table" }, - columns?.map((col: any) => - React.createElement("div", { key: col.key, "data-testid": `column-${col.key}` }, col.title), - ), - dataSource?.map((row: any) => - React.createElement( - "div", - { key: row.key, "data-testid": `row-${row.key}` }, - columns?.map((col: any) => { - const value = col.render ? col.render(row[col.dataIndex], row) : row[col.dataIndex]; - return React.createElement("div", { key: col.key }, value); - }), - ), - ), - ); - } - (Table as any).displayName = "Table"; - - function Segmented(props: any) { - const { value, onChange, options, ...rest } = props; - return React.createElement( - "div", - { ...rest, "data-testid": "antd-segmented" }, - options?.map((opt: any) => - React.createElement( - "button", - { - key: opt.value, - onClick: () => onChange?.(opt.value), - "data-selected": value === opt.value, - }, - opt.label, - ), - ), - ); - } - (Segmented as any).displayName = "AntdSegmented"; - - function Tooltip(props: any) { - const { title, children, ...rest } = props; - return React.createElement("div", { ...rest, "data-testid": "antd-tooltip", title }, children); - } - (Tooltip as any).displayName = "AntdTooltip"; - - return { - ...actual, - Select, - Alert, - Badge, - Table, - Segmented, - Tooltip, - }; -}); - -vi.mock("@ant-design/icons", async () => { - const React = await import("react"); - - function Icon() { - return React.createElement("span"); - } - - function LoadingOutlined(props: any) { - return React.createElement("span", { "data-testid": "loading-icon", ...props }); - } - - return { - GlobalOutlined: Icon, - BankOutlined: Icon, - TeamOutlined: Icon, - ShoppingCartOutlined: Icon, - TagsOutlined: Icon, - RobotOutlined: Icon, - LineChartOutlined: Icon, - BarChartOutlined: Icon, - ClockCircleOutlined: Icon, - CalendarOutlined: Icon, - InfoCircleOutlined: Icon, - UserOutlined: Icon, - DownOutlined: Icon, - RightOutlined: Icon, - ExportOutlined: Icon, - LoadingOutlined, - }; -}); - -// Mock Tremor components -vi.mock("@tremor/react", async () => { - const React = await import("react"); - const actual = await import("@tremor/react"); - - function TabGroup({ children }: any) { - return React.createElement("div", { "data-testid": "tremor-tab-group" }, children); - } - - function TabList({ children }: any) { - return React.createElement("div", { "data-testid": "tremor-tab-list" }, children); - } - - function Tab({ children, ...props }: any) { - return React.createElement("button", { ...props, "data-testid": "tremor-tab" }, children); - } - - function TabPanels({ children }: any) { - return React.createElement("div", { "data-testid": "tremor-tab-panels" }, children); - } - - function TabPanel({ children }: any) { - return React.createElement("div", { "data-testid": "tremor-tab-panel" }, children); - } - - function Card({ children, ...props }: any) { - return React.createElement("div", { ...props, "data-testid": "tremor-card" }, children); - } - - function Grid({ children, numItems, ...props }: any) { - return React.createElement("div", { ...props, "data-testid": "tremor-grid" }, children); - } - - function Col({ children, numColSpan, ...props }: any) { - return React.createElement("div", { ...props, "data-testid": "tremor-col" }, children); - } - - function Title({ children, ...props }: any) { - return React.createElement("h2", { ...props, "data-testid": "tremor-title" }, children); - } - - function Text({ children, ...props }: any) { - return React.createElement("p", { ...props, "data-testid": "tremor-text" }, children); - } - - function Button({ children, icon, onClick, ...props }: any) { - return React.createElement( - "button", - { ...props, onClick, "data-testid": "tremor-button" }, - icon && React.createElement("span", { "data-testid": "tremor-button-icon" }), - children, - ); - } - - return { - ...actual, - TabGroup, - TabList, - Tab, - TabPanels, - TabPanel, - Card, - Grid, - Col, - Title, - Text, - Button, - }; -}); - describe("UsagePage", () => { const mockUserDailyActivityAggregatedCall = vi.mocked(networking.userDailyActivityAggregatedCall); const mockUserDailyActivityCall = vi.mocked(networking.userDailyActivityCall); @@ -885,6 +685,26 @@ describe("UsagePage", () => { }); describe("admin user selector", () => { + // Anchored on the field's own label, so it does not depend on which library draws the control. + const userSelectCombobox = (): HTMLElement => { + let node: HTMLElement | null = screen.getByText("Filter by user"); + while (node && !node.querySelector('[role="combobox"]')) { + node = node.parentElement; + } + const combobox = node?.querySelector('[role="combobox"]') ?? null; + expect(combobox).not.toBeNull(); + return combobox as HTMLElement; + }; + + const openUserSelect = async () => { + await userEvent.setup().click(userSelectCombobox()); + }; + + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what to type. + const promptsWith = (text: string) => + screen.queryAllByText(text).length + screen.queryAllByPlaceholderText(text).length > 0; + it("should render user selector for admin users in global view", async () => { renderWithProviders(); @@ -892,10 +712,8 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); - // Admin should see the user selector select element with the placeholder attribute - const userSelects = screen.getAllByRole("combobox"); - const userSelect = userSelects.find((el) => el.getAttribute("placeholder") === "Select user to filter..."); - expect(userSelect).toBeDefined(); + expect(userSelectCombobox()).toBeInTheDocument(); + expect(promptsWith("Select user to filter...")).toBe(true); }); it("should format user options with alias when available", async () => { @@ -905,6 +723,8 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); + await openUserSelect(); + // User with alias should show "alias (id)" expect(screen.getByText("Alice (user-001)")).toBeInTheDocument(); // User without alias but with email should show "email (id)" @@ -958,6 +778,8 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); + await openUserSelect(); + // Duplicate user should appear only once const dupElements = screen.getAllByText("DupUser (user-dup)"); expect(dupElements).toHaveLength(1); @@ -1003,10 +825,9 @@ describe("UsagePage", () => { expect(mockUserDailyActivityAggregatedCall).toHaveBeenCalled(); }); - // Non-admin should not see the user selector - const userSelects = screen.getAllByRole("combobox"); - const userSelect = userSelects.find((el) => el.getAttribute("placeholder") === "Select user to filter..."); - expect(userSelect).toBeUndefined(); + // The admin case above proves this label is rendered when the selector exists, so its + // absence here is a live assertion rather than a query that can never match. + expect(screen.queryByText("Filter by user")).not.toBeInTheDocument(); }); it("should always pass own userId for non-admin users", async () => { diff --git a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx index dcc0ce06673..80005a20d05 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/usage/_components/components/UsageViewSelect/UsageViewSelect.test.tsx @@ -1,86 +1,16 @@ -import { act, fireEvent, render, screen } from "@testing-library/react"; +import { render, screen, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { UsageViewSelect } from "./UsageViewSelect"; -vi.mock("antd", async () => { - const React = await import("react"); +const openMenu = async (user: ReturnType) => { + await user.click(screen.getByRole("combobox")); +}; - function Select(props: any) { - const { value, onChange, options, optionRender, labelRender, ...rest } = props; - - const optionElements = options?.map((opt: any) => - React.createElement("option", { key: opt.value, value: opt.value }, opt.label), - ); - - const optionRenderOutputs = options - ?.map((opt: any) => { - if (optionRender) { - const rendered = optionRender({ value: opt.value, label: opt.label }); - return React.createElement( - "div", - { - key: `option-render-${opt.value}`, - "data-testid": `option-render-${opt.value}`, - style: { display: "none" }, - }, - rendered, - ); - } - return null; - }) - .filter(Boolean); - - return React.createElement( - React.Fragment, - null, - React.createElement( - "select", - { - ...rest, - value, - onChange: (e: any) => onChange?.(e.target.value), - role: "combobox", - }, - optionElements, - ), - ...(optionRenderOutputs || []), - ); - } - (Select as any).displayName = "AntdSelect"; - - function Badge(props: any) { - const { count, color, children, ...rest } = props; - return React.createElement( - "span", - { ...rest, "data-testid": "antd-badge", "data-color": color }, - count && React.createElement("span", { "data-testid": "antd-badge-count" }, count), - children, - ); - } - (Badge as any).displayName = "AntdBadge"; - - return { Select, Badge }; -}); - -vi.mock("@ant-design/icons", async () => { - const React = await import("react"); - - function Icon(props: any) { - return React.createElement("span", { "data-testid": "antd-icon" }); - } - - return { - GlobalOutlined: Icon, - BankOutlined: Icon, - TeamOutlined: Icon, - ShoppingCartOutlined: Icon, - TagsOutlined: Icon, - RobotOutlined: Icon, - UserOutlined: Icon, - LineChartOutlined: Icon, - BarChartOutlined: Icon, - }; -}); +// The listbox is portalled outside the render container in both antd and Base UI, so an +// option is "offered" when the label appears more times on the page than inside the trigger. +const offers = (container: HTMLElement, label: string) => + screen.queryAllByText(label).length > within(container).queryAllByText(label).length; describe("UsageViewSelect", () => { const mockOnChange = vi.fn(); @@ -89,53 +19,73 @@ describe("UsageViewSelect", () => { mockOnChange.mockClear(); }); - it("should render", () => { - render(); + it("should render", async () => { + const user = userEvent.setup(); + const { container } = render(); expect(screen.getByText("Usage View")).toBeInTheDocument(); expect(screen.getByText("Select the usage data you want to view")).toBeInTheDocument(); expect(screen.getByRole("combobox")).toBeInTheDocument(); - expect(screen.getByRole("option", { name: "Your Usage" })).toBeInTheDocument(); + + await openMenu(user); + expect(offers(container, "Your Usage")).toBe(true); }); - it("should call onChange when value changes", () => { + it("should call onChange when value changes", async () => { + const user = userEvent.setup(); render(); - const select = screen.getByRole("combobox"); - act(() => { - fireEvent.change(select, { target: { value: "team" } }); - }); + await openMenu(user); + const matches = screen.getAllByText("Team Usage"); + await user.click(matches[matches.length - 1]); - expect(mockOnChange).toHaveBeenCalledWith("team"); + expect(mockOnChange).toHaveBeenCalled(); + expect(mockOnChange.mock.calls[0][0]).toBe("team"); }); - it("should show Tag Usage for non-admin users with tag usage permission", () => { - render(); + it("should show Tag Usage for non-admin users with tag usage permission", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); - expect(screen.getByRole("option", { name: "Tag Usage" })).toBeInTheDocument(); + await openMenu(user); + expect(offers(container, "Tag Usage")).toBe(true); }); - it("should hide Tag Usage for non-admin users without tag usage permission", () => { - render(); + it("should hide Tag Usage for non-admin users without tag usage permission", async () => { + const user = userEvent.setup(); + const { container } = render(); - expect(screen.queryByRole("option", { name: "Tag Usage" })).not.toBeInTheDocument(); + await openMenu(user); + expect(offers(container, "Tag Usage")).toBe(false); }); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", (optionName) => { - render(); + it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => { + const user = userEvent.setup(); + const { container } = render(); - expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument(); + await openMenu(user); + expect(offers(container, optionName)).toBe(true); }); - it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", (optionName) => { - render(); + it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => { + const user = userEvent.setup(); + const { container } = render( + , + ); - expect(screen.queryByRole("option", { name: optionName })).not.toBeInTheDocument(); + await openMenu(user); + expect(offers(container, optionName)).toBe(false); }); - it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", (optionName) => { - render(); + it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => { + const user = userEvent.setup(); + const { container } = render( + , + ); - expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument(); + await openMenu(user); + expect(offers(container, optionName)).toBe(true); }); }); diff --git a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx index cd37ea42a90..74d258e2bd0 100644 --- a/ui/litellm-dashboard/src/components/activity_metrics.test.tsx +++ b/ui/litellm-dashboard/src/components/activity_metrics.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import React from "react"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { ActivityMetrics, formatKeyLabel, processActivityData } from "./activity_metrics"; @@ -15,38 +15,12 @@ beforeAll(() => { } }); -vi.mock("@tremor/react", () => ({ - Card: ({ children }: { children: React.ReactNode }) =>
{children}
, - Grid: ({ children }: { children: React.ReactNode }) =>
{children}
, - Text: ({ children }: { children: React.ReactNode }) => {children}, - Title: ({ children }: { children: React.ReactNode }) =>

{children}

, - AreaChart: () =>
AreaChart
, - BarChart: () =>
BarChart
, -})); - -vi.mock("antd", () => { - const CollapseComponent = ({ children }: { children: React.ReactNode }) =>
{children}
; - const PanelComponent = ({ children, header }: { children: React.ReactNode; header: React.ReactNode }) => ( -
-
{header}
-
{children}
-
- ); - PanelComponent.displayName = "Collapse.Panel"; - CollapseComponent.Panel = PanelComponent; - const TableComponent = ({ dataSource, columns }: { dataSource?: unknown[]; columns?: { title: string }[] }) => ( - - - {columns?.map((col, i) => )} - - {dataSource?.map((_, i) => )} -
{col.title}
- ); - return { - Collapse: CollapseComponent, - Table: TableComponent, - }; -}); +// Panel order is a contract; which element the label lands in is not, so compare document order. +const precedes = (firstLabel: string, secondLabel: string): boolean => { + const first = screen.getAllByText(firstLabel)[0]; + const second = screen.getAllByText(secondLabel)[0]; + return Boolean(first.compareDocumentPosition(second) & Node.DOCUMENT_POSITION_FOLLOWING); +}; vi.mock("@/utils/dataUtils", async (importOriginal) => { const actual = await importOriginal(); @@ -256,10 +230,7 @@ describe("ActivityMetrics", () => { }; render(); - const headers = screen.getAllByRole("heading", { level: 2 }); - const gpt4Index = headers.findIndex((h) => h.textContent?.includes("GPT-4")); - const gpt35Index = headers.findIndex((h) => h.textContent?.includes("GPT-3.5")); - expect(gpt4Index).toBeLessThan(gpt35Index); + expect(precedes("GPT-4", "GPT-3.5")).toBe(true); }); it("should display model summary cards with correct values", () => { @@ -387,10 +358,27 @@ describe("ActivityMetrics", () => { }; render(); - const headings = screen.getAllByRole("heading", { level: 2 }); - const gpt4Index = headings.findIndex((h) => h.textContent?.includes("GPT-4")); - const unknownIndex = headings.findIndex((h) => h.textContent?.includes("Unknown")); - expect(gpt4Index).toBeLessThan(unknownIndex); + expect(precedes("GPT-4", "Unknown")).toBe(true); + }); + + // A model section owns view-mode state, so collapsing one must not throw its subtree away. + it("keeps a model section mounted once it has been expanded", () => { + const multipleModels: Record = { + "gpt-3.5": GPT_35_MODEL_DATA, + "gpt-4": { ...mockModelMetrics["gpt-4"], total_spend: 100.5 }, + }; + + render(); + + // Only the highest-spend section is expanded initially, so only its body is mounted. + const sectionsMounted = () => screen.getAllByText("Spend per day").length; + expect(sectionsMounted()).toBe(1); + + fireEvent.click(screen.getAllByText("GPT-3.5")[0]); + expect(sectionsMounted()).toBe(2); + + fireEvent.click(screen.getAllByText("GPT-3.5")[0]); + expect(sectionsMounted()).toBe(2); }); it("should display average tokens per successful request", () => { diff --git a/ui/litellm-dashboard/src/components/common_components/team_multi_select.test.tsx b/ui/litellm-dashboard/src/components/common_components/team_multi_select.test.tsx new file mode 100644 index 00000000000..82508472986 --- /dev/null +++ b/ui/litellm-dashboard/src/components/common_components/team_multi_select.test.tsx @@ -0,0 +1,124 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useInfiniteTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; +import TeamMultiSelect from "./team_multi_select"; + +vi.mock("@/app/(dashboard)/hooks/teams/useTeams", () => ({ + useInfiniteTeams: vi.fn(), +})); + +const team = (id: string, alias: string) => ({ team_id: id, team_alias: alias }); + +const mockTeamsResult = ( + overrides: Partial<{ + pages: { teams: ReturnType[] }[]; + isLoading: boolean; + hasNextPage: boolean; + isFetchingNextPage: boolean; + }> = {}, +) => { + const { pages = [{ teams: [team("team-1", "Alpha Team"), team("team-2", "Beta Team")] }], ...rest } = overrides; + return { + data: { pages }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + ...rest, + }; +}; + +describe("TeamMultiSelect", () => { + const mockUseInfiniteTeams = vi.mocked(useInfiniteTeams); + + beforeEach(() => { + vi.clearAllMocks(); + mockUseInfiniteTeams.mockReturnValue(mockTeamsResult() as never); + }); + + const combobox = () => screen.getByRole("combobox"); + + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what to type. + const promptsWith = (text: string) => + screen.queryAllByText(text).length + screen.queryAllByPlaceholderText(text).length > 0; + + it("renders a search control with the given placeholder", () => { + render(); + + expect(combobox()).toBeInTheDocument(); + expect(promptsWith("Search teams by alias...")).toBe(true); + }); + + it("offers every loaded team by alias and id", async () => { + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + expect(screen.getByText("Alpha Team")).toBeInTheDocument(); + expect(screen.getByText("(team-1)")).toBeInTheDocument(); + expect(screen.getByText("Beta Team")).toBeInTheDocument(); + expect(screen.getByText("(team-2)")).toBeInTheDocument(); + }); + + it("deduplicates a team that appears on more than one page", async () => { + mockUseInfiniteTeams.mockReturnValue( + mockTeamsResult({ + pages: [ + { teams: [team("team-1", "Alpha Team")] }, + { teams: [team("team-1", "Alpha Team"), team("team-2", "Beta Team")] }, + ], + }) as never, + ); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + expect(screen.getAllByText("Alpha Team")).toHaveLength(1); + expect(screen.getByText("Beta Team")).toBeInTheDocument(); + }); + + it("reports the picked team id to onChange", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + const matches = screen.getAllByText("Beta Team"); + await user.click(matches[matches.length - 1]); + + expect(onChange).toHaveBeenCalled(); + expect(onChange.mock.calls[0][0]).toEqual(["team-2"]); + }); + + it("does not report a selection while disabled", async () => { + const onChange = vi.fn(); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + expect(screen.queryByText("Alpha Team")).not.toBeInTheDocument(); + expect(onChange).not.toHaveBeenCalled(); + }); + + it("tells the user when there are no teams to pick", async () => { + mockUseInfiniteTeams.mockReturnValue(mockTeamsResult({ pages: [{ teams: [] }] }) as never); + const user = userEvent.setup(); + render(); + + await user.click(combobox()); + + // Substring match because one library appends an invisible word joiner for its live region. + expect(screen.getByText(/No teams found/)).toBeInTheDocument(); + }); + + it("passes the page size and organization filter through to the teams query", () => { + render(); + + expect(mockUseInfiniteTeams).toHaveBeenCalledWith(25, undefined, "org-7"); + }); +}); diff --git a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx index 66aaf015aca..443ae66af8f 100644 --- a/ui/litellm-dashboard/src/components/per_user_usage.test.tsx +++ b/ui/litellm-dashboard/src/components/per_user_usage.test.tsx @@ -29,6 +29,14 @@ const userRow = (userId: string, userAgent: string | null, successfulRequests: n spend: 1, }); +// The distribution panel owns the only chart in this component, so resolving it by slot keeps +// the assertions independent of how many wrappers the tab library puts around a panel. +const distributionChart = (): HTMLElement => { + const chart = document.querySelector('[data-slot="chart"]'); + expect(chart).not.toBeNull(); + return chart as HTMLElement; +}; + describe("PerUserUsage", () => { const mockPerUserAnalyticsCall = vi.mocked(networking.perUserAnalyticsCall); @@ -70,6 +78,22 @@ describe("PerUserUsage", () => { }); }); + it("keeps both tab panels mounted so switching tabs does not reset their state", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("u1")).toBeInTheDocument(); + }); + + // Still on the User Details tab: the distribution panel is mounted alongside it. + expect(screen.getByText("User Usage Distribution")).toBeInTheDocument(); + + fireEvent.click(screen.getByText("Usage Distribution")); + + // And the details panel survives the switch rather than unmounting. + expect(screen.getByText("u1")).toBeInTheDocument(); + }); + it("renders the usage distribution as a stacked bar chart with the explicit palette and users formatter", async () => { render(); @@ -79,26 +103,22 @@ describe("PerUserUsage", () => { fireEvent.click(screen.getByText("Usage Distribution")); - const panel = screen.getByText("User Usage Distribution").closest("div")?.parentElement; - expect(panel).not.toBeNull(); - await waitFor(() => { - expect(panel!.querySelectorAll("path.recharts-rectangle")).toHaveLength(4); + expect(distributionChart().querySelectorAll("path.recharts-rectangle")).toHaveLength(4); }); - const chart = panel!.querySelector('[data-slot="chart"]'); - expect(chart).not.toBeNull(); - expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2); + const chart = distributionChart(); + expect(chart.querySelectorAll(".recharts-bar")).toHaveLength(2); - const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle")); + const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle")); const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill"))); expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-green-500, #22c55e)"])); const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1])); expect(xPositions.size).toBe(3); - expect(chart!.textContent).toContain("curl/8.0"); - expect(chart!.textContent).toContain("Unknown"); + expect(chart.textContent).toContain("curl/8.0"); + expect(chart.textContent).toContain("Unknown"); for (const bucket of [ "1-9 requests", "10-99 requests", @@ -107,10 +127,10 @@ describe("PerUserUsage", () => { "10K-99.9K requests", "100K+ requests", ]) { - expect(chart!.textContent).toContain(bucket); + expect(chart.textContent).toContain(bucket); } - const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map( + const tickTexts = Array.from(chart.querySelectorAll(".recharts-cartesian-axis-tick-value")).map( (tick) => tick.textContent ?? "", ); expect(tickTexts.some((tick) => / users$/.test(tick))).toBe(true); diff --git a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx index b2facde3e65..07a03f4addf 100644 --- a/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx +++ b/ui/litellm-dashboard/src/components/user_agent_activity.test.tsx @@ -177,40 +177,61 @@ describe("UserAgentActivity", () => { // Check that filter label is present expect(screen.getByText("Filter by User Agents")).toBeInTheDocument(); - // The Ant Design Select component should be in the document with placeholder - const selectElement = screen.getByText("All User Agents"); - expect(selectElement).toBeInTheDocument(); + // One library paints the prompt as its own text node and the other leaves it on the input's + // placeholder attribute, so either one means the user is being told what the filter does. + const prompts = + screen.queryAllByText("All User Agents").length + screen.queryAllByPlaceholderText("All User Agents").length; + expect(prompts).toBeGreaterThan(0); }); - const getPanelForTitle = (title: string): HTMLElement => { - // Assumes two wrapper divs between the Tremor and the panel root; update if Tremor's TabPanel depth changes. - const panel = screen.getByText(title).closest("div")?.parentElement; - expect(panel).not.toBeNull(); - return panel!; + // Walks up from the panel's heading to the nearest ancestor that owns a chart, so the + // assertions do not depend on how many wrappers the tab library puts around a panel. + const chartForTitle = (title: string): HTMLElement => { + let node: HTMLElement | null = screen.getByText(title); + while (node && !node.querySelector('[data-slot="chart"]')) { + node = node.parentElement; + } + const chart = node?.querySelector('[data-slot="chart"]') ?? null; + expect(chart).not.toBeNull(); + return chart as HTMLElement; }; - const expectStackedTwoCategoryChart = (panel: HTMLElement, firstBucketLabel: string) => { - const chart = panel.querySelector('[data-slot="chart"]'); - expect(chart).not.toBeNull(); - expect(chart!.querySelectorAll(".recharts-bar")).toHaveLength(2); + const expectStackedTwoCategoryChart = (chart: HTMLElement, firstBucketLabel: string) => { + expect(chart.querySelectorAll(".recharts-bar")).toHaveLength(2); - const rectangles = Array.from(chart!.querySelectorAll("path.recharts-rectangle")); + const rectangles = Array.from(chart.querySelectorAll("path.recharts-rectangle")); const fills = new Set(rectangles.map((rect) => rect.getAttribute("fill"))); expect(fills).toEqual(new Set(["var(--color-blue-500, #3b82f6)", "var(--color-cyan-500, #06b6d4)"])); const xPositions = new Set(rectangles.map((rect) => rect.getAttribute("d")?.match(/^M\s*([\d.]+)/)?.[1])); expect(xPositions.size).toBe(1); - expect(chart!.textContent).toContain("Chrome/1.0"); - expect(chart!.textContent).toContain("Firefox/2.0"); - expect(chart!.textContent).toContain(firstBucketLabel); + expect(chart.textContent).toContain("Chrome/1.0"); + expect(chart.textContent).toContain("Firefox/2.0"); + expect(chart.textContent).toContain(firstBucketLabel); - const tickTexts = Array.from(chart!.querySelectorAll(".recharts-cartesian-axis-tick-value")).map( + const tickTexts = Array.from(chart.querySelectorAll(".recharts-cartesian-axis-tick-value")).map( (tick) => tick.textContent ?? "", ); expect(tickTexts.some((tick) => /^\d+K$/.test(tick))).toBe(true); }; + it("keeps every tab panel mounted so switching tabs does not reset their state", async () => { + render(<UserAgentActivity {...defaultProps} />); + + await waitFor(() => { + expect(mockTagDauCall).toHaveBeenCalled(); + }); + + // No tab has been clicked: the inactive DAU/WAU/MAU panels are mounted alongside the active one. + expect(screen.getByText("Daily Active Users - Last 7 Days")).toBeInTheDocument(); + expect(screen.getByText("Weekly Active Users - Last 7 Weeks")).toBeInTheDocument(); + expect(screen.getByText("Monthly Active Users - Last 7 Months")).toBeInTheDocument(); + + // And so is the second panel of the outer tab group. + expect(screen.getByText("Per User Usage")).toBeInTheDocument(); + }); + it("renders the DAU chart stacked with default color cycle and abbreviated axis ticks", async () => { const firstBucketDate = new Date(); firstBucketDate.setDate(firstBucketDate.getDate() - 6); @@ -224,12 +245,16 @@ describe("UserAgentActivity", () => { render(<UserAgentActivity {...defaultProps} />); - const panel = getPanelForTitle("Daily Active Users - Last 7 Days"); await waitFor(() => { - expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + expect( + chartForTitle("Daily Active Users - Last 7 Days").querySelectorAll("path.recharts-rectangle"), + ).toHaveLength(2); }); - expectStackedTwoCategoryChart(panel, firstBucketDate.toISOString().split("T")[0]); + expectStackedTwoCategoryChart( + chartForTitle("Daily Active Users - Last 7 Days"), + firstBucketDate.toISOString().split("T")[0], + ); }); it("renders the WAU chart stacked with week buckets and abbreviated axis ticks", async () => { @@ -242,12 +267,13 @@ describe("UserAgentActivity", () => { render(<UserAgentActivity {...defaultProps} />); - const panel = getPanelForTitle("Weekly Active Users - Last 7 Weeks"); await waitFor(() => { - expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + expect( + chartForTitle("Weekly Active Users - Last 7 Weeks").querySelectorAll("path.recharts-rectangle"), + ).toHaveLength(2); }); - expectStackedTwoCategoryChart(panel, "Week 1"); + expectStackedTwoCategoryChart(chartForTitle("Weekly Active Users - Last 7 Weeks"), "Week 1"); }); it("renders the MAU chart stacked with month buckets and abbreviated axis ticks", async () => { @@ -260,11 +286,12 @@ describe("UserAgentActivity", () => { render(<UserAgentActivity {...defaultProps} />); - const panel = getPanelForTitle("Monthly Active Users - Last 7 Months"); await waitFor(() => { - expect(panel.querySelectorAll("path.recharts-rectangle")).toHaveLength(2); + expect( + chartForTitle("Monthly Active Users - Last 7 Months").querySelectorAll("path.recharts-rectangle"), + ).toHaveLength(2); }); - expectStackedTwoCategoryChart(panel, "Month 1"); + expectStackedTwoCategoryChart(chartForTitle("Monthly Active Users - Last 7 Months"), "Month 1"); }); });