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) => | {col.title} | )}
-
- {dataSource?.map((_, i) =>
)}
-
- );
- 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();
+
+ 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();
- 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();
- 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();
- 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");
});
});