mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-07 08:26:10 +00:00
test(e2e/ui): cover the Usage page activity tabs (#39061)
* test(e2e/ui): cover the Usage page activity tabs Usage had one test, on Top Virtual Keys. The Key, Model and Endpoint Activity tabs are the ones an admin reads to answer where the spend went, and none of them was covered. Also fixes waitForKeyInDailyActivity, which only read the first page of /user/daily/activity. The route paginates, so once a run generates more keys than one page holds, the helper spins for its full 120 seconds and then blames the rollup for a key the rollup wrote correctly. The Usage page itself already walks every page; the helper now matches it. * test(e2e/ui): route the user-creation call through SERVER_ROOT_PATH Review caught /user/new posting to the server root, which misses the proxy when it is mounted under a prefix. traffic.ts already had the helper for this; it is now exported so specs making their own management calls can use it too. Also drops the mutable accumulators from the daily-activity paging, which the repo conventions ask for. --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
284e96cfe7
commit
4c86b1d58c
1 changed files with 135 additions and 0 deletions
135
tests/e2e/ui/tests/usage/usageActivityTabs.spec.ts
Normal file
135
tests/e2e/ui/tests/usage/usageActivityTabs.spec.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import { test, expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import {
|
||||
CHAT_MODEL_A,
|
||||
CHAT_MODEL_B,
|
||||
DEPLOYMENT_MODEL_A,
|
||||
DEPLOYMENT_MODEL_B,
|
||||
createVirtualKey,
|
||||
masterKey,
|
||||
rootPath,
|
||||
sendChatCompletion,
|
||||
waitForKeyInDailyActivity,
|
||||
waitForSpendLog,
|
||||
} from "../../helpers/traffic";
|
||||
|
||||
/**
|
||||
* Covers the per-entity breakdowns on /ui/usage. The page-level totals move with every other spec's
|
||||
* traffic, so each assertion is scoped to a key this test minted and to the requests it sent.
|
||||
*/
|
||||
|
||||
/** Each breakdown renders one expandable card per entity, named "<entity> $x.xx N requests". */
|
||||
const entityCard = (page: PlaywrightPage, tab: string, name: string): Locator =>
|
||||
page.getByRole("tabpanel", { name: tab }).getByRole("button", { name: new RegExp(`^${name}\\s`) });
|
||||
|
||||
async function openUsageTab(page: PlaywrightPage, tab: string): Promise<Locator> {
|
||||
await navigateToPage(page, Page.NewUsage);
|
||||
await dismissFeedbackPopup(page);
|
||||
await page.getByRole("tab", { name: tab }).click();
|
||||
const panel = page.getByRole("tabpanel", { name: tab });
|
||||
await expect(panel).toBeVisible({ timeout: 30_000 });
|
||||
return panel;
|
||||
}
|
||||
|
||||
/** Sends `count` completions on one model and waits for each to reach the spend log. */
|
||||
async function sendTraffic(
|
||||
request: Parameters<typeof sendChatCompletion>[0],
|
||||
apiKey: string,
|
||||
model: string,
|
||||
count: number,
|
||||
label: string,
|
||||
): Promise<void> {
|
||||
for (let i = 0; i < count; i++) {
|
||||
const requestId = await sendChatCompletion(request, { model, prompt: `${label} ${i}`, apiKey });
|
||||
await waitForSpendLog(request, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe("Usage page activity tabs", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("Key Activity breaks a key's traffic down by model", async ({ page, request }) => {
|
||||
const alias = `e2e-usage-keyact-${Date.now()}`;
|
||||
const { key, token } = await createVirtualKey(request, { key_alias: alias });
|
||||
|
||||
// An uneven split, so a breakdown that lumps everything into one row or attributes to the
|
||||
// wrong model cannot land on these numbers by accident.
|
||||
await sendTraffic(request, key, CHAT_MODEL_A, 2, alias);
|
||||
await sendTraffic(request, key, CHAT_MODEL_B, 1, alias);
|
||||
await waitForKeyInDailyActivity(request, token, 3);
|
||||
|
||||
await openUsageTab(page, "Key Activity");
|
||||
|
||||
const card = entityCard(page, "Key Activity", alias);
|
||||
await expect(card, `${alias} missing from Key Activity`).toBeVisible({ timeout: 30_000 });
|
||||
await expect(card).toContainText("3 requests");
|
||||
|
||||
// Every key gets a card, and the page opens the first one. Scope to this key's own section,
|
||||
// which the collapsible renders as the trigger's next sibling.
|
||||
await card.click();
|
||||
const details = card.locator("xpath=following-sibling::*[1]");
|
||||
const successfulFor = (model: string) =>
|
||||
details.getByRole("row").filter({ hasText: model }).getByRole("cell").nth(2); // Model | Spend | Successful | Failed | Tokens
|
||||
|
||||
await expect(successfulFor(DEPLOYMENT_MODEL_A)).toHaveText("2", { timeout: 20_000 });
|
||||
await expect(successfulFor(DEPLOYMENT_MODEL_B)).toHaveText("1");
|
||||
});
|
||||
|
||||
test("Model Activity can name its models by deployment instead of by public name", async ({ page, request }) => {
|
||||
const alias = `e2e-usage-modelact-${Date.now()}`;
|
||||
const { key, token } = await createVirtualKey(request, { key_alias: alias });
|
||||
await sendTraffic(request, key, CHAT_MODEL_A, 1, alias);
|
||||
await waitForKeyInDailyActivity(request, token);
|
||||
|
||||
const panel = await openUsageTab(page, "Model Activity");
|
||||
|
||||
await expect(entityCard(page, "Model Activity", CHAT_MODEL_A), `${CHAT_MODEL_A} missing`).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Nothing is published under the deployment's name, so its absence here is what makes the
|
||||
// toggle below a real change of key rather than a relabelled button.
|
||||
await expect(entityCard(page, "Model Activity", DEPLOYMENT_MODEL_A)).toHaveCount(0);
|
||||
|
||||
// Admins reconcile provider bills against the deployment, not the name their users call.
|
||||
await panel.getByRole("button", { name: "Litellm Model Name" }).click();
|
||||
await expect(entityCard(page, "Model Activity", DEPLOYMENT_MODEL_A)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
test("Filter by user narrows Key Activity to that user's keys", async ({ page, request }) => {
|
||||
const stamp = Date.now();
|
||||
const email = `e2e-usage-owner-${stamp}@test.local`;
|
||||
const ownedAlias = `e2e-usage-owned-${stamp}`;
|
||||
const otherAlias = `e2e-usage-other-${stamp}`;
|
||||
|
||||
const userRes = await request.post(`${rootPath()}/user/new`, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}`, "Content-Type": "application/json" },
|
||||
data: { user_email: email, user_role: "internal_user", auto_create_key: false },
|
||||
});
|
||||
expect(userRes.ok(), `POST /user/new failed (${userRes.status()})`).toBe(true);
|
||||
const userId = (await userRes.json()).user_id as string;
|
||||
|
||||
const owned = await createVirtualKey(request, { key_alias: ownedAlias, user_id: userId });
|
||||
const other = await createVirtualKey(request, { key_alias: otherAlias });
|
||||
await sendTraffic(request, owned.key, CHAT_MODEL_A, 1, ownedAlias);
|
||||
await sendTraffic(request, other.key, CHAT_MODEL_A, 1, otherAlias);
|
||||
await waitForKeyInDailyActivity(request, owned.token);
|
||||
await waitForKeyInDailyActivity(request, other.token);
|
||||
|
||||
await openUsageTab(page, "Key Activity");
|
||||
await expect(entityCard(page, "Key Activity", otherAlias)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByRole("combobox", { name: "Search users by email" }).click();
|
||||
await page.keyboard.type(email);
|
||||
await page
|
||||
.getByRole("option", { name: new RegExp(email) })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// The filter earns its place only by dropping the other key; the owned key showing up
|
||||
// proves nothing on a page that already listed every key.
|
||||
await expect(entityCard(page, "Key Activity", otherAlias)).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(entityCard(page, "Key Activity", ownedAlias)).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue