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:
yuneng-jiang 2026-08-31 17:34:30 -07:00 committed by GitHub
commit 68cfe1697b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 209 additions and 255 deletions

View file

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

View file

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

View file

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

View file

@ -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

View file

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

View file

@ -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();

View file

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

View file

@ -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/*");

View file

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

View file

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

View file

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

View file

@ -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.

View file

@ -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,

View file

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

View file

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

View file

@ -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

View file

@ -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}
>

View file

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

View file

@ -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();