From 28032eb4c9acadaa10b10090cfb09ad94eb736e5 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Thu, 13 Aug 2026 14:01:00 -0700 Subject: [PATCH] test(ui): decouple usage-route tests from antd and Tremor markup Rewrites the usage route's library-coupled assertions to role and text queries so they characterise behaviour rather than the widget library that happens to render it. Every test here is green against the current antd and Tremor components and is meant to survive a migration unedited. Drops the wholesale vi.mock of antd, @ant-design/icons and @tremor/react in UsagePageView, UsageViewSelect and activity_metrics, and drives the real controls instead. Two of those mocks were hiding behaviour: activity_metrics asserted panel order through a heading role that only existed because the mock faked an h2, and the Tremor tab stubs flattened panel selection away entirely. Replaces DOM-shape lookups with anchors that do not move: a chart is found from its own heading rather than a fixed wrapper depth, an active panel is identified by the inactive markers both tab libraries set, and select options are matched by text since antd's real options carry no option role. Adds the missing characterisation test for team_multi_select, and covers the mount contracts the route depends on: Tremor keeps every tab panel mounted, and antd Collapse keeps a section mounted once it has been expanded, which is what preserves the view-mode state a model section owns. --- .../EntityUsage/EntityUsage.test.tsx | 66 +++-- .../EntityUsage/TopModelView.test.tsx | 53 ++-- .../components/UsageAIChatPanel.test.tsx | 5 +- .../components/UsagePageView.test.tsx | 239 +++--------------- .../UsageViewSelect/UsageViewSelect.test.tsx | 156 ++++-------- .../src/components/activity_metrics.test.tsx | 70 +++-- .../team_multi_select.test.tsx | 124 +++++++++ .../src/components/per_user_usage.test.tsx | 44 +++- .../components/user_agent_activity.test.tsx | 79 ++++-- 9 files changed, 397 insertions(+), 439 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/common_components/team_multi_select.test.tsx 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"); }); });