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.
This commit is contained in:
Yuneng Jiang 2026-08-13 14:01:00 -07:00
parent c1310de342
commit 28032eb4c9
No known key found for this signature in database
9 changed files with 397 additions and 439 deletions

View file

@ -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(<EntityUsage {...defaultProps} />);
];
it.each(NON_TEAM_PANELS)("shows only the %s panel for a non-team entity type", async (tabLabel, marker) => {
render(<EntityUsage {...defaultProps} />);
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(<EntityUsage {...defaultProps} entityType="team" />);
];
it.each(TEAM_PANELS)("shows only the %s panel for the team entity type", async (tabLabel, marker) => {
render(<EntityUsage {...defaultProps} entityType="team" />);
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 () => {

View file

@ -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<typeof userEvent.setup>, label: string) => {
await user.click(screen.getByText(label));
};
const showsChart = (container: HTMLElement) => container.querySelector(".recharts-wrapper") !== null;
it("should render", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByText("Table View")).toBeInTheDocument();
@ -17,12 +25,12 @@ describe("TopModelView", () => {
it("should display table view button", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
expect(screen.getByRole("button", { name: "Table View" })).toBeInTheDocument();
expect(screen.getByText("Table View")).toBeInTheDocument();
});
it("should display chart view button", () => {
render(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
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(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
const { container } = render(
<TopModelView topModels={oneModel} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />,
);
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(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
const { container } = render(
<TopModelView topModels={oneModel} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />,
);
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(<TopModelView topModels={[]} topModelsLimit={5} setTopModelsLimit={mockSetTopModelsLimit} />);
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);
});

View file

@ -37,7 +37,10 @@ describe("UsageAIChatPanel", () => {
it("should render model selector", () => {
renderWithProviders(<UsageAIChatPanel {...defaultProps} />);
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", () => {

View file

@ -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<typeof import("antd")>();
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(<UsagePage {...defaultProps} />);
@ -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 () => {

View file

@ -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<typeof userEvent.setup>) => {
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(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
it("should render", async () => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
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(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
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(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
it("should show Tag Usage for non-admin users with tag usage permission", async () => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
);
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(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
it("should hide Tag Usage for non-admin users without tag usage permission", async () => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" />);
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(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
it.each(["Organization Usage", "Agent Usage (A2A)"])("should show %s to an admin", async (optionName) => {
const user = userEvent.setup();
const { container } = render(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Admin" />);
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(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
it.each(["Organization Usage", "Agent Usage (A2A)"])("should hide %s from an internal user", async (optionName) => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
);
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(<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />);
it.each(["Team Usage", "Tag Usage"])("should keep %s available to an internal user", async (optionName) => {
const user = userEvent.setup();
const { container } = render(
<UsageViewSelect value="global" onChange={mockOnChange} userRole="Internal User" canViewTagUsage={true} />,
);
expect(screen.getByRole("option", { name: optionName })).toBeInTheDocument();
await openMenu(user);
expect(offers(container, optionName)).toBe(true);
});
});

View file

@ -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 }) => <div>{children}</div>,
Grid: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
Text: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
Title: ({ children }: { children: React.ReactNode }) => <h2>{children}</h2>,
AreaChart: () => <div>AreaChart</div>,
BarChart: () => <div>BarChart</div>,
}));
vi.mock("antd", () => {
const CollapseComponent = ({ children }: { children: React.ReactNode }) => <div>{children}</div>;
const PanelComponent = ({ children, header }: { children: React.ReactNode; header: React.ReactNode }) => (
<div>
<div>{header}</div>
<div>{children}</div>
</div>
);
PanelComponent.displayName = "Collapse.Panel";
CollapseComponent.Panel = PanelComponent;
const TableComponent = ({ dataSource, columns }: { dataSource?: unknown[]; columns?: { title: string }[] }) => (
<table>
<thead>
<tr>{columns?.map((col, i) => <th key={i}>{col.title}</th>)}</tr>
</thead>
<tbody>{dataSource?.map((_, i) => <tr key={i} />)}</tbody>
</table>
);
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<typeof import("@/utils/dataUtils")>();
@ -256,10 +230,7 @@ describe("ActivityMetrics", () => {
};
render(<ActivityMetrics modelMetrics={multipleModels} />);
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(<ActivityMetrics modelMetrics={modelsWithEmptyKey} />);
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<string, ModelActivityData> = {
"gpt-3.5": GPT_35_MODEL_DATA,
"gpt-4": { ...mockModelMetrics["gpt-4"], total_spend: 100.5 },
};
render(<ActivityMetrics modelMetrics={multipleModels} />);
// 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", () => {

View file

@ -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<typeof team>[] }[];
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(<TeamMultiSelect placeholder="Search teams by alias..." />);
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(<TeamMultiSelect />);
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(<TeamMultiSelect />);
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(<TeamMultiSelect onChange={onChange} />);
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(<TeamMultiSelect onChange={onChange} disabled />);
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(<TeamMultiSelect />);
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(<TeamMultiSelect pageSize={25} organizationId="org-7" />);
expect(mockUseInfiniteTeams).toHaveBeenCalledWith(25, undefined, "org-7");
});
});

View file

@ -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(<PerUserUsage {...defaultProps} />);
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(<PerUserUsage {...defaultProps} />);
@ -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);

View file

@ -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 <Title> 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");
});
});