From 540c860a9737838e5fa2146aa8d4cd2fab445548 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 30 Jun 2026 11:47:35 -0700 Subject: [PATCH] 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. --- .../tests/settings/routerSettings.spec.ts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts b/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts index 98b86ec9b11..e560500fca6 100644 --- a/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts +++ b/ui/litellm-dashboard/e2e_tests/tests/settings/routerSettings.spec.ts @@ -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>, +) { + 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), ...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); + }); +});