diff --git a/tests/e2e/ui/tests/internal-user/internalUser.spec.ts b/tests/e2e/ui/tests/internal-user/internalUser.spec.ts index b8424b06115..26e34dd2fe5 100644 --- a/tests/e2e/ui/tests/internal-user/internalUser.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUser.spec.ts @@ -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); }); }); diff --git a/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts index c44305187f1..653e096b713 100644 --- a/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUserNoTeam.spec.ts @@ -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); }); }); diff --git a/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts b/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts index 68319154554..49e27a36673 100644 --- a/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts +++ b/tests/e2e/ui/tests/internal-user/internalUserWithTeams.spec.ts @@ -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); }); }); diff --git a/tests/e2e/ui/tests/internal-viewer/internalViewer.spec.ts b/tests/e2e/ui/tests/internal-viewer/internalViewer.spec.ts index 4de86c46398..dd40976341d 100644 --- a/tests/e2e/ui/tests/internal-viewer/internalViewer.spec.ts +++ b/tests/e2e/ui/tests/internal-viewer/internalViewer.spec.ts @@ -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 diff --git a/tests/e2e/ui/tests/logs/logs.spec.ts b/tests/e2e/ui/tests/logs/logs.spec.ts index 56a3d0f0109..610a88c6cd0 100644 --- a/tests/e2e/ui/tests/logs/logs.spec.ts +++ b/tests/e2e/ui/tests/logs/logs.spec.ts @@ -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, }); diff --git a/tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts b/tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts index 46799c8a18f..aa7cdf82498 100644 --- a/tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts +++ b/tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts @@ -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(); diff --git a/tests/e2e/ui/tests/migration/migratedPages.spec.ts b/tests/e2e/ui/tests/migration/migratedPages.spec.ts index 3ad4b217d08..547330190bd 100644 --- a/tests/e2e/ui/tests/migration/migratedPages.spec.ts +++ b/tests/e2e/ui/tests/migration/migratedPages.spec.ts @@ -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(); } diff --git a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts index dad716b4c83..84d1c01b452 100644 --- a/tests/e2e/ui/tests/modelsPage/addModel.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/addModel.spec.ts @@ -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/*"); diff --git a/tests/e2e/ui/tests/modelsPage/autoRouterTemplateSelect.spec.ts b/tests/e2e/ui/tests/modelsPage/autoRouterTemplateSelect.spec.ts index 1d080ec82b8..51df50a2e68 100644 --- a/tests/e2e/ui/tests/modelsPage/autoRouterTemplateSelect.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/autoRouterTemplateSelect.spec.ts @@ -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); }); }); diff --git a/tests/e2e/ui/tests/modelsPage/responsiveHeader.spec.ts b/tests/e2e/ui/tests/modelsPage/responsiveHeader.spec.ts index 6ad1ccb8451..aabdf18d427 100644 --- a/tests/e2e/ui/tests/modelsPage/responsiveHeader.spec.ts +++ b/tests/e2e/ui/tests/modelsPage/responsiveHeader.spec.ts @@ -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); }); }); diff --git a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts index 0c38641dcc7..f5bee68f245 100644 --- a/tests/e2e/ui/tests/proxy-admin/keys.spec.ts +++ b/tests/e2e/ui/tests/proxy-admin/keys.spec.ts @@ -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 }); diff --git a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts index f93cca75347..5116cb5df19 100644 --- a/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts +++ b/tests/e2e/ui/tests/team-admin/teamAdmin.spec.ts @@ -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. diff --git a/tests/e2e/ui/tests/usage/usagePage.spec.ts b/tests/e2e/ui/tests/usage/usagePage.spec.ts index 8fa59beb905..f61ab018e1b 100644 --- a/tests/e2e/ui/tests/usage/usagePage.spec.ts +++ b/tests/e2e/ui/tests/usage/usagePage.spec.ts @@ -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, diff --git a/tests/e2e/ui/tests/users/searchUsers.spec.ts b/tests/e2e/ui/tests/users/searchUsers.spec.ts index e87218b5a5e..fa8f32764e8 100644 --- a/tests/e2e/ui/tests/users/searchUsers.spec.ts +++ b/tests/e2e/ui/tests/users/searchUsers.spec.ts @@ -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); }); }); diff --git a/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts b/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts index 614191372d0..b46fb4d112a 100644 --- a/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts +++ b/tests/e2e/ui/tests/users/viewInternalUsers.spec.ts @@ -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(); }); }); diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index b0bb0e8a5b5..c3e1f924d09 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -213,6 +213,20 @@ describe("Sidebar (leftnav)", () => { expect(screen.getByText("Search Tools")).toBeInTheDocument(); }); }); + it("reports whether a nested tab is expanded", async () => { + renderWithProviders(); + + 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 diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 42389facac2..51ba36348e1 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -570,6 +570,7 @@ const Sidebar_: React.FC = ({ toggleGroup(item.key)} title={collapsed ? labelText(item) : undefined} > diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx index 5aee6b33ec5..6cd9f476628 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.test.tsx @@ -53,6 +53,24 @@ describe("SectionHeader", () => { expect(onToggleCollapse).toHaveBeenCalledTimes(1); }); + it("reports its collapsed state to assistive technology", () => { + const { rerender } = render( + , + ); + + expect(screen.getByRole("button", { name: /^Input/ })).toHaveAttribute("aria-expanded", "true"); + + rerender(); + + expect(screen.getByRole("button", { name: /^Input/ })).toHaveAttribute("aria-expanded", "false"); + }); + + it("names each copy button for the section it belongs to", () => { + render(); + + expect(screen.getByRole("button", { name: "Copy output" })).toBeInTheDocument(); + }); + it("stays inert when no toggle handler is given", async () => { const onCopy = vi.fn(); render(); diff --git a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx index 93e9953b2ee..6a18aaf8642 100644 --- a/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/LogDetailsDrawer/SectionHeader.tsx @@ -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 ? ( + + ) : ( + + ))} + +
+ {type === "input" ? ( + + ) : ( + + )} + {type === "input" ? "Input" : "Output"} +
+ + {tokens !== undefined && Tokens: {tokens.toLocaleString()}} + + {cost !== undefined && Cost: ${cost.toFixed(6)}} + + {turnCount !== undefined && turnCount > 0 && ( + Turns: {turnCount} + )} + + ); + return (
-
- {onToggleCollapse && - (isCollapsed ? ( - - ) : ( - - ))} - -
- {type === "input" ? ( - - ) : ( - - )} - {type === "input" ? "Input" : "Output"} -
- - {tokens !== undefined && ( - Tokens: {tokens.toLocaleString()} - )} - - {cost !== undefined && Cost: ${cost.toFixed(6)}} - - {turnCount !== undefined && turnCount > 0 && ( - Turns: {turnCount} - )} -
+ {onToggleCollapse ? ( + + ) : ( +
{summary}
+ )} { e.stopPropagation(); onCopy();