mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-10 03:28:53 +00:00
ci: drop docker-based SERVER_ROOT_PATH e2e in favor of a unit test (#34642)
Co-authored-by: ryan <ryan@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
1a0acaa33b
commit
16550edd00
4 changed files with 87 additions and 221 deletions
151
.github/workflows/test_server_root_path.yml
vendored
151
.github/workflows/test_server_root_path.yml
vendored
|
|
@ -1,151 +0,0 @@
|
|||
name: Test Proxy SERVER_ROOT_PATH Routing
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- litellm_internal_staging
|
||||
- litellm_oss_staging
|
||||
- "litellm_**"
|
||||
|
||||
jobs:
|
||||
test-server-root-path:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
root_path: ["/api/v1", "/llmproxy"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Free up disk space
|
||||
run: |
|
||||
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc /usr/local/share/boost
|
||||
sudo apt-get clean
|
||||
df -h /
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@0adf9959216b96bec444f325f1e493d4aa344497 # v6.14.0
|
||||
with:
|
||||
context: .
|
||||
file: ./docker/Dockerfile.non_root
|
||||
tags: litellm-test:${{ github.sha }}
|
||||
load: true
|
||||
push: false
|
||||
|
||||
- name: Start LiteLLM container with SERVER_ROOT_PATH
|
||||
run: |
|
||||
docker run -d \
|
||||
--name litellm-test \
|
||||
-p 4000:4000 \
|
||||
-e SERVER_ROOT_PATH="${{ matrix.root_path }}" \
|
||||
-e LITELLM_MASTER_KEY="sk-1234" \
|
||||
litellm-test:${{ github.sha }} \
|
||||
--detailed_debug
|
||||
|
||||
- name: Wait for container to be healthy
|
||||
run: |
|
||||
echo "Waiting for LiteLLM to start..."
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
|
||||
while [ $attempt -lt $max_attempts ]; do
|
||||
if docker logs litellm-test 2>&1 | grep -q "Uvicorn running"; then
|
||||
echo "LiteLLM started successfully"
|
||||
break
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
echo "Attempt $attempt/$max_attempts - waiting for server to start..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo "Server failed to start within timeout"
|
||||
docker logs litellm-test
|
||||
exit 1
|
||||
fi
|
||||
|
||||
sleep 5
|
||||
|
||||
- name: Show container logs
|
||||
if: always()
|
||||
run: docker logs litellm-test
|
||||
|
||||
- name: Test UI endpoint with root path
|
||||
run: |
|
||||
ROOT_PATH="${{ matrix.root_path }}"
|
||||
echo "Testing UI at: http://localhost:4000${ROOT_PATH}/ui/"
|
||||
|
||||
for i in 1 2 3; do
|
||||
content=$(curl -sL --max-time 5 -H "Authorization: Bearer sk-1234" "http://localhost:4000${ROOT_PATH}/ui/")
|
||||
if echo "$content" | grep -q -E "(html|<!DOCTYPE|<head|<body)"; then
|
||||
echo "UI page contains valid HTML content"
|
||||
exit 0
|
||||
fi
|
||||
echo "Attempt $i/3 - no valid HTML, retrying in 5s..."
|
||||
sleep 5
|
||||
done
|
||||
echo "UI page does not contain expected HTML content"
|
||||
echo "Response: $content"
|
||||
docker logs litellm-test
|
||||
exit 1
|
||||
|
||||
- name: Setup Node for Playwright
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "20"
|
||||
|
||||
- name: Install e2e deps and Chromium
|
||||
working-directory: tests/e2e/ui
|
||||
run: |
|
||||
retry() {
|
||||
local attempt=1
|
||||
local max_attempts=4
|
||||
until "$@"; do
|
||||
if [ "$attempt" -ge "$max_attempts" ]; then
|
||||
echo "Command failed after $attempt attempts: $*"
|
||||
return 1
|
||||
fi
|
||||
echo "Attempt $attempt failed: $*. Retrying in $((attempt * 15))s..."
|
||||
sleep $((attempt * 15))
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
}
|
||||
|
||||
npm config set fetch-retries 5
|
||||
npm config set fetch-retry-mintimeout 20000
|
||||
npm config set fetch-retry-maxtimeout 120000
|
||||
|
||||
retry npm ci
|
||||
retry npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run SERVER_ROOT_PATH redirect e2e
|
||||
working-directory: tests/e2e/ui
|
||||
env:
|
||||
SERVER_ROOT_PATH: ${{ matrix.root_path }}
|
||||
run: npx playwright test --config=serverRootPath.config.ts
|
||||
|
||||
- name: Upload Playwright artifacts on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-trace-${{ strategy.job-index }}
|
||||
path: tests/e2e/ui/test-results/
|
||||
retention-days: 7
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker stop litellm-test || true
|
||||
docker rm litellm-test || true
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
// Minimal config for the SERVER_ROOT_PATH redirect spec. Deliberately does NOT
|
||||
// reuse the main e2e config because:
|
||||
// - globalSetup logs in via http://localhost:4000/ui/login, which 404s when
|
||||
// the proxy is mounted under a non-root path.
|
||||
// - The redirect spec must run against a clean, unauthenticated session, so
|
||||
// no storage state should be loaded.
|
||||
export default defineConfig({
|
||||
testDir: "./tests/login",
|
||||
testMatch: ["serverRootPathRedirect.spec.ts"],
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: "list",
|
||||
use: {
|
||||
trace: "on-first-retry",
|
||||
actionTimeout: 15 * 1000,
|
||||
navigationTimeout: 30 * 1000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
timeout: 60 * 1000,
|
||||
expect: {
|
||||
timeout: 10 * 1000,
|
||||
},
|
||||
});
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
import { expect, test } from "@playwright/test";
|
||||
|
||||
// Driven by the SERVER_ROOT_PATH env var injected by the workflow; the container
|
||||
// is booted with the same value, so the asset paths and the runtime config it
|
||||
// serves at /litellm/.well-known/litellm-ui-config will both reflect it.
|
||||
const ROOT_PATH = process.env.SERVER_ROOT_PATH ?? "";
|
||||
|
||||
test.skip(!ROOT_PATH, "Requires SERVER_ROOT_PATH env var");
|
||||
|
||||
// Contract: an unauthenticated visit must redirect to a login URL that preserves
|
||||
// the SERVER_ROOT_PATH prefix. The redirect URL is built client-side from
|
||||
// `proxyBaseUrl`, which is populated by an async fetch of the runtime UI config.
|
||||
// If the redirect fires before that fetch resolves, the URL is missing the
|
||||
// prefix and the user lands on a 404. To make the race deterministic across
|
||||
// runners, the config endpoint is intentionally delayed.
|
||||
test("unauth redirect preserves SERVER_ROOT_PATH prefix", async ({ page }) => {
|
||||
// Matches both `/litellm/.well-known/litellm-ui-config` and
|
||||
// `${SERVER_ROOT_PATH}/.well-known/litellm-ui-config` (the proxy rewrites the
|
||||
// bundle at boot when a root path is set).
|
||||
await page.route("**/.well-known/litellm-ui-config", async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
await page.context().clearCookies();
|
||||
|
||||
await page.goto(`http://localhost:4000${ROOT_PATH}/ui/?page=virtual-keys`);
|
||||
|
||||
await page.waitForURL((url) => url.pathname.includes("/ui/login"), { timeout: 15_000 });
|
||||
|
||||
// The redirect target is built by joining proxyBaseUrl (assembled by
|
||||
// resolveApiBase from the origin + SERVER_ROOT_PATH) with "/ui/login". A
|
||||
// regression in that join surfaces as a doubled separator, which the loose
|
||||
// toContain above would still accept, so assert the prefix joins exactly once.
|
||||
const { pathname } = new URL(page.url());
|
||||
expect(pathname.startsWith(`${ROOT_PATH}/ui/login`)).toBe(true);
|
||||
expect(pathname).not.toContain("//");
|
||||
});
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
/* @vitest-environment jsdom */
|
||||
import React from "react";
|
||||
import { renderHook, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import useAuthorized from "./useAuthorized";
|
||||
|
||||
vi.unmock("@/app/(dashboard)/hooks/useAuthorized");
|
||||
|
||||
const replaceMock = vi.fn();
|
||||
|
||||
const UI_CONFIG_DELAY_MS = 50;
|
||||
|
||||
const uiConfigResponse = {
|
||||
server_root_path: "/llmproxy",
|
||||
proxy_base_url: null,
|
||||
auto_redirect_to_sso: false,
|
||||
admin_ui_disabled: false,
|
||||
sso_configured: false,
|
||||
};
|
||||
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = typeof input === "string" ? input : input.toString();
|
||||
if (!url.includes("/litellm/.well-known/litellm-ui-config")) {
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, UI_CONFIG_DELAY_MS));
|
||||
return { ok: true, json: async () => uiConfigResponse } as unknown as Response;
|
||||
});
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } });
|
||||
return React.createElement(QueryClientProvider, { client: queryClient }, children);
|
||||
};
|
||||
|
||||
describe("useAuthorized under SERVER_ROOT_PATH", () => {
|
||||
const originalLocation = window.location;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
Object.defineProperty(window, "location", {
|
||||
value: {
|
||||
href: "http://proxy.example/llmproxy/ui/?page=virtual-keys",
|
||||
origin: "http://proxy.example",
|
||||
hostname: "proxy.example",
|
||||
pathname: "/llmproxy/ui/",
|
||||
search: "?page=virtual-keys",
|
||||
protocol: "http:",
|
||||
replace: replaceMock,
|
||||
},
|
||||
writable: true,
|
||||
});
|
||||
document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(window, "location", { value: originalLocation, writable: true });
|
||||
vi.unstubAllGlobals();
|
||||
replaceMock.mockReset();
|
||||
fetchMock.mockClear();
|
||||
});
|
||||
|
||||
it("sends an unauthenticated visitor to a login URL that keeps the server root path", async () => {
|
||||
renderHook(() => useAuthorized(), { wrapper });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
expect(replaceMock).toHaveBeenCalledTimes(1);
|
||||
const { origin, pathname } = new URL(replaceMock.mock.calls[0][0] as string);
|
||||
expect(origin).toBe("http://proxy.example");
|
||||
expect(pathname).toBe("/llmproxy/ui/login/");
|
||||
});
|
||||
|
||||
it("does not redirect before the UI config resolves the server root path", async () => {
|
||||
renderHook(() => useAuthorized(), { wrapper });
|
||||
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
await new Promise((resolve) => setTimeout(resolve, UI_CONFIG_DELAY_MS / 2));
|
||||
expect(replaceMock).not.toHaveBeenCalled();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(replaceMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue