mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
test(e2e): cover add-fallback flow in Router Settings (#29069)
* test(e2e): cover add-fallback flow in Router Settings as proxy admin The Router Settings → Fallbacks → Add Fallbacks flow was an uncovered manual-QA path. This adds a test that opens the modal, picks a primary + fallback from the seeded mock models, saves, and verifies both render in the fallback table. * fix(e2e): make router-fallback test idempotent and pick antd options by text - Match `.ant-select-item-option` by text instead of `getByTitle(...)` — FallbackGroupConfig uses `options=` (not <Select.Option> children), so no `title` attribute is emitted and the title-based selector hangs. - Add before/after hooks that wipe any fallback for fake-openai-gpt-4 via /config/update so retries and local reruns don't trip on leftover state. - Tighten the success assertion to a single tbody row containing BOTH the primary and the fallback names — pre-existing rows can no longer vacuously satisfy the check. - Fix the stale "Three tabs" comment to "Four tabs". Addresses Greptile P2s on PR #29069. * fix(e2e): keyboard-select fallback models + correct cleanup endpoint - Replace mouse-based option clicks with click-to-focus + type + Enter. FallbackGroupConfig's Selects use `options=` and a custom getPopupContainer, so locating options via `.ant-select-dropdown` hit several races: DOM-clicks left antd's popup state stale (the primary popup then intercepted the fallback click), `getByRole` matched always-mounted hidden options, and pointer stability fought the open animation. Typing into the showSearch input narrows the listbox to one option and Enter selects it cleanly. - Assert on dialog-side state changes (the active tab adopts the primary model name; the chain helper shows "1/10 used") instead of popup contents — these reflect the actual selection landing. - Cleanup helper now hits /get/config/callbacks (the real endpoint; /get/callbacks returns 404), so the before/after reset actually clears prior router_settings.fallbacks state.
This commit is contained in:
parent
7cae5dc08a
commit
b0ea013042
1 changed files with 102 additions and 0 deletions
|
|
@ -0,0 +1,102 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { Role, users } from "../../fixtures/users";
|
||||
|
||||
const PRIMARY = "fake-openai-gpt-4";
|
||||
const FALLBACK = "fake-anthropic-claude";
|
||||
|
||||
/**
|
||||
* Wipe any fallbacks for the primary model so the test is idempotent across
|
||||
* retries and local reruns (the proxy persists router_settings to the DB).
|
||||
*/
|
||||
async function clearFallbackForPrimary(request: import("@playwright/test").APIRequestContext) {
|
||||
const masterKey = users[Role.ProxyAdmin].password;
|
||||
const auth = { Authorization: `Bearer ${masterKey}` };
|
||||
|
||||
const current = await request.get("http://localhost:4000/get/config/callbacks", { headers: auth });
|
||||
if (!current.ok()) return;
|
||||
const body = await current.json();
|
||||
const router = body?.router_settings ?? {};
|
||||
const existing: Array<Record<string, string[]>> = Array.isArray(router.fallbacks) ? router.fallbacks : [];
|
||||
const next = existing.filter((entry) => !(entry && PRIMARY in entry));
|
||||
if (next.length === existing.length) return;
|
||||
|
||||
await request.post("http://localhost:4000/config/update", {
|
||||
headers: auth,
|
||||
data: { router_settings: { ...router, fallbacks: next } },
|
||||
});
|
||||
}
|
||||
|
||||
test.describe("Router Settings - Fallbacks", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await clearFallbackForPrimary(request);
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await clearFallbackForPrimary(request);
|
||||
});
|
||||
|
||||
test("Add a fallback and verify it appears in the table", async ({ page }) => {
|
||||
await navigateToPage(page, Page.RouterSettings);
|
||||
|
||||
// Four tabs: Loadbalancing / Routing Groups / Fallbacks / General — click Fallbacks
|
||||
await page.getByRole("tab", { name: "Fallbacks" }).click();
|
||||
|
||||
// The model options come from /model_group/info, which AddFallbacks
|
||||
// fires only after the modal mounts. Wait for that response so the
|
||||
// dropdown is populated before we try to pick from it — without this
|
||||
// the test races on CI (local SLOWMO masks the gap).
|
||||
const modelsLoaded = page.waitForResponse(
|
||||
(res) => res.url().includes("/model_group/info") && res.status() === 200,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByRole("button", { name: /Add Fallbacks/i }).click();
|
||||
await modelsLoaded;
|
||||
|
||||
const modal = page.locator(".ant-modal:visible");
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// FallbackGroupConfig.tsx renders both selects with `showSearch`. The
|
||||
// most stable interaction is: click to open + focus, type the model name to
|
||||
// narrow the listbox to a single highlighted option, then press Enter.
|
||||
// Verify each selection landed by watching the dialog's own state transition
|
||||
// (the tab title updates to the picked primary; the fallback chain list
|
||||
// populates) rather than by asserting on the dropdown popup, which sits in
|
||||
// a custom getPopupContainer and is awkward to scope reliably.
|
||||
const primarySelect = modal.locator(".ant-select").filter({ hasText: "Select primary model" });
|
||||
await primarySelect.click();
|
||||
await page.keyboard.type(PRIMARY);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(modal.getByRole("tab", { name: PRIMARY })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const fallbackSelect = modal.locator(".ant-select").filter({ hasText: "Select fallback models" });
|
||||
await fallbackSelect.click();
|
||||
await page.keyboard.type(FALLBACK);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.keyboard.press("Escape");
|
||||
// The Fallback Chain helper text reads "(N/10 used)"; once it ticks to 1 the
|
||||
// selection has been recorded.
|
||||
await expect(modal.getByText("(1/10 used)")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Save
|
||||
await modal.getByRole("button", { name: /Save All Configurations/i }).click();
|
||||
|
||||
// Success toast
|
||||
await expect(page.getByText(/fallback configuration\(s\) added successfully/i).first())
|
||||
.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Modal closes, and a single row contains BOTH the primary and the fallback
|
||||
// model — stronger than asserting each name appears somewhere in tbody,
|
||||
// which could be satisfied by leftover rows from prior runs.
|
||||
await expect(modal).not.toBeVisible({ timeout: 5_000 });
|
||||
|
||||
const newRow = page.locator("table tbody tr")
|
||||
.filter({ hasText: PRIMARY })
|
||||
.filter({ hasText: FALLBACK });
|
||||
await expect(newRow).toHaveCount(1, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue