mirror of
https://github.com/tiennm99/litellm.git
synced 2026-06-18 09:32:08 +00:00
[Test] UI - Add Playwright E2E tests with local PostgreSQL
Add a self-contained Playwright E2E test suite that runs against a local PostgreSQL database instead of Neon. Tests cover role-based access for all 5 user roles (proxy admin, admin viewer, internal user, internal viewer, team admin) and authentication flows. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,99 @@
|
||||
name: UI E2E Playwright Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui_e2e_tests:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_USER: llmproxy
|
||||
POSTGRES_PASSWORD: dbpassword9090
|
||||
POSTGRES_DB: litellm
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4.3.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Install Poetry
|
||||
run: pip install 'poetry==2.3.2'
|
||||
|
||||
- name: Cache Poetry dependencies
|
||||
uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.0.0
|
||||
with:
|
||||
path: |
|
||||
~/.cache/pypoetry
|
||||
~/.cache/pip
|
||||
.venv
|
||||
key: ${{ runner.os }}-poetry-ui-e2e-${{ hashFiles('poetry.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-poetry-ui-e2e-
|
||||
${{ runner.os }}-poetry-
|
||||
|
||||
- name: Install Python dependencies
|
||||
run: |
|
||||
poetry config virtualenvs.in-project true
|
||||
poetry install --with dev,proxy-dev --extras "proxy" --quiet
|
||||
|
||||
- name: Generate Prisma client
|
||||
env:
|
||||
PRISMA_BINARY_CACHE_DIR: ${{ runner.temp }}/prisma-cache
|
||||
run: |
|
||||
poetry run pip install nodejs-wheel-binaries==24.13.1
|
||||
poetry run prisma generate --schema litellm/proxy/schema.prisma
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd tests/ui_e2e_tests
|
||||
npm install --silent
|
||||
npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run UI E2E tests
|
||||
env:
|
||||
CI: "true"
|
||||
DATABASE_URL: postgresql://llmproxy:dbpassword9090@localhost:5432/litellm
|
||||
run: |
|
||||
cd tests/ui_e2e_tests
|
||||
./run_e2e.sh
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
|
||||
with:
|
||||
name: playwright-results
|
||||
path: |
|
||||
tests/ui_e2e_tests/test-results/
|
||||
tests/ui_e2e_tests/playwright-report/
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,40 @@
|
||||
export const ADMIN_STORAGE_PATH = "admin.storageState.json";
|
||||
|
||||
// Page enum — maps to ?page= query parameter values in the UI
|
||||
export enum Page {
|
||||
ApiKeys = "api-keys",
|
||||
Teams = "teams",
|
||||
AdminSettings = "settings",
|
||||
}
|
||||
|
||||
// Test user credentials — all users have password "test" (hashed in seed.sql)
|
||||
export enum Role {
|
||||
ProxyAdmin = "proxy_admin",
|
||||
ProxyAdminViewer = "proxy_admin_viewer",
|
||||
InternalUser = "internal_user",
|
||||
InternalUserViewer = "internal_user_viewer",
|
||||
TeamAdmin = "team_admin",
|
||||
}
|
||||
|
||||
export const users: Record<Role, { email: string; password: string }> = {
|
||||
[Role.ProxyAdmin]: {
|
||||
email: "admin",
|
||||
password: process.env.LITELLM_MASTER_KEY || "sk-1234",
|
||||
},
|
||||
[Role.ProxyAdminViewer]: {
|
||||
email: "adminviewer@test.local",
|
||||
password: "test",
|
||||
},
|
||||
[Role.InternalUser]: {
|
||||
email: "internal@test.local",
|
||||
password: "test",
|
||||
},
|
||||
[Role.InternalUserViewer]: {
|
||||
email: "viewer@test.local",
|
||||
password: "test",
|
||||
},
|
||||
[Role.TeamAdmin]: {
|
||||
email: "teamadmin@test.local",
|
||||
password: "test",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
model_list:
|
||||
- model_name: fake-openai-gpt-4
|
||||
litellm_params:
|
||||
model: openai/fake-gpt-4
|
||||
api_base: os.environ/MOCK_LLM_URL
|
||||
api_key: fake-key
|
||||
- model_name: fake-anthropic-claude
|
||||
litellm_params:
|
||||
model: openai/fake-claude
|
||||
api_base: os.environ/MOCK_LLM_URL
|
||||
api_key: fake-key
|
||||
|
||||
general_settings:
|
||||
master_key: os.environ/LITELLM_MASTER_KEY
|
||||
database_url: os.environ/DATABASE_URL
|
||||
store_prompts_in_spend_logs: true
|
||||
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
Mock LLM server for UI e2e tests.
|
||||
Responds to OpenAI-format endpoints with canned responses.
|
||||
"""
|
||||
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
|
||||
app = FastAPI(title="Mock LLM Server")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/v1/models")
|
||||
@app.get("/models")
|
||||
async def list_models():
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"id": "fake-gpt-4", "object": "model", "owned_by": "mock"},
|
||||
{"id": "fake-claude", "object": "model", "owned_by": "mock"},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/chat/completions")
|
||||
@app.post("/chat/completions")
|
||||
async def chat_completions(request: Request):
|
||||
body = await request.json()
|
||||
model = body.get("model", "mock-model")
|
||||
stream = body.get("stream", False)
|
||||
|
||||
response_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
|
||||
created = int(time.time())
|
||||
|
||||
if stream:
|
||||
async def stream_generator():
|
||||
chunk = {
|
||||
"id": response_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"delta": {"role": "assistant", "content": "This is a mock response."},
|
||||
"finish_reason": None,
|
||||
}
|
||||
],
|
||||
}
|
||||
yield f"data: {json.dumps(chunk)}\n\n"
|
||||
|
||||
done_chunk = {
|
||||
"id": response_id,
|
||||
"object": "chat.completion.chunk",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
|
||||
}
|
||||
yield f"data: {json.dumps(done_chunk)}\n\n"
|
||||
yield "data: [DONE]\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
stream_generator(), media_type="text/event-stream"
|
||||
)
|
||||
|
||||
return {
|
||||
"id": response_id,
|
||||
"object": "chat.completion",
|
||||
"created": created,
|
||||
"model": model,
|
||||
"choices": [
|
||||
{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "This is a mock response."},
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18},
|
||||
}
|
||||
|
||||
|
||||
@app.post("/v1/embeddings")
|
||||
@app.post("/embeddings")
|
||||
async def embeddings(request: Request):
|
||||
body = await request.json()
|
||||
inputs = body.get("input", [""])
|
||||
if isinstance(inputs, str):
|
||||
inputs = [inputs]
|
||||
return {
|
||||
"object": "list",
|
||||
"data": [
|
||||
{"object": "embedding", "index": i, "embedding": [0.0] * 1536}
|
||||
for i in range(len(inputs))
|
||||
],
|
||||
"model": body.get("model", "mock-embedding"),
|
||||
"usage": {"prompt_tokens": 5, "total_tokens": 5},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=8090)
|
||||
@@ -0,0 +1,103 @@
|
||||
-- UI E2E Test Database Seed
|
||||
-- Run with: psql $DATABASE_URL -f seed.sql
|
||||
|
||||
-- ============================================================
|
||||
-- 1. Budget Table (must be first — referenced by org FK)
|
||||
-- ============================================================
|
||||
INSERT INTO "LiteLLM_BudgetTable" (
|
||||
budget_id, max_budget, created_by, updated_by
|
||||
) VALUES (
|
||||
'e2e-budget-org', 1000.0, 'e2e-proxy-admin', 'e2e-proxy-admin'
|
||||
) ON CONFLICT (budget_id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 2. Organization
|
||||
-- ============================================================
|
||||
INSERT INTO "LiteLLM_OrganizationTable" (
|
||||
organization_id, organization_alias, budget_id, metadata, models, spend,
|
||||
model_spend, created_by, updated_by
|
||||
) VALUES (
|
||||
'e2e-org-main', 'E2E Organization', 'e2e-budget-org', '{}'::jsonb,
|
||||
ARRAY[]::text[], 0.0, '{}'::jsonb, 'e2e-proxy-admin', 'e2e-proxy-admin'
|
||||
) ON CONFLICT (organization_id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 3. Users (password is scrypt hash of "test")
|
||||
-- ============================================================
|
||||
INSERT INTO "LiteLLM_UserTable" (
|
||||
user_id, user_email, user_role, password, teams, models, metadata,
|
||||
spend, model_spend, model_max_budget
|
||||
) VALUES
|
||||
(
|
||||
'e2e-proxy-admin', 'admin@test.local', 'proxy_admin', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr',
|
||||
ARRAY['e2e-team-crud']::text[], ARRAY[]::text[], '{}'::jsonb,
|
||||
0.0, '{}'::jsonb, '{}'::jsonb
|
||||
),
|
||||
(
|
||||
'e2e-admin-viewer', 'adminviewer@test.local', 'proxy_admin_viewer', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr',
|
||||
ARRAY[]::text[], ARRAY[]::text[], '{}'::jsonb,
|
||||
0.0, '{}'::jsonb, '{}'::jsonb
|
||||
),
|
||||
(
|
||||
'e2e-internal-user', 'internal@test.local', 'internal_user', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr',
|
||||
ARRAY['e2e-team-crud', 'e2e-team-org']::text[], ARRAY[]::text[], '{}'::jsonb,
|
||||
0.0, '{}'::jsonb, '{}'::jsonb
|
||||
),
|
||||
(
|
||||
'e2e-internal-viewer', 'viewer@test.local', 'internal_user_viewer', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr',
|
||||
ARRAY[]::text[], ARRAY[]::text[], '{}'::jsonb,
|
||||
0.0, '{}'::jsonb, '{}'::jsonb
|
||||
),
|
||||
(
|
||||
'e2e-team-admin', 'teamadmin@test.local', 'internal_user', 'scrypt:MU5CcTAi6rVK1HfY1rVPEWq6r4sxg837eq9dG4n5Q6BhDJ44442+seC6LAhLEAYr',
|
||||
ARRAY['e2e-team-crud', 'e2e-team-delete']::text[], ARRAY[]::text[], '{}'::jsonb,
|
||||
0.0, '{}'::jsonb, '{}'::jsonb
|
||||
)
|
||||
ON CONFLICT (user_id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 4. Teams
|
||||
-- ============================================================
|
||||
INSERT INTO "LiteLLM_TeamTable" (
|
||||
team_id, team_alias, organization_id, admins, members,
|
||||
members_with_roles, metadata, models, spend, model_spend,
|
||||
model_max_budget, blocked
|
||||
) VALUES
|
||||
(
|
||||
'e2e-team-crud', 'E2E Team CRUD', NULL,
|
||||
ARRAY['e2e-team-admin']::text[],
|
||||
ARRAY['e2e-team-admin', 'e2e-internal-user']::text[],
|
||||
'[{"role": "admin", "user_id": "e2e-team-admin"}, {"role": "user", "user_id": "e2e-internal-user"}]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
ARRAY['fake-openai-gpt-4', 'fake-anthropic-claude']::text[],
|
||||
0.0, '{}'::jsonb, '{}'::jsonb, false
|
||||
),
|
||||
(
|
||||
'e2e-team-delete', 'E2E Team Delete', NULL,
|
||||
ARRAY['e2e-team-admin']::text[],
|
||||
ARRAY['e2e-team-admin']::text[],
|
||||
'[{"role": "admin", "user_id": "e2e-team-admin"}]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
ARRAY['fake-openai-gpt-4']::text[],
|
||||
0.0, '{}'::jsonb, '{}'::jsonb, false
|
||||
),
|
||||
(
|
||||
'e2e-team-org', 'E2E Team In Org', 'e2e-org-main',
|
||||
ARRAY[]::text[],
|
||||
ARRAY['e2e-internal-user']::text[],
|
||||
'[{"role": "user", "user_id": "e2e-internal-user"}]'::jsonb,
|
||||
'{}'::jsonb,
|
||||
ARRAY['fake-openai-gpt-4']::text[],
|
||||
0.0, '{}'::jsonb, '{}'::jsonb, false
|
||||
)
|
||||
ON CONFLICT (team_id) DO NOTHING;
|
||||
|
||||
-- ============================================================
|
||||
-- 5. Team Memberships
|
||||
-- ============================================================
|
||||
INSERT INTO "LiteLLM_TeamMembership" (user_id, team_id, spend) VALUES
|
||||
('e2e-team-admin', 'e2e-team-crud', 0.0),
|
||||
('e2e-internal-user', 'e2e-team-crud', 0.0),
|
||||
('e2e-team-admin', 'e2e-team-delete', 0.0),
|
||||
('e2e-internal-user', 'e2e-team-org', 0.0)
|
||||
ON CONFLICT (user_id, team_id) DO NOTHING;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { chromium, expect } from "@playwright/test";
|
||||
import { users, Role, ADMIN_STORAGE_PATH } from "./constants";
|
||||
import * as fs from "fs";
|
||||
|
||||
async function globalSetup() {
|
||||
const browser = await chromium.launch();
|
||||
const page = await browser.newPage();
|
||||
await page.goto("http://localhost:4000/ui/login");
|
||||
await page.getByPlaceholder("Enter your username").fill(users[Role.ProxyAdmin].email);
|
||||
await page.getByPlaceholder("Enter your password").fill(users[Role.ProxyAdmin].password);
|
||||
await page.getByRole("button", { name: "Login", exact: true }).click();
|
||||
try {
|
||||
// Wait for navigation away from login page into the dashboard
|
||||
await page.waitForURL(
|
||||
(url) => url.pathname.startsWith("/ui") && !url.pathname.includes("/login"),
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
// Wait for sidebar to render as a signal that the dashboard is ready
|
||||
await expect(page.getByRole("menuitem", { name: "Virtual Keys" })).toBeVisible({ timeout: 30_000 });
|
||||
} catch (e) {
|
||||
// Save a screenshot for debugging before re-throwing
|
||||
fs.mkdirSync("test-results", { recursive: true });
|
||||
await page.screenshot({ path: "test-results/global-setup-failure.png", fullPage: true });
|
||||
console.error("Global setup failed. Screenshot saved to test-results/global-setup-failure.png");
|
||||
console.error("Current URL:", page.url());
|
||||
throw e;
|
||||
}
|
||||
await page.context().storageState({ path: ADMIN_STORAGE_PATH });
|
||||
await browser.close();
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Page as PlaywrightPage, expect } from "@playwright/test";
|
||||
import { users, Role } from "../constants";
|
||||
|
||||
export async function loginAs(page: PlaywrightPage, role: Role) {
|
||||
const user = users[role];
|
||||
await page.goto("/ui/login");
|
||||
await page.getByPlaceholder("Enter your username").fill(user.email);
|
||||
await page.getByPlaceholder("Enter your password").fill(user.password);
|
||||
await page.getByRole("button", { name: "Login", exact: true }).click();
|
||||
// Wait for navigation away from login page into the dashboard
|
||||
await page.waitForURL((url) => url.pathname.startsWith("/ui") && !url.pathname.includes("/login"), {
|
||||
timeout: 30_000,
|
||||
});
|
||||
// Wait for sidebar to render as a signal that the dashboard is ready
|
||||
await expect(page.getByRole("menuitem", { name: "Virtual Keys" })).toBeVisible({ timeout: 30_000 });
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Page as PlaywrightPage } from "@playwright/test";
|
||||
import { Page } from "../constants";
|
||||
|
||||
export async function navigateToPage(page: PlaywrightPage, targetPage: Page) {
|
||||
await page.goto(`/ui?page=${targetPage}`);
|
||||
}
|
||||
Generated
+76
@@ -0,0 +1,76 @@
|
||||
{
|
||||
"name": "litellm-ui-e2e-tests",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "litellm-ui-e2e-tests",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "litellm-ui-e2e-tests",
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.50.0"
|
||||
},
|
||||
"scripts": {
|
||||
"e2e": "playwright test",
|
||||
"e2e:headed": "playwright test --headed",
|
||||
"e2e:ui": "playwright test --ui"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
|
||||
const isCI = !!process.env.CI;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: "**/*.spec.ts",
|
||||
globalSetup: "./globalSetup.ts",
|
||||
fullyParallel: false,
|
||||
forbidOnly: isCI,
|
||||
retries: isCI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: isCI ? [["html", { open: "never" }]] : [["html"]],
|
||||
timeout: 4 * 60 * 1000,
|
||||
expect: {
|
||||
timeout: 10_000,
|
||||
},
|
||||
use: {
|
||||
baseURL: "http://localhost:4000",
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
actionTimeout: 15_000,
|
||||
navigationTimeout: 30_000,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: "chromium",
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
Executable
+162
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ================================================================
|
||||
# UI E2E Test Runner
|
||||
# Starts postgres, seeds DB, starts mock + proxy, runs Playwright.
|
||||
# All credentials are generated per run — nothing is stored on disk.
|
||||
#
|
||||
# In CI (CI=true), expects:
|
||||
# - PostgreSQL already running on 127.0.0.1:5432
|
||||
# - DATABASE_URL already set
|
||||
# - Python/Poetry already installed
|
||||
# - Node.js/npx already available
|
||||
# ================================================================
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
IS_CI="${CI:-false}"
|
||||
CONTAINER_NAME="litellm-e2e-postgres-$$"
|
||||
MOCK_PID=""
|
||||
PROXY_PID=""
|
||||
|
||||
# --- Ensure common tool paths are available (local dev only) ---
|
||||
if [ "$IS_CI" = "false" ]; then
|
||||
for p in /usr/local/bin /opt/homebrew/bin "$HOME/.local/bin" /opt/homebrew/opt/postgresql@14/bin /opt/homebrew/opt/libpq/bin; do
|
||||
[ -d "$p" ] && export PATH="$p:$PATH"
|
||||
done
|
||||
[ -s "$HOME/.nvm/nvm.sh" ] && source "$HOME/.nvm/nvm.sh"
|
||||
fi
|
||||
|
||||
# --- Cleanup on exit ---
|
||||
cleanup() {
|
||||
echo "Cleaning up..."
|
||||
[ -n "$MOCK_PID" ] && kill "$MOCK_PID" 2>/dev/null || true
|
||||
[ -n "$PROXY_PID" ] && kill "$PROXY_PID" 2>/dev/null || true
|
||||
if [ "$IS_CI" = "false" ]; then
|
||||
docker stop "$CONTAINER_NAME" 2>/dev/null || true
|
||||
fi
|
||||
echo "Done."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
for cmd in python3 npx poetry; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "Error: $cmd not found."; exit 1; }
|
||||
done
|
||||
|
||||
# --- Database setup ---
|
||||
if [ "$IS_CI" = "false" ]; then
|
||||
# Local: spin up a postgres container
|
||||
for cmd in docker psql; do
|
||||
command -v "$cmd" >/dev/null 2>&1 || { echo "Error: $cmd not found."; exit 1; }
|
||||
done
|
||||
for port in 4000 5432 8090; do
|
||||
if lsof -ti ":$port" >/dev/null 2>&1; then
|
||||
echo "Error: port $port is in use"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
export POSTGRES_USER="e2euser"
|
||||
export POSTGRES_PASSWORD="$(openssl rand -hex 32)"
|
||||
export POSTGRES_DB="litellm_e2e"
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:5432/${POSTGRES_DB}"
|
||||
|
||||
echo "=== Starting PostgreSQL ==="
|
||||
docker run -d --rm --name "$CONTAINER_NAME" \
|
||||
-e POSTGRES_USER -e POSTGRES_PASSWORD -e POSTGRES_DB \
|
||||
-p 127.0.0.1:5432:5432 \
|
||||
postgres:16
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
for i in $(seq 1 30); do
|
||||
if PGPASSWORD="$POSTGRES_PASSWORD" pg_isready -h 127.0.0.1 -U "$POSTGRES_USER" -d "$POSTGRES_DB" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
else
|
||||
# CI: postgres is already running as a service container
|
||||
echo "=== Using CI PostgreSQL service ==="
|
||||
: "${DATABASE_URL:?DATABASE_URL must be set in CI}"
|
||||
fi
|
||||
|
||||
# --- Credentials ---
|
||||
export LITELLM_MASTER_KEY="sk-e2e-$(openssl rand -hex 32)"
|
||||
export MOCK_LLM_URL="http://127.0.0.1:8090/v1"
|
||||
export DISABLE_SCHEMA_UPDATE="true"
|
||||
|
||||
# --- Python environment ---
|
||||
echo "=== Setting up Python environment ==="
|
||||
cd "$REPO_ROOT"
|
||||
if ! poetry run python3 -c "import prisma" 2>/dev/null; then
|
||||
echo "Installing Python dependencies (first run)..."
|
||||
poetry install --with dev,proxy-dev --extras "proxy" --quiet
|
||||
poetry run pip install nodejs-wheel-binaries 2>/dev/null || true
|
||||
poetry run prisma generate --schema litellm/proxy/schema.prisma
|
||||
fi
|
||||
|
||||
echo "=== Pushing Prisma schema to database ==="
|
||||
poetry run prisma db push --schema litellm/proxy/schema.prisma --accept-data-loss
|
||||
|
||||
# --- Mock LLM server ---
|
||||
echo "=== Starting mock LLM server ==="
|
||||
poetry run python3 "$SCRIPT_DIR/fixtures/mock_llm_server/server.py" &
|
||||
MOCK_PID=$!
|
||||
|
||||
for i in $(seq 1 15); do
|
||||
if curl -sf http://127.0.0.1:8090/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# --- LiteLLM proxy ---
|
||||
echo "=== Starting LiteLLM proxy ==="
|
||||
cd "$REPO_ROOT"
|
||||
poetry run python3 -m litellm.proxy.proxy_cli \
|
||||
--config "$SCRIPT_DIR/fixtures/config.yml" \
|
||||
--port 4000 &
|
||||
PROXY_PID=$!
|
||||
|
||||
echo "Waiting for proxy..."
|
||||
PROXY_READY=0
|
||||
for i in $(seq 1 180); do
|
||||
if ! kill -0 "$PROXY_PID" 2>/dev/null; then
|
||||
echo "Error: proxy process exited unexpectedly"
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:4000/health -H "Authorization: Bearer $LITELLM_MASTER_KEY" 2>/dev/null || true)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
PROXY_READY=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$PROXY_READY" -ne 1 ]; then
|
||||
echo "Error: proxy did not become healthy within 180 seconds"
|
||||
exit 1
|
||||
fi
|
||||
echo "Proxy is ready."
|
||||
|
||||
# --- Seed database ---
|
||||
echo "=== Seeding database ==="
|
||||
# Extract credentials from DATABASE_URL for psql
|
||||
DB_USER=$(echo "$DATABASE_URL" | sed -n 's|.*://\([^:]*\):.*|\1|p')
|
||||
DB_PASS=$(echo "$DATABASE_URL" | sed -n 's|.*://[^:]*:\([^@]*\)@.*|\1|p')
|
||||
DB_HOST=$(echo "$DATABASE_URL" | sed -n 's|.*@\([^:]*\):.*|\1|p')
|
||||
DB_PORT=$(echo "$DATABASE_URL" | sed -n 's|.*:\([0-9]*\)/.*|\1|p')
|
||||
DB_NAME=$(echo "$DATABASE_URL" | sed -n 's|.*/\([^?]*\).*|\1|p')
|
||||
|
||||
PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" \
|
||||
-f "$SCRIPT_DIR/fixtures/seed.sql"
|
||||
|
||||
# --- Playwright ---
|
||||
echo "=== Installing Playwright dependencies ==="
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install --silent
|
||||
|
||||
echo "=== Running Playwright tests ==="
|
||||
npx playwright test "$@"
|
||||
EXIT_CODE=$?
|
||||
|
||||
exit $EXIT_CODE
|
||||
@@ -0,0 +1,10 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Role } from "../../constants";
|
||||
import { loginAs } from "../../helpers/login";
|
||||
|
||||
test.describe("Admin Viewer Role", () => {
|
||||
test("Should not see Test Key page", async ({ page }) => {
|
||||
await loginAs(page, Role.ProxyAdminViewer);
|
||||
await expect(page.getByRole("menuitem", { name: "Test Key" })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Page, Role } from "../../constants";
|
||||
import { loginAs } from "../../helpers/login";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
|
||||
test.describe("Internal User Role", () => {
|
||||
test("Should not see litellm-dashboard keys", async ({ page }) => {
|
||||
await loginAs(page, Role.InternalUser);
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await expect(page.getByText("litellm-dashboard")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Page, Role } from "../../constants";
|
||||
import { loginAs } from "../../helpers/login";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
|
||||
test.describe("Internal User Viewer Role", () => {
|
||||
test("Can only see allowed pages", async ({ page }) => {
|
||||
await loginAs(page, Role.InternalUserViewer);
|
||||
await expect(page.getByRole("menuitem", { name: "Virtual Keys" })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Admin Settings" })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Cannot create keys", async ({ page }) => {
|
||||
await loginAs(page, Role.InternalUserViewer);
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await expect(page.getByRole("button", { name: /Create New Key/i })).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("Cannot edit or delete keys", async ({ page }) => {
|
||||
await loginAs(page, Role.InternalUserViewer);
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
// Ensure the keys table has loaded before asserting absence of actions
|
||||
await expect(page.getByRole("menuitem", { name: "Virtual Keys" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Edit Key/i })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Delete Key/i })).not.toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /Regenerate Key/i })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH, Page } from "../../constants";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
|
||||
test.describe("Proxy Admin Role", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("Can create keys", async ({ page }) => {
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await expect(page.getByRole("button", { name: /Create New Key/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Can list teams via API", async ({ page }) => {
|
||||
const response = await page.request.get("/team/list", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${process.env.LITELLM_MASTER_KEY || "sk-1234"}`,
|
||||
},
|
||||
});
|
||||
expect(response.status()).toBe(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { Page, Role } from "../../constants";
|
||||
import { loginAs } from "../../helpers/login";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
|
||||
test.describe("Team Admin Role", () => {
|
||||
test("Can view all team keys", async ({ page }) => {
|
||||
await loginAs(page, Role.TeamAdmin);
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await expect(page.getByRole("menuitem", { name: "Virtual Keys" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { users, Role } from "../../constants";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test("Login with valid admin credentials", async ({ page }) => {
|
||||
await page.goto("/ui/login");
|
||||
await page.getByPlaceholder("Enter your username").fill(users[Role.ProxyAdmin].email);
|
||||
await page.getByPlaceholder("Enter your password").fill(users[Role.ProxyAdmin].password);
|
||||
await page.getByRole("button", { name: "Login", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Virtual Keys" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("Unauthenticated user is redirected to login", async ({ page }) => {
|
||||
await page.goto("/ui");
|
||||
await page.waitForURL(/\/ui\/login/);
|
||||
await expect(page.getByRole("heading", { name: /Login/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["**/*.ts"]
|
||||
}
|
||||
@@ -16,70 +16,70 @@
|
||||
"format:check": "prettier --check .",
|
||||
"e2e": "playwright test --config e2e_tests/playwright.config.ts",
|
||||
"e2e:ui": "playwright test --ui --config e2e_tests/playwright.config.ts",
|
||||
"e2e:psql": "../../tests/ui_e2e_tests/run_e2e.sh",
|
||||
"knip": "knip",
|
||||
"knip:fix": "knip --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.54.0",
|
||||
"@headlessui/tailwindcss": "^0.2.0",
|
||||
"@heroicons/react": "^1.0.6",
|
||||
"@remixicon/react": "^4.1.1",
|
||||
"@tanstack/react-pacer": "^0.2.0",
|
||||
"@tanstack/react-query": "^5.64.1",
|
||||
"@tanstack/react-table": "^8.20.6",
|
||||
"@tremor/react": "^3.13.3",
|
||||
"@types/papaparse": "^5.3.15",
|
||||
"antd": "^5.13.2",
|
||||
"cva": "^1.0.0-beta.3",
|
||||
"dayjs": "^1.11.19",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"lucide-react": "^0.513.0",
|
||||
"moment": "^2.30.1",
|
||||
"next": "^16.1.7",
|
||||
"openai": "^4.93.0",
|
||||
"papaparse": "^5.5.2",
|
||||
"react": "^18.3.1",
|
||||
"react-copy-to-clipboard": "^5.1.0",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-json-view-lite": "^2.5.0",
|
||||
"react-markdown": "^9.0.1",
|
||||
"react-syntax-highlighter": "^15.6.6",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"tailwind-merge": "^3.2.0",
|
||||
"uuid": "^11.1.0"
|
||||
"@anthropic-ai/sdk": "0.54.0",
|
||||
"@headlessui/tailwindcss": "0.2.2",
|
||||
"@heroicons/react": "1.0.6",
|
||||
"@remixicon/react": "4.9.0",
|
||||
"@tanstack/react-pacer": "0.2.0",
|
||||
"@tanstack/react-query": "5.90.20",
|
||||
"@tanstack/react-table": "8.21.3",
|
||||
"@tremor/react": "3.18.7",
|
||||
"@types/papaparse": "5.5.2",
|
||||
"antd": "5.29.3",
|
||||
"cva": "1.0.0-beta.4",
|
||||
"dayjs": "1.11.19",
|
||||
"jwt-decode": "4.0.0",
|
||||
"lucide-react": "0.513.0",
|
||||
"moment": "2.30.1",
|
||||
"next": "16.1.7",
|
||||
"openai": "4.104.0",
|
||||
"papaparse": "5.5.3",
|
||||
"react": "18.3.1",
|
||||
"react-copy-to-clipboard": "5.1.0",
|
||||
"react-dom": "18.3.1",
|
||||
"react-json-view-lite": "2.5.0",
|
||||
"react-markdown": "9.1.0",
|
||||
"react-syntax-highlighter": "15.6.6",
|
||||
"remark-gfm": "4.0.1",
|
||||
"tailwind-merge": "3.4.0",
|
||||
"uuid": "11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@neondatabase/api-client": "^2.6.0",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@tailwindcss/forms": "^0.5.7",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^6.8.0",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/babel__traverse": "^7.28.0",
|
||||
"@types/lodash": "^4.17.15",
|
||||
"@playwright/test": "1.58.1",
|
||||
"@tailwindcss/forms": "0.5.11",
|
||||
"@testing-library/dom": "10.4.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@testing-library/user-event": "14.6.1",
|
||||
"@types/babel__traverse": "7.28.0",
|
||||
"@types/lodash": "4.17.23",
|
||||
"@types/node": "20.19.37",
|
||||
"@types/react": "18.2.48",
|
||||
"@types/react-copy-to-clipboard": "^5.0.7",
|
||||
"@types/react-dom": "^18",
|
||||
"@types/react-syntax-highlighter": "^15.5.11",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^3.2.4",
|
||||
"@vitest/ui": "^3.2.4",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.2",
|
||||
"@types/react-copy-to-clipboard": "5.0.7",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"@vitest/ui": "3.2.4",
|
||||
"autoprefixer": "10.4.24",
|
||||
"dotenv": "17.2.3",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-next": "15.5.10",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-unused-imports": "^4.2.0",
|
||||
"jsdom": "^27.0.0",
|
||||
"knip": "^5.83.1",
|
||||
"postcss": "^8.4.33",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-unused-imports": "4.3.0",
|
||||
"jsdom": "27.4.0",
|
||||
"knip": "5.83.1",
|
||||
"postcss": "8.5.6",
|
||||
"prettier": "3.2.5",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tailwindcss": "3.4.19",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "^7.1.11",
|
||||
"vitest": "^3.2.4"
|
||||
"vite": "7.3.1",
|
||||
"vitest": "3.2.4"
|
||||
},
|
||||
"overrides": {
|
||||
"prismjs": "1.30.0",
|
||||
|
||||
Reference in New Issue
Block a user