mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-06 08:16:43 +00:00
Merge pull request #39016 from BerriAI/litellm_/flaky-e2e-tests-d022f6
test(e2e): assert user-observable behavior instead of DOM structure
This commit is contained in:
commit
68cfe1697b
19 changed files with 209 additions and 255 deletions
|
|
@ -22,8 +22,7 @@ test.describe("Internal User", () => {
|
|||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
|
||||
const dropdown = page.locator('[data-slot="combobox-content"]:visible');
|
||||
await expect(dropdown.getByText(E2E_TEAM_CRUD_ALIAS).first()).toBeVisible({ timeout: 5_000 });
|
||||
await expect(page.getByRole("option", { name: E2E_TEAM_CRUD_ALIAS }).first()).toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("Team info page omits the Settings tab for non-admin members", async ({ page }) => {
|
||||
|
|
@ -43,12 +42,12 @@ test.describe("Internal User", () => {
|
|||
|
||||
// Anchor on the user's own seeded key so the absence check below cannot
|
||||
// pass vacuously against an empty table.
|
||||
await expect(page.locator("table tbody").getByText(E2E_INTERNAL_USER_KEY_ALIAS).first()).toBeVisible({
|
||||
await expect(page.getByRole("row").filter({ hasText: E2E_INTERNAL_USER_KEY_ALIAS }).first()).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// The litellm-dashboard team is the proxy's internal bookkeeping team —
|
||||
// its keys must never leak into an internal user's Virtual Keys table.
|
||||
await expect(page.locator("table tbody").getByText("litellm-dashboard")).toHaveCount(0);
|
||||
await expect(page.getByRole("row").filter({ hasText: "litellm-dashboard" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -30,16 +30,13 @@ test.describe("Internal User with no team memberships", () => {
|
|||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
|
||||
const dropdown = page.locator('[data-slot="combobox-content"]:visible').first();
|
||||
await expect(dropdown).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Wait for the settled-empty state, not a transient one. The dropdown shows
|
||||
// "Loading teams…" while teams load and only swaps in "No teams found" once
|
||||
// the request resolves with nothing (team_dropdown.tsx passes both copies to
|
||||
// PaginatedSearchSelect). Asserting on it means a regression where teams DO
|
||||
// load for this user fails here instead of racing a one-shot count() against
|
||||
// an in-flight request.
|
||||
await expect(dropdown.getByText("No teams found")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(dropdown.getByRole("option")).toHaveCount(0);
|
||||
await expect(page.getByText("No teams found")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("option")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,13 +21,10 @@ test.describe("Internal User with team memberships", () => {
|
|||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
|
||||
const dropdown = page.locator('[data-slot="combobox-content"]:visible').first();
|
||||
await expect(dropdown).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// Both seeded memberships render, and nothing else does — proving the
|
||||
// dropdown is scoped to the user's teams rather than empty or unfiltered.
|
||||
await expect(dropdown.getByText(E2E_TEAM_CRUD_ALIAS, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(dropdown.getByText(E2E_TEAM_ORG_ALIAS, { exact: true })).toBeVisible();
|
||||
await expect(dropdown.getByRole("option")).toHaveCount(2);
|
||||
await expect(page.getByRole("option", { name: E2E_TEAM_CRUD_ALIAS })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("option", { name: E2E_TEAM_ORG_ALIAS })).toBeVisible();
|
||||
await expect(page.getByRole("option")).toHaveCount(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,9 +59,9 @@ test.describe("Internal Viewer", () => {
|
|||
await expect(page.getByRole("button", { name: /Create New Key/i })).toHaveCount(0);
|
||||
|
||||
// Open the viewer's own key info page
|
||||
const keyRow = page.locator("tr", { hasText: E2E_VIEWER_KEY_ALIAS });
|
||||
const keyRow = page.getByRole("row").filter({ hasText: E2E_VIEWER_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.locator("button").first().click();
|
||||
await keyRow.getByRole("button", { name: E2E_VIEWER_KEY_ALIAS }).click();
|
||||
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// None of the destructive / mutating actions should render
|
||||
|
|
|
|||
|
|
@ -11,12 +11,11 @@ import { CHAT_MODEL_A, MOCK_RESPONSE_TEXT, sendChatCompletion, waitForSpendLog }
|
|||
|
||||
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
/**
|
||||
* Walking up from the label is the only stable handle: the header carries no role, test id or class,
|
||||
* and its copy button is icon-only with a hover-only tooltip.
|
||||
*/
|
||||
const sectionHeader = (drawer: Locator, label: "Input" | "Output"): Locator =>
|
||||
drawer.getByText(label, { exact: true }).locator("xpath=../../..");
|
||||
const sectionToggle = (drawer: Locator, label: "Input" | "Output"): Locator =>
|
||||
drawer.getByRole("button", { name: new RegExp(`^${label}\\b`) });
|
||||
|
||||
const sectionCopy = (drawer: Locator, label: "Input" | "Output"): Locator =>
|
||||
drawer.getByRole("button", { name: `Copy ${label.toLowerCase()}` });
|
||||
|
||||
/** Every tab stays mounted, so the DOM holds four tables at once; scope to the visible one. */
|
||||
const requestLogsRows = (page: PlaywrightPage): Locator =>
|
||||
|
|
@ -95,14 +94,14 @@ test.describe("Logs page", () => {
|
|||
await expect(drawer).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Copy request: the Input card's copy button puts the prompt on the clipboard.
|
||||
await sectionHeader(drawer, "Input").getByRole("button").click();
|
||||
await sectionCopy(drawer, "Input").click();
|
||||
await expect(page.getByText("Input copied")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(prompt);
|
||||
|
||||
// Copy response: the Output card's copy button puts the completion on it.
|
||||
await sectionHeader(drawer, "Output").getByRole("button").click();
|
||||
await sectionCopy(drawer, "Output").click();
|
||||
await expect(page.getByText("Output copied")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
|
@ -125,24 +124,15 @@ test.describe("Logs page", () => {
|
|||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The body collapses via `max-height: 0; overflow: hidden`, which zeroes its own bounding
|
||||
// box, so the wrapper reads as hidden while the clipped text node inside it does not.
|
||||
const header = sectionHeader(drawer, "Input");
|
||||
const body = header.locator("xpath=following-sibling::div[1]");
|
||||
await expect(header.locator(".lucide-chevron-up")).toBeVisible();
|
||||
await expect(body).toBeVisible();
|
||||
const toggle = sectionToggle(drawer, "Input");
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
await expect(drawer.getByText(prompt, { exact: false })).toBeVisible();
|
||||
|
||||
await header.click();
|
||||
await expect(header.locator(".lucide-chevron-down")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(body).toBeHidden({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "false", { timeout: 10_000 });
|
||||
|
||||
await header.click();
|
||||
await expect(header.locator(".lucide-chevron-up")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(body).toBeVisible({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
await expect(toggle).toHaveAttribute("aria-expanded", "true", { timeout: 10_000 });
|
||||
await expect(drawer.getByText(prompt, { exact: false })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ test.describe("MCP Servers - edit and delete", () => {
|
|||
test("Deleting a server removes it", async ({ page }) => {
|
||||
expect(await findServerByName(page, serverName), `created server ${serverName} exists`).toBeTruthy();
|
||||
|
||||
const card = page.getByTestId("mcp-servers-grid").locator("div").filter({ hasText: serverName }).first();
|
||||
const card = page.getByTestId("mcp-servers-grid").getByRole("button", { name: serverName });
|
||||
await card.getByRole("button", { name: "Server actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
|
||||
|
|
|
|||
|
|
@ -35,17 +35,12 @@ async function expectRendered(page: Page) {
|
|||
*/
|
||||
async function clickSidebar(page: Page, segment: string) {
|
||||
const link = sidebar(page).locator(`a[href$="/ui/${segment}"]`).first();
|
||||
const collapsedGroups = sidebar(page).getByRole("button", { expanded: false });
|
||||
for (let i = 0; i < 8 && !(await link.isVisible().catch(() => false)); i++) {
|
||||
// A collapsed group is a menu item with a group-toggle button but no
|
||||
// rendered submenu yet; clicking the toggle expands it.
|
||||
const collapsedGroup = sidebar(page)
|
||||
.locator(
|
||||
'[data-slot="sidebar-menu-item"]:has(> [data-slot="sidebar-menu-button"]):not(:has(> [data-slot="sidebar-menu-sub"])) > [data-slot="sidebar-menu-button"]',
|
||||
)
|
||||
.first();
|
||||
if (!(await collapsedGroup.isVisible().catch(() => false))) break;
|
||||
await collapsedGroup.click();
|
||||
await page.waitForTimeout(250);
|
||||
const stillCollapsed = await collapsedGroups.count();
|
||||
if (stillCollapsed === 0) break;
|
||||
await collapsedGroups.first().click();
|
||||
await expect(collapsedGroups).toHaveCount(stillCollapsed - 1);
|
||||
}
|
||||
await link.click();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ test.describe("Add Model", () => {
|
|||
await expect(resultsModal).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
await page.getByTestId("add-model-btn").click();
|
||||
});
|
||||
expect(created.model_name, "the model is created under the name that was typed").toBe(publicName);
|
||||
expect(created.litellm_params?.api_base, "the api base survives the form").toBe(MOCK_LLM_BASE);
|
||||
|
|
@ -254,7 +254,7 @@ test.describe("Add Model", () => {
|
|||
|
||||
// Click Add Model button by its text
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
await page.getByTestId("add-model-btn").click();
|
||||
});
|
||||
// The form sends custom_llm_provider separately from the name, so both halves have to arrive.
|
||||
expect(created.model_name, "the selected model is what goes on the wire").toBe("claude-haiku-4-5");
|
||||
|
|
@ -267,11 +267,9 @@ test.describe("Add Model", () => {
|
|||
// Navigate to All Models tab
|
||||
await page.getByRole("tab", { name: "All Models" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Search for the model we just added
|
||||
await page.getByPlaceholder("Search model names").fill("claude-haiku-4-5");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the model appears in the results count (not "Showing 0 results")
|
||||
await expect(page.getByTestId("pagination-range")).toHaveText(/Showing \d+-\d+ of \d+/, {
|
||||
|
|
@ -279,8 +277,9 @@ test.describe("Add Model", () => {
|
|||
});
|
||||
|
||||
// Verify the model name appears in the table body
|
||||
const tableBody = page.locator("table tbody");
|
||||
await expect(tableBody.getByText("claude-haiku-4-5").first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("row").filter({ hasText: "claude-haiku-4-5" })).not.toHaveCount(0, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// A row proves the name is there, not what the deployment routes to.
|
||||
const stored = await findDeploymentByName(page, "claude-haiku-4-5");
|
||||
|
|
@ -333,11 +332,11 @@ test.describe("Add Model", () => {
|
|||
const teamDropdown = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await expect(teamDropdown).toBeVisible({ timeout: 5_000 });
|
||||
await teamDropdown.click();
|
||||
const teamOption = page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ID).first();
|
||||
const teamOption = page.getByRole("option", { name: E2E_TEAM_CRUD_ID }).first();
|
||||
await expect(teamOption).toBeVisible({ timeout: 5_000 });
|
||||
await teamOption.click();
|
||||
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
await page.getByTestId("add-model-btn").click();
|
||||
|
||||
// Scope to the toast container so a stale toast can't satisfy this.
|
||||
await expect(page.locator("[data-sonner-toast]").getByText("created successfully").last()).toBeVisible({
|
||||
|
|
@ -347,12 +346,9 @@ test.describe("Add Model", () => {
|
|||
// The Models table renders team-scoped models with the team id in the row.
|
||||
await page.getByRole("tab", { name: "All Models" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
// networkidle fires before the table finishes re-rendering.
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.getByPlaceholder("Search model names").fill("cohere");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
|
||||
// Clearer failure than timing out on a row assertion when the table is empty.
|
||||
await expect(page.getByTestId("pagination-range")).toHaveText(/Showing \d+-\d+ of \d+/, {
|
||||
timeout: 15_000,
|
||||
|
|
@ -361,7 +357,7 @@ test.describe("Add Model", () => {
|
|||
// Pin to one row carrying both the name and the team, so the sibling test's
|
||||
// team-less cohere row can't satisfy it.
|
||||
const teamCohereRow = page
|
||||
.locator("table tbody tr")
|
||||
.getByRole("row")
|
||||
.filter({ hasText: "cohere/" })
|
||||
.filter({ hasText: E2E_TEAM_CRUD_ID });
|
||||
await expect(teamCohereRow).toHaveCount(1, { timeout: 15_000 });
|
||||
|
|
@ -387,7 +383,7 @@ test.describe("Add Model", () => {
|
|||
|
||||
// Click Add Model button by its text
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
await page.getByTestId("add-model-btn").click();
|
||||
});
|
||||
// A wildcard with the star stripped becomes a plain "cohere" deployment that matches nothing.
|
||||
expect(created.model_name, "the wildcard route goes on the wire intact").toBe("cohere/*");
|
||||
|
|
@ -398,11 +394,9 @@ test.describe("Add Model", () => {
|
|||
// Navigate to All Models tab
|
||||
await page.getByRole("tab", { name: "All Models" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Search for the wildcard model
|
||||
await page.getByPlaceholder("Search model names").fill("cohere");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify the model appears in the results count (not "Showing 0 results")
|
||||
await expect(page.getByTestId("pagination-range")).toHaveText(/Showing \d+-\d+ of \d+/, {
|
||||
|
|
@ -410,8 +404,7 @@ test.describe("Add Model", () => {
|
|||
});
|
||||
|
||||
// Verify the wildcard model appears in the table body (wildcard models show as "cohere/*")
|
||||
const tableBody = page.locator("table tbody");
|
||||
await expect(tableBody.getByText("cohere/").first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("row").filter({ hasText: "cohere/" })).not.toHaveCount(0, { timeout: 15_000 });
|
||||
|
||||
// "cohere/" in the table also matches a plain cohere deployment; require the wildcard exactly.
|
||||
const stored = await findDeploymentByName(page, "cohere/*");
|
||||
|
|
|
|||
|
|
@ -17,49 +17,54 @@ async function openTemplateSelect(page: PlaywrightPage) {
|
|||
return trigger;
|
||||
}
|
||||
|
||||
function pollPixelsBelowTrigger(trigger: Locator, popup: Locator) {
|
||||
async function boxes(trigger: Locator, options: Locator) {
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
const optionsBox = await options.boundingBox();
|
||||
return triggerBox && optionsBox ? { triggerBox, optionsBox } : null;
|
||||
}
|
||||
|
||||
const clippedPopup = (page: PlaywrightPage) => page.locator('[data-slot="select-content"]');
|
||||
|
||||
function pollOptionsOpenBelowTrigger(trigger: Locator, options: Locator) {
|
||||
return expect.poll(async () => {
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
const popupBox = await popup.boundingBox();
|
||||
if (!triggerBox || !popupBox) return null;
|
||||
return popupBox.y - (triggerBox.y + triggerBox.height);
|
||||
const box = await boxes(trigger, options);
|
||||
return box && box.optionsBox.y >= box.triggerBox.y + box.triggerBox.height;
|
||||
});
|
||||
}
|
||||
|
||||
function pollPopupOverlapsTrigger(trigger: Locator, popup: Locator) {
|
||||
function pollOptionsCoverTrigger(trigger: Locator, options: Locator) {
|
||||
return expect.poll(async () => {
|
||||
const triggerBox = await trigger.boundingBox();
|
||||
const popupBox = await popup.boundingBox();
|
||||
if (!triggerBox || !popupBox) return null;
|
||||
return popupBox.y < triggerBox.y + triggerBox.height && popupBox.y + popupBox.height > triggerBox.y;
|
||||
const box = await boxes(trigger, options);
|
||||
return (
|
||||
box &&
|
||||
box.optionsBox.y < box.triggerBox.y + box.triggerBox.height &&
|
||||
box.optionsBox.y + box.optionsBox.height > box.triggerBox.y
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Auto Router template select anchoring", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("opens the options below the trigger rather than over it", async ({ page }) => {
|
||||
test("opens the options below the trigger when there is room below it", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 900 });
|
||||
const trigger = await openTemplateSelect(page);
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
|
||||
await trigger.click();
|
||||
const popup = page.locator('[data-slot="select-content"]');
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
|
||||
// Item-aligned mode reports "none" and puts the active item over the trigger.
|
||||
await expect(popup).toHaveAttribute("data-side", "bottom");
|
||||
await pollPixelsBelowTrigger(trigger, popup).toBeGreaterThanOrEqual(0);
|
||||
await pollOptionsOpenBelowTrigger(trigger, clippedPopup(page)).toBe(true);
|
||||
});
|
||||
|
||||
test("flips above the trigger instead of covering it when there is no room below", async ({ page }) => {
|
||||
test("keeps the trigger uncovered when the options open with no room below it", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1280, height: 560 });
|
||||
const trigger = await openTemplateSelect(page);
|
||||
await trigger.scrollIntoViewIfNeeded();
|
||||
|
||||
await trigger.click();
|
||||
const popup = page.locator('[data-slot="select-content"]');
|
||||
await expect(popup).toBeVisible();
|
||||
await expect(page.getByRole("listbox")).toBeVisible();
|
||||
|
||||
await pollPopupOverlapsTrigger(trigger, popup).toBe(false);
|
||||
await pollOptionsCoverTrigger(trigger, clippedPopup(page)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,9 +7,7 @@ test.describe("Models and Endpoints responsive header", () => {
|
|||
viewport: { width: 900, height: 720 },
|
||||
});
|
||||
|
||||
test("keeps the refresh action on the same row as the tabs", async ({
|
||||
page,
|
||||
}) => {
|
||||
test("keeps the refresh action on the same row as the tabs", async ({ page }) => {
|
||||
await page.goto("/ui");
|
||||
await page
|
||||
.getByRole("complementary")
|
||||
|
|
@ -26,8 +24,8 @@ test.describe("Models and Endpoints responsive header", () => {
|
|||
expect(tabsBox).not.toBeNull();
|
||||
expect(refreshBox).not.toBeNull();
|
||||
|
||||
const tabsCenterY = tabsBox!.y + tabsBox!.height / 2;
|
||||
const refreshCenterY = refreshBox!.y + refreshBox!.height / 2;
|
||||
expect(Math.abs(tabsCenterY - refreshCenterY)).toBeLessThanOrEqual(2);
|
||||
const sharesARow = refreshCenterY > tabsBox!.y && refreshCenterY < tabsBox!.y + tabsBox!.height;
|
||||
expect(sharesARow, "refresh wrapped onto its own row below the tabs").toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
|
||||
await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click();
|
||||
await page.getByRole("option", { name: E2E_TEAM_CRUD_ALIAS }).first().click();
|
||||
|
||||
// Select models — the popup is portaled to the body, so scope options to the page.
|
||||
await page.getByRole("combobox", { name: "Select models" }).click();
|
||||
|
|
@ -74,10 +74,9 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
const before = await findKeyByAlias(page, E2E_REGENERATE_KEY_ALIAS);
|
||||
expect(before?.token, `seeded key ${E2E_REGENERATE_KEY_ALIAS} has a token`).toBeTruthy();
|
||||
|
||||
// Key IDs are rendered as buttons in the table
|
||||
const keyRow = page.locator("tr", { hasText: E2E_REGENERATE_KEY_ALIAS });
|
||||
const keyRow = page.getByRole("row").filter({ hasText: E2E_REGENERATE_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.locator("button").first().click();
|
||||
await keyRow.getByRole("button", { name: E2E_REGENERATE_KEY_ALIAS }).click();
|
||||
|
||||
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
|
@ -109,9 +108,9 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
const before = await findKeyByAlias(page, E2E_UPDATE_LIMITS_KEY_ALIAS);
|
||||
expect(before, `seeded key ${E2E_UPDATE_LIMITS_KEY_ALIAS} exists`).toBeTruthy();
|
||||
|
||||
const keyRow = page.locator("tr", { hasText: E2E_UPDATE_LIMITS_KEY_ALIAS });
|
||||
const keyRow = page.getByRole("row").filter({ hasText: E2E_UPDATE_LIMITS_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.locator("button").first().click();
|
||||
await keyRow.getByRole("button", { name: E2E_UPDATE_LIMITS_KEY_ALIAS }).click();
|
||||
|
||||
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
|
@ -147,9 +146,9 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
await navigateToPage(page, Page.ApiKeys);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
const keyRow = page.locator("tr", { hasText: E2E_DELETE_KEY_ALIAS });
|
||||
const keyRow = page.getByRole("row").filter({ hasText: E2E_DELETE_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.locator("button").first().click();
|
||||
await keyRow.getByRole("button", { name: E2E_DELETE_KEY_ALIAS }).click();
|
||||
|
||||
await expect(page.getByText("Back to Keys")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ test.describe("Team Admin", () => {
|
|||
const teamSelect = page.getByTestId("team-dropdown").getByRole("combobox");
|
||||
await teamSelect.click();
|
||||
await page.keyboard.type(E2E_TEAM_CRUD_ALIAS);
|
||||
await page.locator('[data-slot="combobox-content"]:visible').getByText(E2E_TEAM_CRUD_ALIAS).first().click();
|
||||
await page.getByRole("option", { name: E2E_TEAM_CRUD_ALIAS }).first().click();
|
||||
|
||||
// Models — pick "All Team Models". The popup is portaled to the body, so
|
||||
// scope the option lookup to the page.
|
||||
|
|
|
|||
|
|
@ -51,20 +51,19 @@ test.describe("Usage page", () => {
|
|||
const card = await openUsage(page);
|
||||
|
||||
// Table view (the default): the key is listed by its alias.
|
||||
const row = card.locator("tbody tr").filter({ hasText: alias });
|
||||
const row = card.getByRole("row").filter({ hasText: alias });
|
||||
await expect(row, `${alias} missing from Top Virtual Keys`).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Chart view swaps the table out for the bar chart, and back.
|
||||
await card.getByText("Chart View", { exact: true }).click();
|
||||
await expect(card.locator("tbody tr")).toHaveCount(0, { timeout: 10_000 });
|
||||
await expect(card.getByRole("table")).toHaveCount(0, { timeout: 10_000 });
|
||||
await card.getByText("Table View", { exact: true }).click();
|
||||
await expect(row).toHaveCount(1, { timeout: 10_000 });
|
||||
|
||||
// Clicking the Key ID cell fetches key info and opens the detail panel.
|
||||
// The alias is already in the row behind the modal, so match the panel's own controls.
|
||||
await row.locator("td").first().click();
|
||||
await row.getByRole("button", { name: token }).click();
|
||||
const keyInfo = page.getByRole("tab", { name: "Overview", exact: true });
|
||||
await expect(keyInfo, "key info panel did not open").toBeVisible({
|
||||
timeout: 20_000,
|
||||
|
|
|
|||
|
|
@ -1,91 +1,52 @@
|
|||
import { test, expect, Page } from "@playwright/test";
|
||||
import { test, expect, Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
test.skip("Internal Users Search", () => {
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
|
||||
const userRows = (page: PlaywrightPage) => page.getByRole("row").filter({ has: page.getByRole("cell") });
|
||||
|
||||
async function goToInternalUsers(page: PlaywrightPage) {
|
||||
await navigateToPage(page, Page.Users);
|
||||
await expect(page.getByRole("columnheader", { name: "User ID" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userRows(page)).not.toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
test.describe("Internal Users Search", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
async function goToInternalUsers(page: Page) {
|
||||
await page.goto("/ui");
|
||||
|
||||
const tab = page.getByRole("menuitem", { name: "Internal User" });
|
||||
await expect(tab).toBeVisible();
|
||||
await tab.click();
|
||||
|
||||
await expect(page.locator("tbody tr").first()).toBeVisible();
|
||||
await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("can search users by email", async ({ page }) => {
|
||||
test("narrows the table to the matching email, and restores it when cleared", async ({ page }) => {
|
||||
await goToInternalUsers(page);
|
||||
|
||||
const rows = page.locator("tbody tr");
|
||||
const searchInput = page.getByPlaceholder("Search by email...");
|
||||
const search = page.getByPlaceholder("Search by email…");
|
||||
await expect(search).toBeVisible();
|
||||
|
||||
await expect(searchInput).toBeVisible();
|
||||
await search.fill("noteam@");
|
||||
await expect(userRows(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(userRows(page).first()).toContainText("noteam@test.local");
|
||||
|
||||
// Ensure initial data is loaded
|
||||
const initialCount = await rows.count();
|
||||
expect(initialCount).toBeGreaterThan(0);
|
||||
|
||||
// 🔹 Apply filter + wait for backend response
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes("/user/list") &&
|
||||
res.url().includes("user_email=test%40") && // encoded "test@"
|
||||
res.status() === 200,
|
||||
),
|
||||
searchInput.fill("test@"),
|
||||
]);
|
||||
await page.waitForTimeout(5000);
|
||||
const filteredCount = await rows.count();
|
||||
await expect(filteredCount).toBeLessThan(initialCount);
|
||||
|
||||
// 🔹 Clear filter + wait for unfiltered request
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) => res.url().includes("/user/list") && !res.url().includes("user_email=") && res.status() === 200,
|
||||
),
|
||||
searchInput.clear(),
|
||||
]);
|
||||
|
||||
const resetCount = await rows.count();
|
||||
await expect(resetCount).toBe(initialCount);
|
||||
await search.clear();
|
||||
await expect(userRows(page).filter({ hasText: "admin@test.local" })).not.toHaveCount(0, { timeout: 30_000 });
|
||||
});
|
||||
|
||||
test("can filter users by user ID and SSO ID", async ({ page }) => {
|
||||
test("filters the table down to one user by user ID", async ({ page }) => {
|
||||
await goToInternalUsers(page);
|
||||
const rows = page.locator("tbody tr");
|
||||
|
||||
// Ensure initial data is loaded
|
||||
const initialCount = await rows.count();
|
||||
expect(initialCount).toBeGreaterThan(0);
|
||||
await page.getByRole("button", { name: "Filters" }).click();
|
||||
await page.getByTestId("users-filter-user-id").fill("e2e-internal-noteam");
|
||||
await page.getByTestId("filter-drawer-apply").click();
|
||||
|
||||
const filtersButton = page.getByRole("button", {
|
||||
name: "Filters",
|
||||
exact: true,
|
||||
});
|
||||
await filtersButton.click();
|
||||
await expect(userRows(page)).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(userRows(page).first()).toContainText("noteam@test.local");
|
||||
});
|
||||
|
||||
const userIdInput = page.getByPlaceholder("Filter by User ID");
|
||||
const ssoIdInput = page.getByPlaceholder("Filter by SSO ID");
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) => res.url().includes("/user/list") && res.url().includes("user_ids=user") && res.status() === 200,
|
||||
),
|
||||
userIdInput.fill("user"),
|
||||
]);
|
||||
test("shows no users when the SSO ID matches nobody", async ({ page }) => {
|
||||
await goToInternalUsers(page);
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse(
|
||||
(res) =>
|
||||
res.url().includes("/user/list") &&
|
||||
res.url().includes("user_ids=user") &&
|
||||
res.url().includes("sso_user_ids=sso") &&
|
||||
res.status() === 200,
|
||||
),
|
||||
ssoIdInput.fill("sso"),
|
||||
]);
|
||||
const combinedFilteredCount = await rows.count();
|
||||
await expect(combinedFilteredCount).toBeLessThan(initialCount);
|
||||
await page.getByRole("button", { name: "Filters" }).click();
|
||||
await page.getByTestId("users-filter-sso-id").fill("e2e-sso-id-that-matches-nobody");
|
||||
await page.getByTestId("filter-drawer-apply").click();
|
||||
|
||||
await expect(page.getByText("No users found")).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userRows(page).filter({ hasText: "noteam@test.local" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,54 +1,29 @@
|
|||
import { test, expect, Page } from "@playwright/test";
|
||||
import { test, expect, Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
|
||||
test.skip("Internal Users Page", () => {
|
||||
async function goToInternalUsers(page: PlaywrightPage) {
|
||||
await navigateToPage(page, Page.Users);
|
||||
await expect(page.getByRole("columnheader", { name: "User ID" })).toBeVisible({ timeout: 30_000 });
|
||||
await expect(userRows(page)).not.toHaveCount(0, { timeout: 30_000 });
|
||||
}
|
||||
|
||||
const userRows = (page: PlaywrightPage) => page.getByRole("row").filter({ has: page.getByRole("cell") });
|
||||
|
||||
test.describe("Internal Users Page", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
async function goToInternalUsers(page: Page) {
|
||||
await page.goto("/ui");
|
||||
|
||||
const internalUserTab = page.getByRole("menuitem", { name: "Internal User" });
|
||||
await expect(internalUserTab).toBeVisible();
|
||||
await internalUserTab.click();
|
||||
|
||||
const firstRow = page.locator("tbody tr").first();
|
||||
await expect(firstRow).toBeVisible();
|
||||
await expect(page.locator('[data-slot="skeleton"]')).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("renders internal users table correctly", async ({ page }) => {
|
||||
test("lists the seeded users under the identifying columns", async ({ page }) => {
|
||||
await goToInternalUsers(page);
|
||||
|
||||
const rows = page.locator("tbody tr");
|
||||
const rowCount = await rows.count();
|
||||
expect(rowCount).toBeGreaterThan(0);
|
||||
|
||||
const userIdHeader = page.getByRole("columnheader", { name: "User ID" });
|
||||
await expect(userIdHeader).toBeVisible();
|
||||
|
||||
const virtualKeysHeader = page.getByRole("columnheader", { name: "Virtual Keys" });
|
||||
await expect(virtualKeysHeader).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: "User ID" })).toBeVisible();
|
||||
await expect(page.getByRole("columnheader", { name: "Virtual Keys" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("pagination controls work correctly", async ({ page }) => {
|
||||
test("cannot page backwards off the first page", async ({ page }) => {
|
||||
await goToInternalUsers(page);
|
||||
|
||||
const paginationInfo = page.locator(".text-sm.text-gray-700");
|
||||
const prevButton = page.getByRole("button", { name: "Previous" });
|
||||
const nextButton = page.getByRole("button", { name: "Next" });
|
||||
|
||||
const infoText = (await paginationInfo.textContent()) || "";
|
||||
|
||||
// On first page, Previous should be disabled
|
||||
if (infoText.includes("1 -")) {
|
||||
await expect(prevButton).toBeDisabled();
|
||||
}
|
||||
|
||||
await page.waitForTimeout(1000);
|
||||
// Check if there are more pages
|
||||
const hasMorePages = infoText.includes("of") && !infoText.endsWith("25 of 25");
|
||||
if (hasMorePages) {
|
||||
await expect(nextButton).toBeEnabled();
|
||||
}
|
||||
await expect(page.getByRole("button", { name: "Go to previous page" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -213,6 +213,20 @@ describe("Sidebar (leftnav)", () => {
|
|||
expect(screen.getByText("Search Tools")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
it("reports whether a nested tab is expanded", async () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
const toggle = screen.getByText("Tools").closest("button")!;
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(toggle);
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(toggle).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps Router Settings as a single Settings child", () => {
|
||||
// Router Settings is admin-only, so getAvailablePages() filters it out entirely and the
|
||||
// page_utils duplicate-key guard cannot see it. Walk menuGroups directly, otherwise a
|
||||
|
|
|
|||
|
|
@ -570,6 +570,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
<SidebarMenuItem key={item.key}>
|
||||
<SidebarMenuButton
|
||||
isActive={active}
|
||||
aria-expanded={open}
|
||||
onClick={() => toggleGroup(item.key)}
|
||||
title={collapsed ? labelText(item) : undefined}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -53,6 +53,24 @@ describe("SectionHeader", () => {
|
|||
expect(onToggleCollapse).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports its collapsed state to assistive technology", () => {
|
||||
const { rerender } = render(
|
||||
<SectionHeader type="input" onCopy={vi.fn()} onToggleCollapse={vi.fn()} isCollapsed={false} />,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Input/ })).toHaveAttribute("aria-expanded", "true");
|
||||
|
||||
rerender(<SectionHeader type="input" onCopy={vi.fn()} onToggleCollapse={vi.fn()} isCollapsed={true} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: /^Input/ })).toHaveAttribute("aria-expanded", "false");
|
||||
});
|
||||
|
||||
it("names each copy button for the section it belongs to", () => {
|
||||
render(<SectionHeader type="output" onCopy={vi.fn()} onToggleCollapse={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Copy output" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("stays inert when no toggle handler is given", async () => {
|
||||
const onCopy = vi.fn();
|
||||
render(<SectionHeader type="input" onCopy={onCopy} />);
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ interface SectionHeaderProps {
|
|||
turnCount?: number;
|
||||
}
|
||||
|
||||
const SUMMARY_CLASSES = "flex flex-1 items-center gap-4";
|
||||
|
||||
export function SectionHeader({
|
||||
type,
|
||||
tokens,
|
||||
|
|
@ -26,42 +28,53 @@ export function SectionHeader({
|
|||
onToggleCollapse,
|
||||
turnCount,
|
||||
}: SectionHeaderProps) {
|
||||
const summary = (
|
||||
<>
|
||||
{onToggleCollapse &&
|
||||
(isCollapsed ? (
|
||||
<ChevronDown className="size-2.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronUp className="size-2.5 text-muted-foreground" />
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{type === "input" ? (
|
||||
<MessageSquare className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="text-sm opacity-60 grayscale">✨</span>
|
||||
)}
|
||||
<span className="text-sm font-medium">{type === "input" ? "Input" : "Output"}</span>
|
||||
</div>
|
||||
|
||||
{tokens !== undefined && <span className="text-xs text-muted-foreground">Tokens: {tokens.toLocaleString()}</span>}
|
||||
|
||||
{cost !== undefined && <span className="text-xs text-muted-foreground">Cost: ${cost.toFixed(6)}</span>}
|
||||
|
||||
{turnCount !== undefined && turnCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">Turns: {turnCount}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onToggleCollapse}
|
||||
className={cn(
|
||||
"flex items-center justify-between bg-muted px-4 py-2.5 transition-colors",
|
||||
isCollapsed ? "border-b-0" : "border-b border-border",
|
||||
onToggleCollapse ? "cursor-pointer hover:bg-accent" : "cursor-default",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{onToggleCollapse &&
|
||||
(isCollapsed ? (
|
||||
<ChevronDown className="size-2.5 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronUp className="size-2.5 text-muted-foreground" />
|
||||
))}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{type === "input" ? (
|
||||
<MessageSquare className="size-3.5 text-muted-foreground" />
|
||||
) : (
|
||||
<span className="text-sm opacity-60 grayscale">✨</span>
|
||||
)}
|
||||
<span className="text-sm font-medium">{type === "input" ? "Input" : "Output"}</span>
|
||||
</div>
|
||||
|
||||
{tokens !== undefined && (
|
||||
<span className="text-xs text-muted-foreground">Tokens: {tokens.toLocaleString()}</span>
|
||||
)}
|
||||
|
||||
{cost !== undefined && <span className="text-xs text-muted-foreground">Cost: ${cost.toFixed(6)}</span>}
|
||||
|
||||
{turnCount !== undefined && turnCount > 0 && (
|
||||
<span className="text-xs text-muted-foreground">Turns: {turnCount}</span>
|
||||
)}
|
||||
</div>
|
||||
{onToggleCollapse ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapse}
|
||||
aria-expanded={!isCollapsed}
|
||||
className={cn(SUMMARY_CLASSES, "-mx-2 cursor-pointer rounded-md px-2 py-1 text-left hover:bg-accent")}
|
||||
>
|
||||
{summary}
|
||||
</button>
|
||||
) : (
|
||||
<div className={SUMMARY_CLASSES}>{summary}</div>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
|
|
@ -69,7 +82,7 @@ export function SectionHeader({
|
|||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label="Copy"
|
||||
aria-label={type === "input" ? "Copy input" : "Copy output"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onCopy();
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue