mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-11 22:51:28 +00:00
test(ui): add typed e2e for Router Settings Loadbalancing save (LIT-4057)
Drives the real save flow against a live proxy: seeds a present routing_groups array (the LIT-4057 trigger) via the typed /config/update contract, changes num_retries on the Loadbalancing tab, and asserts the POST returns 200 instead of 422, the success toast appears, and the value still shows after a reload (the ticket's "refresh shows old values" symptom). The round-trip is typed against the OpenAPI-generated backend schema (ConfigYAML write, RouterSettingsResponse read) through a type-only import, so a backend contract drift fails the type check.
This commit is contained in:
parent
30141f86f8
commit
540c860a97
1 changed files with 84 additions and 0 deletions
|
|
@ -3,6 +3,10 @@ import { ADMIN_STORAGE_PATH } from "../../constants";
|
|||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { Role, users } from "../../fixtures/users";
|
||||
// Type-only import of the OpenAPI-generated backend schema; esbuild erases it at
|
||||
// runtime, so the round-trip below is checked against the real /config/update and
|
||||
// /router/settings contracts without bundling the 2 MB definition file.
|
||||
import type { components } from "../../../src/lib/http/schema";
|
||||
|
||||
const PRIMARY = "fake-openai-gpt-4";
|
||||
const FALLBACK = "fake-anthropic-claude";
|
||||
|
|
@ -99,3 +103,83 @@ test.describe("Router Settings - Fallbacks", () => {
|
|||
await expect(newRow).toHaveCount(1, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
type ConfigYAML = components["schemas"]["ConfigYAML"];
|
||||
type RouterSettingsResponse = components["schemas"]["RouterSettingsResponse"];
|
||||
|
||||
const BASE_URL = "http://localhost:4000";
|
||||
const ADMIN_AUTH = { Authorization: `Bearer ${users[Role.ProxyAdmin].password}` };
|
||||
|
||||
/**
|
||||
* Merge a router_settings patch into the live config through the typed
|
||||
* /config/update contract, preserving any other settings already present.
|
||||
*/
|
||||
async function patchRouterSettings(
|
||||
request: import("@playwright/test").APIRequestContext,
|
||||
patch: Partial<NonNullable<ConfigYAML["router_settings"]>>,
|
||||
) {
|
||||
const current = await request.get(`${BASE_URL}/get/config/callbacks`, { headers: ADMIN_AUTH });
|
||||
const existing = current.ok() ? (await current.json())?.router_settings ?? {} : {};
|
||||
const payload = { router_settings: { ...(existing as Record<string, unknown>), ...patch } };
|
||||
await request.post(`${BASE_URL}/config/update`, { headers: ADMIN_AUTH, data: payload });
|
||||
}
|
||||
|
||||
test.describe("Router Settings - Loadbalancing", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
// Seed a present routing_groups array (the LIT-4057 trigger) plus a known
|
||||
// num_retries so the UI assertions are deterministic across reruns.
|
||||
const ROUTING_GROUP = { group_name: "e2e-lit-4057", models: [PRIMARY], routing_strategy: "simple-shuffle" };
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await patchRouterSettings(request, { num_retries: 3, routing_groups: [ROUTING_GROUP] });
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await patchRouterSettings(request, { num_retries: 3, routing_groups: [] });
|
||||
});
|
||||
|
||||
test("saves the Loadbalancing tab without a 422 when routing_groups is present, and persists", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
await navigateToPage(page, Page.RouterSettings);
|
||||
await page.getByRole("tab", { name: "Loadbalancing" }).click();
|
||||
|
||||
const numRetries = page.locator('input[name="num_retries"]');
|
||||
await expect(numRetries).toHaveValue("3", { timeout: 15_000 });
|
||||
// routing_groups belongs to its own tab and must not leak into this form.
|
||||
await expect(page.locator('input[name="routing_groups"]')).toHaveCount(0);
|
||||
|
||||
await numRetries.fill("5");
|
||||
|
||||
// LIT-4057: the tab used to serialize routing_groups as the string "[]",
|
||||
// which the backend rejects with 422 while the UI still claimed success.
|
||||
// Assert the save actually succeeds at the network level.
|
||||
const saveResponse = page.waitForResponse(
|
||||
(res) => res.url().includes("/config/update") && res.request().method() === "POST",
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByRole("button", { name: /save changes/i }).click();
|
||||
expect((await saveResponse).status()).toBe(200);
|
||||
|
||||
await expect(page.getByText(/router settings updated successfully/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The ticket's core symptom was that a refresh showed the old value.
|
||||
await navigateToPage(page, Page.RouterSettings);
|
||||
await page.getByRole("tab", { name: "Loadbalancing" }).click();
|
||||
await expect(page.locator('input[name="num_retries"]')).toHaveValue("5", { timeout: 15_000 });
|
||||
|
||||
// The typed backend read agrees the change persisted.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const res = await request.get(`${BASE_URL}/router/settings`, { headers: ADMIN_AUTH });
|
||||
const data = (await res.json()) as RouterSettingsResponse;
|
||||
return data.current_values?.num_retries;
|
||||
},
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
.toBe(5);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue