mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
test(e2e/ui): cover the Logs page filter drawer (#39056)
The Logs page had coverage for opening a request and for the End User filter, but nothing for the filters an on-call engineer actually reaches for: whose key made the request, and which requests failed. Each test mints its own keys and asserts on request ids it generated itself, so a filter that quietly does nothing fails on the other key's row still being on screen rather than passing because our own row happens to be there. Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8a4ba78869
commit
7809eacb8b
1 changed files with 152 additions and 0 deletions
152
tests/e2e/ui/tests/logs/logsFilters.spec.ts
Normal file
152
tests/e2e/ui/tests/logs/logsFilters.spec.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { test, expect, type APIRequestContext, 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,
|
||||
createVirtualKey,
|
||||
sendChatCompletion,
|
||||
waitForSpendLog,
|
||||
} from "../../helpers/traffic";
|
||||
|
||||
/**
|
||||
* Every test mints its own key and asserts against request ids it generated, so a filter that
|
||||
* quietly does nothing shows up as the other key's row still being on screen, and concurrent
|
||||
* specs' traffic cannot decide the outcome.
|
||||
*/
|
||||
|
||||
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
/** Every tab stays mounted, so the DOM holds four tables at once; scope to the visible one. */
|
||||
const requestLogsRows = (page: PlaywrightPage): Locator =>
|
||||
page.locator("table").filter({ visible: true }).first().locator("tbody tr");
|
||||
|
||||
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
|
||||
|
||||
async function openLogs(page: PlaywrightPage): Promise<void> {
|
||||
await navigateToPage(page, Page.Logs);
|
||||
await dismissFeedbackPopup(page);
|
||||
await expect(visibleTestId(page, "datatable-search")).toBeVisible({ timeout: 20_000 });
|
||||
}
|
||||
|
||||
async function openFilterDrawer(page: PlaywrightPage): Promise<Locator> {
|
||||
await visibleTestId(page, "datatable-filters-trigger").click();
|
||||
const drawer = page.getByRole("dialog", { name: "Filters" });
|
||||
await expect(drawer).toBeVisible({ timeout: 10_000 });
|
||||
return drawer;
|
||||
}
|
||||
|
||||
/** Picks a value in one of the drawer's searchable comboboxes and applies the filter. */
|
||||
async function applyComboboxFilter(
|
||||
page: PlaywrightPage,
|
||||
drawer: Locator,
|
||||
comboboxLabel: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
await drawer.getByRole("combobox", { name: comboboxLabel }).click();
|
||||
await page.keyboard.type(value);
|
||||
await page.getByRole("option", { name: value, exact: true }).first().click();
|
||||
await drawer.getByRole("button", { name: "Apply Filters" }).click();
|
||||
await expect(drawer).not.toBeVisible({ timeout: 10_000 });
|
||||
}
|
||||
|
||||
/** A request the key is not entitled to make, so the proxy refuses it and logs the refusal. */
|
||||
async function sendDeniedCompletion(request: APIRequestContext, apiKey: string): Promise<void> {
|
||||
const res = await request.post("/v1/chat/completions", {
|
||||
headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
|
||||
data: { model: CHAT_MODEL_B, messages: [{ role: "user", content: "denied" }] },
|
||||
});
|
||||
expect(res.status(), "a model outside the key's allow-list is refused").toBe(403);
|
||||
}
|
||||
|
||||
test.describe("Logs page filters", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("the Key Alias filter narrows the table to that key's requests", async ({ page, request }) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const mine = await createVirtualKey(request, { key_alias: `e2e-logs-mine-${suffix}` });
|
||||
const theirs = await createVirtualKey(request, { key_alias: `e2e-logs-theirs-${suffix}` });
|
||||
|
||||
const myRequestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: `logs-filter-mine-${suffix}`,
|
||||
apiKey: mine.key,
|
||||
});
|
||||
const theirRequestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: `logs-filter-theirs-${suffix}`,
|
||||
apiKey: theirs.key,
|
||||
});
|
||||
await waitForSpendLog(request, myRequestId);
|
||||
await waitForSpendLog(request, theirRequestId);
|
||||
|
||||
await openLogs(page);
|
||||
const drawer = await openFilterDrawer(page);
|
||||
await applyComboboxFilter(page, drawer, "Search a key alias", mine.alias!);
|
||||
|
||||
await expect(requestLogsRows(page).filter({ hasText: myRequestId })).toHaveCount(1, { timeout: 30_000 });
|
||||
// The filter is only doing its job if the other key's request is gone, not merely if ours is present.
|
||||
await expect(requestLogsRows(page).filter({ hasText: theirRequestId })).toHaveCount(0, { timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the Status filter narrows the table to the refused request", async ({ page, request }) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const alias = `e2e-logs-status-${suffix}`;
|
||||
const scoped = await createVirtualKey(request, { key_alias: alias, models: [CHAT_MODEL_A] });
|
||||
|
||||
const servedRequestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: `logs-filter-served-${suffix}`,
|
||||
apiKey: scoped.key,
|
||||
});
|
||||
await sendDeniedCompletion(request, scoped.key);
|
||||
await waitForSpendLog(request, servedRequestId);
|
||||
|
||||
await openLogs(page);
|
||||
const drawer = await openFilterDrawer(page);
|
||||
await drawer.getByRole("combobox", { name: "Search a key alias" }).click();
|
||||
await page.keyboard.type(alias);
|
||||
await page.getByRole("option", { name: alias, exact: true }).first().click();
|
||||
// The Status field labels its group, not the trigger, so it is addressed by the value it shows.
|
||||
await drawer.getByRole("combobox").filter({ hasText: "All Statuses" }).click();
|
||||
await page.getByRole("option", { name: "Failure", exact: true }).click();
|
||||
await drawer.getByRole("button", { name: "Apply Filters" }).click();
|
||||
await expect(drawer).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Both requests were made by this key, so a Status filter that does nothing leaves the served one on screen.
|
||||
await expect(requestLogsRows(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(requestLogsRows(page)).toContainText("Failure");
|
||||
await expect(requestLogsRows(page).filter({ hasText: servedRequestId })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Reset Filters brings back the rows a filter hid", async ({ page, request }) => {
|
||||
const suffix = uniqueSuffix();
|
||||
const mine = await createVirtualKey(request, { key_alias: `e2e-logs-reset-mine-${suffix}` });
|
||||
const theirs = await createVirtualKey(request, { key_alias: `e2e-logs-reset-theirs-${suffix}` });
|
||||
|
||||
const myRequestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: `logs-reset-mine-${suffix}`,
|
||||
apiKey: mine.key,
|
||||
});
|
||||
const theirRequestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: `logs-reset-theirs-${suffix}`,
|
||||
apiKey: theirs.key,
|
||||
});
|
||||
await waitForSpendLog(request, myRequestId);
|
||||
await waitForSpendLog(request, theirRequestId);
|
||||
|
||||
await openLogs(page);
|
||||
const drawer = await openFilterDrawer(page);
|
||||
await applyComboboxFilter(page, drawer, "Search a key alias", mine.alias!);
|
||||
await expect(requestLogsRows(page).filter({ hasText: theirRequestId })).toHaveCount(0, { timeout: 30_000 });
|
||||
|
||||
// A filter you cannot clear is a page that looks empty forever, which is how it reads to a user.
|
||||
await page.getByRole("button", { name: "Reset Filters" }).filter({ visible: true }).click();
|
||||
|
||||
await expect(requestLogsRows(page).filter({ hasText: theirRequestId })).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(requestLogsRows(page).filter({ hasText: myRequestId })).toHaveCount(1, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue