From d215576477d613c1f0c4c5c30524b50b2099f087 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 17:07:12 -0800 Subject: [PATCH 01/10] Add auto redirect to SSO to new login page --- litellm/proxy/proxy_server.py | 5 +- .../e2e_ui_tests/login_to_ui.spec.ts | 4 +- .../src/app/login/LoginPage.test.tsx | 172 ++++++++++++++++++ .../src/app/login/LoginPage.tsx | 159 ++++++++++++++++ ui/litellm-dashboard/src/app/login/page.tsx | 153 +--------------- .../src/components/networking.tsx | 1 + 6 files changed, 339 insertions(+), 155 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/login/LoginPage.test.tsx create mode 100644 ui/litellm-dashboard/src/app/login/LoginPage.tsx diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 4d971e8ce4..40436e1716 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -555,10 +555,11 @@ else: global_max_parallel_request_retry_timeout_env ) -ui_link = f"{server_root_path}/ui/" +ui_link = f"{server_root_path}/ui/login" +fallback_login_link = f"{server_root_path}/fallback/login" model_hub_link = f"{server_root_path}/ui/model_hub_table" ui_message = ( - f"šŸ‘‰ [```LiteLLM Admin Panel on /ui```]({ui_link}). Create, Edit Keys with SSO" + f"šŸ‘‰ [```LiteLLM Admin Panel on /ui```]({ui_link}). Create, Edit Keys with SSO. Having issues? Try [```Fallback Login```]({fallback_login_link})" ) ui_message += "\n\nšŸ’ø [```LiteLLM Model Cost Map```](https://models.litellm.ai/)." diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts index f691389de5..fc4d465324 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts @@ -10,7 +10,7 @@ import { test, expect } from "@playwright/test"; test("admin login test", async ({ page }) => { // Go to the specified URL - await page.goto("http://localhost:4000/ui"); + await page.goto("http://localhost:4000/ui/login"); // Enter "admin" in the username input field await page.fill('input[name="username"]', "admin"); @@ -19,7 +19,7 @@ test("admin login test", async ({ page }) => { await page.fill('input[name="password"]', "gm"); // Optionally, you can add an assertion to verify the login button is enabled - const loginButton = page.locator('input[type="submit"]'); + const loginButton = page.locator('button[type="submit"]'); await expect(loginButton).toBeEnabled(); // Optionally, you can click the login button to submit the form diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx new file mode 100644 index 0000000000..cce063eceb --- /dev/null +++ b/ui/litellm-dashboard/src/app/login/LoginPage.test.tsx @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import LoginPage from "./LoginPage"; + +const mockPush = vi.fn(); +const mockReplace = vi.fn(); + +vi.mock("next/navigation", () => ({ + useRouter: vi.fn(() => ({ + push: mockPush, + replace: mockReplace, + })), +})); + +vi.mock("@/app/(dashboard)/hooks/uiConfig/useUIConfig", () => ({ + useUIConfig: vi.fn(), +})); + +vi.mock("@/utils/cookieUtils", () => ({ + getCookie: vi.fn(), +})); + +vi.mock("@/utils/jwtUtils", () => ({ + isJwtExpired: vi.fn(), +})); + +vi.mock("@/components/networking", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getProxyBaseUrl: vi.fn().mockReturnValue("http://localhost:4000"), + }; +}); + +vi.mock("@/app/(dashboard)/hooks/login/useLogin", () => ({ + useLogin: vi.fn(() => ({ + mutate: vi.fn(), + isPending: false, + error: null, + })), +})); + +import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; +import { getCookie } from "@/utils/cookieUtils"; +import { isJwtExpired } from "@/utils/jwtUtils"; + +const createQueryClient = () => + new QueryClient({ + defaultOptions: { + queries: { + retry: false, + gcTime: 0, + }, + }, + }); + +describe("LoginPage", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockPush.mockClear(); + mockReplace.mockClear(); + }); + + it("should render", async () => { + (useUIConfig as ReturnType).mockReturnValue({ + data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null }, + isLoading: false, + }); + (getCookie as ReturnType).mockReturnValue(null); + + const queryClient = createQueryClient(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument(); + }); + }); + + it("should call router.replace to dashboard when jwt is valid", async () => { + const validToken = "valid-token"; + (useUIConfig as ReturnType).mockReturnValue({ + data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null }, + isLoading: false, + }); + (getCookie as ReturnType).mockReturnValue(validToken); + (isJwtExpired as ReturnType).mockReturnValue(false); + + const queryClient = createQueryClient(); + render( + + + , + ); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith("http://localhost:4000/ui"); + }); + }); + + it("should call router.push to SSO when jwt is invalid and auto_redirect_to_sso is true", async () => { + const invalidToken = "invalid-token"; + (useUIConfig as ReturnType).mockReturnValue({ + data: { auto_redirect_to_sso: true, server_root_path: "/", proxy_base_url: null }, + isLoading: false, + }); + (getCookie as ReturnType).mockReturnValue(invalidToken); + (isJwtExpired as ReturnType).mockReturnValue(true); + + const queryClient = createQueryClient(); + render( + + + , + ); + + await waitFor(() => { + expect(mockPush).toHaveBeenCalledWith("http://localhost:4000/sso/key/generate"); + }); + }); + + it("should not call router when jwt is invalid and auto_redirect_to_sso is false", async () => { + const invalidToken = "invalid-token"; + (useUIConfig as ReturnType).mockReturnValue({ + data: { auto_redirect_to_sso: false, server_root_path: "/", proxy_base_url: null }, + isLoading: false, + }); + (getCookie as ReturnType).mockReturnValue(invalidToken); + (isJwtExpired as ReturnType).mockReturnValue(true); + + const queryClient = createQueryClient(); + render( + + + , + ); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Login" })).toBeInTheDocument(); + }); + + expect(mockPush).not.toHaveBeenCalled(); + expect(mockReplace).not.toHaveBeenCalled(); + }); + + it("should send user to dashboard when jwt is valid even if auto_redirect_to_sso is true", async () => { + const validToken = "valid-token"; + (useUIConfig as ReturnType).mockReturnValue({ + data: { auto_redirect_to_sso: true, server_root_path: "/", proxy_base_url: null }, + isLoading: false, + }); + (getCookie as ReturnType).mockReturnValue(validToken); + (isJwtExpired as ReturnType).mockReturnValue(false); + + const queryClient = createQueryClient(); + render( + + + , + ); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith("http://localhost:4000/ui"); + }); + + expect(mockPush).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/login/LoginPage.tsx b/ui/litellm-dashboard/src/app/login/LoginPage.tsx new file mode 100644 index 0000000000..85f2c6dd87 --- /dev/null +++ b/ui/litellm-dashboard/src/app/login/LoginPage.tsx @@ -0,0 +1,159 @@ +"use client"; + +import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin"; +import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; +import LoadingScreen from "@/components/common_components/LoadingScreen"; +import { getProxyBaseUrl } from "@/components/networking"; +import { getCookie } from "@/utils/cookieUtils"; +import { isJwtExpired } from "@/utils/jwtUtils"; +import { InfoCircleOutlined } from "@ant-design/icons"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { Alert, Button, Card, Form, Input, Space, Typography } from "antd"; +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +function LoginPageContent() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(true); + const { data: uiConfig, isLoading: isConfigLoading } = useUIConfig(); + const loginMutation = useLogin(); + const router = useRouter(); + + useEffect(() => { + if (isConfigLoading) { + return; + } + + const rawToken = getCookie("token"); + if (rawToken && !isJwtExpired(rawToken)) { + router.replace(`${getProxyBaseUrl()}/ui`); + return; + } + + if (uiConfig && uiConfig.auto_redirect_to_sso) { + router.push(`${getProxyBaseUrl()}/sso/key/generate`); + return; + } + + setIsLoading(false); + }, [isConfigLoading, router, uiConfig]); + + const handleSubmit = () => { + loginMutation.mutate( + { username, password }, + { + onSuccess: (data) => { + router.push(data.redirect_url); + }, + }, + ); + }; + + const error = loginMutation.error instanceof Error ? loginMutation.error.message : null; + const isLoginLoading = loginMutation.isPending; + + const { Title, Text, Paragraph } = Typography; + + if (isConfigLoading || isLoading) { + return ; + } + + return ( +
+ + +
+ šŸš… LiteLLM +
+ +
+ Login + Access your LiteLLM Admin UI. +
+ + + + By default, Username is admin and + Password is your set LiteLLM Proxy + MASTER_KEY. + + + Need to set UI credentials or SSO?{" "} + + Check the documentation + + . + + + } + type="info" + icon={} + showIcon + /> + + {error && } + +
+ + setUsername(e.target.value)} + disabled={isLoginLoading} + size="large" + className="rounded-md border-gray-300" + /> + + + + setPassword(e.target.value)} + disabled={isLoginLoading} + size="large" + /> + + + + + +
+
+
+
+ ); +} + +export default function LoginPage() { + const queryClient = new QueryClient(); + + return ( + + + + ); +} diff --git a/ui/litellm-dashboard/src/app/login/page.tsx b/ui/litellm-dashboard/src/app/login/page.tsx index bd24240a6c..a539f87ee5 100644 --- a/ui/litellm-dashboard/src/app/login/page.tsx +++ b/ui/litellm-dashboard/src/app/login/page.tsx @@ -1,154 +1,5 @@ "use client"; -import { useLogin } from "@/app/(dashboard)/hooks/login/useLogin"; -import { useUIConfig } from "@/app/(dashboard)/hooks/uiConfig/useUIConfig"; -import LoadingScreen from "@/components/common_components/LoadingScreen"; -import { getProxyBaseUrl } from "@/components/networking"; -import { getCookie } from "@/utils/cookieUtils"; -import { isJwtExpired } from "@/utils/jwtUtils"; -import { InfoCircleOutlined } from "@ant-design/icons"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { Alert, Button, Card, Form, Input, Space, Typography } from "antd"; -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; +import LoginPage from "./LoginPage"; -function LoginPageContent() { - const [username, setUsername] = useState(""); - const [password, setPassword] = useState(""); - const [isLoading, setIsLoading] = useState(true); - const { isLoading: isConfigLoading } = useUIConfig(); - const loginMutation = useLogin(); - const router = useRouter(); - - useEffect(() => { - if (isConfigLoading) { - return; - } - - const rawToken = getCookie("token"); - if (rawToken && !isJwtExpired(rawToken)) { - router.replace(`${getProxyBaseUrl()}/ui`); - return; - } - - setIsLoading(false); - }, [isConfigLoading, router]); - - const handleSubmit = () => { - loginMutation.mutate( - { username, password }, - { - onSuccess: (data) => { - router.push(data.redirect_url); - }, - }, - ); - }; - - const error = loginMutation.error instanceof Error ? loginMutation.error.message : null; - const isLoginLoading = loginMutation.isPending; - - const { Title, Text, Paragraph } = Typography; - - if (isConfigLoading || isLoading) { - return ; - } - - return ( -
- - -
- šŸš… LiteLLM -
- -
- Login - Access your LiteLLM Admin UI. -
- - - - By default, Username is admin and - Password is your set LiteLLM Proxy - MASTER_KEY. - - - Need to set UI credentials or SSO?{" "} - - Check the documentation - - . - - - } - type="info" - icon={} - showIcon - /> - - {error && } - -
- - setUsername(e.target.value)} - disabled={isLoginLoading} - size="large" - className="rounded-md border-gray-300" - /> - - - - setPassword(e.target.value)} - disabled={isLoginLoading} - size="large" - /> - - - - - -
-
-
-
- ); -} - -export default function LoginPage() { - const queryClient = new QueryClient(); - - return ( - - - - ); -} +export default LoginPage; diff --git a/ui/litellm-dashboard/src/components/networking.tsx b/ui/litellm-dashboard/src/components/networking.tsx index 061d62d670..0e45c0f3a9 100644 --- a/ui/litellm-dashboard/src/components/networking.tsx +++ b/ui/litellm-dashboard/src/components/networking.tsx @@ -205,6 +205,7 @@ export interface PublicModelHubInfo { export interface LiteLLMWellKnownUiConfig { server_root_path: string; proxy_base_url: string | null; + auto_redirect_to_sso: boolean; } export interface CredentialsResponse { From 3de84b3f8b2e6d001827da05bae088114b24eae1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 20:34:41 -0800 Subject: [PATCH 02/10] e2e tests --- litellm/proxy/proxy_server.py | 2 +- .../proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts | 8 ++++++-- .../e2e_ui_tests/search_users.spec.ts | 4 ++-- .../e2e_ui_tests/view_internal_user.spec.ts | 8 ++++---- .../e2e_ui_tests/view_user_info.spec.ts | 1 + ui/litellm-dashboard/src/app/page.tsx | 2 +- 6 files changed, 15 insertions(+), 10 deletions(-) diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 40436e1716..ebe2a90f37 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -555,7 +555,7 @@ else: global_max_parallel_request_retry_timeout_env ) -ui_link = f"{server_root_path}/ui/login" +ui_link = f"{server_root_path}/ui" fallback_login_link = f"{server_root_path}/fallback/login" model_hub_link = f"{server_root_path}/ui/model_hub_table" ui_message = ( diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts index fc4d465324..793ae53b39 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts @@ -10,7 +10,9 @@ import { test, expect } from "@playwright/test"; test("admin login test", async ({ page }) => { // Go to the specified URL - await page.goto("http://localhost:4000/ui/login"); + await page.goto("http://localhost:4000/ui"); + + page.screenshot({ path: "login_before.png" }); // Enter "admin" in the username input field await page.fill('input[name="username"]', "admin"); @@ -18,8 +20,10 @@ test("admin login test", async ({ page }) => { // Enter "gm" in the password input field await page.fill('input[name="password"]', "gm"); + page.screenshot({ path: "login_after_inputs.png" }); + // Optionally, you can add an assertion to verify the login button is enabled - const loginButton = page.locator('button[type="submit"]'); + const loginButton = page.getByRole("button", { name: "Login" }); await expect(loginButton).toBeEnabled(); // Optionally, you can click the login button to submit the form diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts index 7b9da6a27d..a384248a5f 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts @@ -31,7 +31,7 @@ test("user search test", async ({ page }) => { await page.fill('input[name="password"]', "gm"); console.log("Filled login credentials"); - const loginButton = page.locator('input[type="submit"]'); + const loginButton = page.getByRole("button", { name: "Login" }); await expect(loginButton).toBeEnabled(); await loginButton.click(); console.log("Clicked login button"); @@ -138,7 +138,7 @@ test("user filter test", async ({ page }) => { await page.fill('input[name="password"]', "gm"); console.log("Filled login credentials"); - const loginButton = page.locator('input[type="submit"]'); + const loginButton = page.getByRole("button", { name: "Login" }); await expect(loginButton).toBeEnabled(); await loginButton.click(); console.log("Clicked login button"); diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts index e578ab57e3..dd6f812df4 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts @@ -15,7 +15,7 @@ test("view internal user page", async ({ page }) => { await page.fill('input[name="password"]', "gm"); // Click the login button - const loginButton = page.locator('input[type="submit"]'); + const loginButton = page.getByRole("button", { name: "Login" }); await expect(loginButton).toBeEnabled(); await loginButton.click(); @@ -35,9 +35,9 @@ test("view internal user page", async ({ page }) => { const rowCount = await page.locator("tbody tr").count(); expect(rowCount).toBeGreaterThan(0); - // Verify table headers are present (including Virtual Keys column) - const virtualKeysHeader = page.locator("th", { hasText: "Virtual Keys" }); - await expect(virtualKeysHeader).toBeVisible(); + const userIdHeader = page.locator("th", { hasText: "User ID" }); + page.screenshot({ path: "user_id_header.png" }); + await expect(userIdHeader).toBeVisible(); // test pagination // Wait for pagination controls to be visible diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts index 01eadc9ad1..742dbd14d4 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts @@ -14,6 +14,7 @@ test.describe("User Info View", () => { // Wait for loading state to disappear await page.waitForSelector('text="šŸš… Loading users..."', { state: "hidden", + timeout: 10000, }); // Wait for users table to load await page.waitForSelector("table"); diff --git a/ui/litellm-dashboard/src/app/page.tsx b/ui/litellm-dashboard/src/app/page.tsx index 68060ae696..20f5480c97 100644 --- a/ui/litellm-dashboard/src/app/page.tsx +++ b/ui/litellm-dashboard/src/app/page.tsx @@ -186,7 +186,7 @@ export default function CreateKeyPage() { useEffect(() => { if (redirectToLogin) { // Replace instead of assigning to avoid back-button loops - const dest = (proxyBaseUrl || "") + "/sso/key/generate"; + const dest = (proxyBaseUrl || "") + "/ui/login"; window.location.replace(dest); } }, [redirectToLogin]); From a4341ccf833c19f716e00a8fedc66fb257d5bcd6 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 21:00:49 -0800 Subject: [PATCH 03/10] ci/cd changes for debugging --- .circleci/config.yml | 7 ++++++- .../e2e_ui_tests/login_to_ui.spec.ts | 4 ++-- .../require_auth_for_dashboard.spec.ts | 14 ++++++++++---- tests/proxy_admin_ui_tests/utils/login.ts | 2 +- 4 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a518628afb..0adfd5be52 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3496,8 +3496,13 @@ jobs: command: | npx playwright test e2e_ui_tests/ --reporter=html --output=test-results no_output_timeout: 120m - - store_test_results: + - store_artifacts: path: test-results + destination: playwright-results + + - store_artifacts: + path: playwright-report + destination: playwright-report test_nonroot_image: machine: diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts index 793ae53b39..b9c16299cc 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts @@ -12,7 +12,7 @@ test("admin login test", async ({ page }) => { // Go to the specified URL await page.goto("http://localhost:4000/ui"); - page.screenshot({ path: "login_before.png" }); + await page.screenshot({ path: "test-results/login_before.png" }); // Enter "admin" in the username input field await page.fill('input[name="username"]', "admin"); @@ -20,7 +20,7 @@ test("admin login test", async ({ page }) => { // Enter "gm" in the password input field await page.fill('input[name="password"]', "gm"); - page.screenshot({ path: "login_after_inputs.png" }); + page.screenshot({ path: "test-results/login_after_inputs.png" }); // Optionally, you can add an assertion to verify the login button is enabled const loginButton = page.getByRole("button", { name: "Login" }); diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/require_auth_for_dashboard.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/require_auth_for_dashboard.spec.ts index 18bc91c097..2b198d6ab7 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/require_auth_for_dashboard.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/require_auth_for_dashboard.spec.ts @@ -2,15 +2,19 @@ import { test, expect } from "@playwright/test"; test.describe("Authentication Checks", () => { - test("should redirect unauthenticated user from a protected page", async ({ page }) => { + test("should redirect unauthenticated user from a protected page", async ({ + page, + }) => { test.setTimeout(30000); page.on("console", (msg) => console.log("PAGE LOG:", msg.text())); const protectedPageUrl = "http://localhost:4000/ui?page=llm-playground"; - const expectedRedirectUrl = "http://localhost:4000/sso/key/generate"; + const expectedRedirectUrl = "http://localhost:4000/ui/login"; - console.log(`Attempting to navigate to protected page: ${protectedPageUrl}`); + console.log( + `Attempting to navigate to protected page: ${protectedPageUrl}` + ); await page.goto(protectedPageUrl); @@ -20,7 +24,9 @@ test.describe("Authentication Checks", () => { await page.waitForURL(expectedRedirectUrl, { timeout: 10000 }); console.log(`Waited for URL. Current URL is now: ${page.url()}`); } catch (error) { - console.error(`Timeout waiting for URL: ${expectedRedirectUrl}. Current URL: ${page.url()}`); + console.error( + `Timeout waiting for URL: ${expectedRedirectUrl}. Current URL: ${page.url()}` + ); await page.screenshot({ path: "redirect-fail-screenshot.png" }); throw error; } diff --git a/tests/proxy_admin_ui_tests/utils/login.ts b/tests/proxy_admin_ui_tests/utils/login.ts index e875508997..716efe58c7 100644 --- a/tests/proxy_admin_ui_tests/utils/login.ts +++ b/tests/proxy_admin_ui_tests/utils/login.ts @@ -13,7 +13,7 @@ export async function loginToUI(page: Page) { await page.fill('input[name="password"]', "gm"); console.log("Filled login credentials"); - const loginButton = page.locator('input[type="submit"]'); + const loginButton = page.getByRole("button", { name: "Login" }); await expect(loginButton).toBeEnabled(); await loginButton.click(); console.log("Clicked login button"); From 636f26d8a1641bf0871a5a6fffdf2fa06828f165 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 21:21:27 -0800 Subject: [PATCH 04/10] e2e changes --- tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts | 1 + .../e2e_ui_tests/require_auth_for_dashboard.spec.ts | 2 +- tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts | 4 ++++ .../e2e_ui_tests/view_internal_user.spec.ts | 3 +++ tests/proxy_admin_ui_tests/utils/login.ts | 2 ++ 5 files changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts index b9c16299cc..c4a1dc77c3 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts @@ -11,6 +11,7 @@ import { test, expect } from "@playwright/test"; test("admin login test", async ({ page }) => { // Go to the specified URL await page.goto("http://localhost:4000/ui"); + await page.waitForLoadState("networkidle"); await page.screenshot({ path: "test-results/login_before.png" }); diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/require_auth_for_dashboard.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/require_auth_for_dashboard.spec.ts index 2b198d6ab7..4e4bd2fcd9 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/require_auth_for_dashboard.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/require_auth_for_dashboard.spec.ts @@ -10,7 +10,7 @@ test.describe("Authentication Checks", () => { page.on("console", (msg) => console.log("PAGE LOG:", msg.text())); const protectedPageUrl = "http://localhost:4000/ui?page=llm-playground"; - const expectedRedirectUrl = "http://localhost:4000/ui/login"; + const expectedRedirectUrl = "http://localhost:4000/ui/login/"; console.log( `Attempting to navigate to protected page: ${protectedPageUrl}` diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts index a384248a5f..66d0ad2965 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts @@ -21,8 +21,11 @@ test("user search test", async ({ page }) => { // Login first await page.goto("http://localhost:4000/ui"); + await page.waitForLoadState("networkidle"); console.log("Navigated to login page"); + page.screenshot({ path: "test-results/search_users_before_login.png" }); + // Wait for login form to be visible await page.waitForSelector('input[name="username"]', { timeout: 10000 }); console.log("Login form is visible"); @@ -128,6 +131,7 @@ test("user filter test", async ({ page }) => { // Login first await page.goto("http://localhost:4000/ui"); + await page.waitForLoadState("networkidle"); console.log("Navigated to login page"); // Wait for login form to be visible diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts index dd6f812df4..a7843d5291 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts @@ -7,6 +7,9 @@ import { test, expect } from "@playwright/test"; test("view internal user page", async ({ page }) => { // Go to the specified URL await page.goto("http://localhost:4000/ui"); + await page.waitForLoadState("networkidle"); + + page.screenshot({ path: "test-results/view_internal_user_before_login.png" }); // Enter "admin" in the username input field await page.fill('input[name="username"]', "admin"); diff --git a/tests/proxy_admin_ui_tests/utils/login.ts b/tests/proxy_admin_ui_tests/utils/login.ts index 716efe58c7..5749006cde 100644 --- a/tests/proxy_admin_ui_tests/utils/login.ts +++ b/tests/proxy_admin_ui_tests/utils/login.ts @@ -3,8 +3,10 @@ import { Page, expect } from "@playwright/test"; export async function loginToUI(page: Page) { // Login first await page.goto("http://localhost:4000/ui"); + await page.waitForLoadState("networkidle"); console.log("Navigated to login page"); + page.screenshot({ path: "test-results/login_utils_before.png" }); // Wait for login form to be visible await page.waitForSelector('input[name="username"]', { timeout: 10000 }); console.log("Login form is visible"); From c634c08818cdaa66c05852de9d3fa4717a693c0c Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 21:51:45 -0800 Subject: [PATCH 05/10] Find inputs by placeholder --- .../e2e_ui_tests/login_to_ui.spec.ts | 4 ++-- .../e2e_ui_tests/search_users.spec.ts | 16 ++++++++++------ .../e2e_ui_tests/view_internal_user.spec.ts | 4 ++-- tests/proxy_admin_ui_tests/utils/login.ts | 8 +++++--- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts index c4a1dc77c3..e5a397a6a6 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/login_to_ui.spec.ts @@ -16,10 +16,10 @@ test("admin login test", async ({ page }) => { await page.screenshot({ path: "test-results/login_before.png" }); // Enter "admin" in the username input field - await page.fill('input[name="username"]', "admin"); + await page.fill('input[placeholder="Enter your username"]', "admin"); // Enter "gm" in the password input field - await page.fill('input[name="password"]', "gm"); + await page.fill('input[placeholder="Enter your password"]', "gm"); page.screenshot({ path: "test-results/login_after_inputs.png" }); diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts index 66d0ad2965..d72c44ab8c 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/search_users.spec.ts @@ -27,11 +27,13 @@ test("user search test", async ({ page }) => { page.screenshot({ path: "test-results/search_users_before_login.png" }); // Wait for login form to be visible - await page.waitForSelector('input[name="username"]', { timeout: 10000 }); + await page.waitForSelector('input[placeholder="Enter your username"]', { + timeout: 10000, + }); console.log("Login form is visible"); - await page.fill('input[name="username"]', "admin"); - await page.fill('input[name="password"]', "gm"); + await page.fill('input[placeholder="Enter your username"]', "admin"); + await page.fill('input[placeholder="Enter your password"]', "gm"); console.log("Filled login credentials"); const loginButton = page.getByRole("button", { name: "Login" }); @@ -135,11 +137,13 @@ test("user filter test", async ({ page }) => { console.log("Navigated to login page"); // Wait for login form to be visible - await page.waitForSelector('input[name="username"]', { timeout: 10000 }); + await page.waitForSelector('input[placeholder="Enter your username"]', { + timeout: 10000, + }); console.log("Login form is visible"); - await page.fill('input[name="username"]', "admin"); - await page.fill('input[name="password"]', "gm"); + await page.fill('input[placeholder="Enter your username"]', "admin"); + await page.fill('input[placeholder="Enter your password"]', "gm"); console.log("Filled login credentials"); const loginButton = page.getByRole("button", { name: "Login" }); diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts index a7843d5291..2aae9e2bb5 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_internal_user.spec.ts @@ -12,10 +12,10 @@ test("view internal user page", async ({ page }) => { page.screenshot({ path: "test-results/view_internal_user_before_login.png" }); // Enter "admin" in the username input field - await page.fill('input[name="username"]', "admin"); + await page.fill('input[placeholder="Enter your username"]', "admin"); // Enter "gm" in the password input field - await page.fill('input[name="password"]', "gm"); + await page.fill('input[placeholder="Enter your password"]', "gm"); // Click the login button const loginButton = page.getByRole("button", { name: "Login" }); diff --git a/tests/proxy_admin_ui_tests/utils/login.ts b/tests/proxy_admin_ui_tests/utils/login.ts index 5749006cde..25858d9f57 100644 --- a/tests/proxy_admin_ui_tests/utils/login.ts +++ b/tests/proxy_admin_ui_tests/utils/login.ts @@ -8,11 +8,13 @@ export async function loginToUI(page: Page) { page.screenshot({ path: "test-results/login_utils_before.png" }); // Wait for login form to be visible - await page.waitForSelector('input[name="username"]', { timeout: 10000 }); + await page.waitForSelector('input[placeholder="Enter your username"]', { + timeout: 10000, + }); console.log("Login form is visible"); - await page.fill('input[name="username"]', "admin"); - await page.fill('input[name="password"]', "gm"); + await page.fill('input[placeholder="Enter your username"]', "admin"); + await page.fill('input[placeholder="Enter your password"]', "gm"); console.log("Filled login credentials"); const loginButton = page.getByRole("button", { name: "Login" }); From 45a97234673fd65b700450250246edc01f70a1d1 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 22:20:04 -0800 Subject: [PATCH 06/10] view_user_info debugging --- .../e2e_ui_tests/view_user_info.spec.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts index 742dbd14d4..53c7359388 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts @@ -5,7 +5,9 @@ test.describe("User Info View", () => { test.beforeEach(async ({ page }) => { await loginToUI(page); // Navigate to users page + page.screenshot({ path: "test-results/view_user_info_before_nav.png" }); await page.goto("http://localhost:4000/ui?page=users"); + page.screenshot({ path: "test-results/view_user_info_after_nav.png" }); }); test("should display user info when clicking on user ID", async ({ @@ -16,9 +18,12 @@ test.describe("User Info View", () => { state: "hidden", timeout: 10000, }); + page.screenshot({ path: "test-results/view_user_info_after_loading.png" }); // Wait for users table to load await page.waitForSelector("table"); - + page.screenshot({ + path: "test-results/view_user_info_after_table_load.png", + }); // Get the first user ID cell const firstUserIdCell = page.locator( "table tbody tr:first-child td:first-child" From 19dc5d750c409a88a5b64e05505c10a89a8a7c56 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 23:10:32 -0800 Subject: [PATCH 07/10] Adding waits --- tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts index 53c7359388..cba7ddad7c 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts @@ -18,6 +18,7 @@ test.describe("User Info View", () => { state: "hidden", timeout: 10000, }); + await page.waitForLoadState("networkidle"); page.screenshot({ path: "test-results/view_user_info_after_loading.png" }); // Wait for users table to load await page.waitForSelector("table"); @@ -33,6 +34,7 @@ test.describe("User Info View", () => { // Click on the user ID await firstUserIdCell.click(); + await page.waitForLoadState("networkidle"); // Check for tabs await expect(page.locator('button:has-text("Overview")')).toBeVisible({ From 0483336c53cfd6249d31bd44ba064a2fa6246689 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 23:21:09 -0800 Subject: [PATCH 08/10] change test to follow other logins --- .../e2e_ui_tests/view_user_info.spec.ts | 36 ++++++++++++++----- 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts index cba7ddad7c..22d37b19ec 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts @@ -2,17 +2,37 @@ import { test, expect } from "@playwright/test"; import { loginToUI } from "../utils/login"; test.describe("User Info View", () => { - test.beforeEach(async ({ page }) => { - await loginToUI(page); - // Navigate to users page - page.screenshot({ path: "test-results/view_user_info_before_nav.png" }); - await page.goto("http://localhost:4000/ui?page=users"); - page.screenshot({ path: "test-results/view_user_info_after_nav.png" }); - }); - test("should display user info when clicking on user ID", async ({ page, }) => { + test.setTimeout(60000); + + // Enable console logging + page.on("console", (msg) => console.log("PAGE LOG:", msg.text())); + + // Login first + await page.goto("http://localhost:4000/ui"); + await page.waitForLoadState("networkidle"); + console.log("Navigated to login page"); + + // Wait for login form to be visible + await page.waitForSelector('input[placeholder="Enter your username"]', { + timeout: 10000, + }); + console.log("Login form is visible"); + + await page.fill('input[placeholder="Enter your username"]', "admin"); + await page.fill('input[placeholder="Enter your password"]', "gm"); + console.log("Filled login credentials"); + + const loginButton = page.getByRole("button", { name: "Login" }); + await expect(loginButton).toBeEnabled(); + await loginButton.click(); + console.log("Clicked login button"); + + // Wait for navigation to complete and dashboard to load + await page.waitForLoadState("networkidle"); + await page.goto("http://localhost:4000/ui?page=users"); // Wait for loading state to disappear await page.waitForSelector('text="šŸš… Loading users..."', { state: "hidden", From 59b32c28a860b0cdb43ee85c29983106441acefa Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 23:37:15 -0800 Subject: [PATCH 09/10] Change selector in test --- .../e2e_ui_tests/view_user_info.spec.ts | 39 +++++++++++-------- 1 file changed, 23 insertions(+), 16 deletions(-) diff --git a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts index 22d37b19ec..adda3088f1 100644 --- a/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts +++ b/tests/proxy_admin_ui_tests/e2e_ui_tests/view_user_info.spec.ts @@ -5,40 +5,47 @@ test.describe("User Info View", () => { test("should display user info when clicking on user ID", async ({ page, }) => { - test.setTimeout(60000); - - // Enable console logging - page.on("console", (msg) => console.log("PAGE LOG:", msg.text())); - - // Login first await page.goto("http://localhost:4000/ui"); await page.waitForLoadState("networkidle"); - console.log("Navigated to login page"); - // Wait for login form to be visible - await page.waitForSelector('input[placeholder="Enter your username"]', { - timeout: 10000, + page.screenshot({ + path: "test-results/view_user_info_before_login.png", }); - console.log("Login form is visible"); + // Enter "admin" in the username input field await page.fill('input[placeholder="Enter your username"]', "admin"); - await page.fill('input[placeholder="Enter your password"]', "gm"); - console.log("Filled login credentials"); + page.screenshot({ + path: "test-results/view_user_info_after_username_input.png", + }); + // Enter "gm" in the password input field + await page.fill('input[placeholder="Enter your password"]', "gm"); + page.screenshot({ + path: "test-results/view_user_info_after_password_input.png", + }); + + // Click the login button const loginButton = page.getByRole("button", { name: "Login" }); await expect(loginButton).toBeEnabled(); await loginButton.click(); - console.log("Clicked login button"); + page.screenshot({ + path: "test-results/view_user_info_after_login_button_click.png", + }); // Wait for navigation to complete and dashboard to load await page.waitForLoadState("networkidle"); - await page.goto("http://localhost:4000/ui?page=users"); + const tabElement = page.locator("span.ant-menu-title-content", { + hasText: "Internal User", + }); + await tabElement.click(); + page.screenshot({ + path: "test-results/view_user_info_after_internal_user_tab_click.png", + }); // Wait for loading state to disappear await page.waitForSelector('text="šŸš… Loading users..."', { state: "hidden", timeout: 10000, }); - await page.waitForLoadState("networkidle"); page.screenshot({ path: "test-results/view_user_info_after_loading.png" }); // Wait for users table to load await page.waitForSelector("table"); From 5496e622c14eb3068d1292b2a8719119b021e762 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Wed, 3 Dec 2025 23:46:09 -0800 Subject: [PATCH 10/10] Unit tests --- ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx index 9287e20318..c3c5ae5923 100644 --- a/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx +++ b/ui/litellm-dashboard/tests/CreateKeyPage.expiredToken.test.tsx @@ -193,7 +193,7 @@ describe("CreateKeyPage auth behavior", () => { // Assert: we eventually redirect to SSO login (single replace, not assign/href) await waitFor(() => { - expect(window.location.replace).toHaveBeenCalledWith("https://example.com/sso/key/generate"); + expect(window.location.replace).toHaveBeenCalledWith("https://example.com/ui/login"); }); // And we attempted to clear the cookie (defensive deletion)