mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
test(e2e-ui): verify UI mutations against the API instead of trusting the toast (#36632)
* test(e2e-ui): cover the Playground, Logs and Usage manual-QA flows
These three pages carried no e2e coverage, so the manual QA checklist was the
only thing standing behind them.
Playground: sends a chat from the UI for both configured models, and for both
virtual-key sources (the logged-in session, and a key pasted into the panel).
This is the only spec that drives the dashboard's own LLM call path rather than
an admin CRUD endpoint.
Logs: a request the proxy actually served appears in the table, its drawer
expands to the real request and response bodies, both copy to the clipboard,
the Input card collapses, the JSON view exposes Request/Response, and the End
User filter narrows the table to one customer.
Usage: traffic billed to a virtual key reaches Top Virtual Keys, the card
toggles between table and chart, and the key opens its key-info panel.
Router settings: the existing spec proved the UI can record a fallback; the new
one proves the fallback is honoured, by pointing a model at an unreachable
upstream and asserting the reply comes back anyway. It asserts the un-fallen-back
call fails first, so a quietly-working primary cannot fake a pass.
Supporting changes:
- helpers/traffic.ts generates the traffic these pages render, rather than
seeding rows no code produced. Its two wait helpers exist because the Logs
and Usage pages read different stores: spend logs are flushed on a timer, and
the Usage page reads a background rollup *and* fetches once on mount, so
waiting on the DOM there can never converge.
- helpers/playground.ts holds the playground controls, now shared with the
fallback spec. Everything is scoped to the visible copy of the config panel,
which is rendered twice for the docked and collapsed layouts.
- run_e2e.sh gains E2E_KEEP_ALIVE=1, which brings the stack up and blocks so a
spec can be re-run against it without paying for a UI rebuild each iteration.
Verified with the full suite on a fresh stack: 89 passed, 0 failed, 5 skipped.
* test(e2e-ui): cover listing and calling MCP tools
Covers the two MCP manual-QA items the create-only spec cannot reach:
opening a server's tool list, and calling a tool and seeing its result.
Both need an MCP server that actually answers, so this points at DeepWiki's
public MCP server -- Streamable HTTP, auth None, so there is no credential to
hold and nothing to leak from a public repo. The call is made by the proxy,
not the browser; nothing in the e2e chart restricts that egress.
The external dependency is real and is left visible: an upstream outage turns
these red rather than auto-skipping, because a spec that skips itself on
connection trouble also skips when the proxy's MCP client is what broke.
E2E_SKIP_EXTERNAL_MCP=1 is the explicit opt-out.
Not yet executed against a live stack.
* test(e2e-ui): verify key mutations round-trip instead of trusting the toast
The recurring customer report is a form that says "Saved!" and then either
no-ops or clobbers an unrelated field. A toast-only assertion passes in both
cases, and outside three specs that is all this suite checks.
Adds helpers/roundTrip.ts, factoring out the idiom clearCustomPricing,
credentials and routerSettings already use: capture the outgoing request body,
then read the resource back through the management API.
Applies it to the keys spec:
- create: the key is readable from /key/list and owns a team_id, rather than
trusting a table row rendered from the create response the UI already held
- update limits: TPM/RPM are on the wire AND persisted, and the key's models
and team are unchanged -- bumping one field wiping another is the reported
failure mode (PR #34452), not a hypothetical
- delete: the key is gone from /key/list, not merely toasted as deleted
- regenerate: the stored token actually changed
/key/list shape is per KeyListResponseObject in litellm/proxy/_types.py.
Not yet executed: ports 4000/8090 are held by a parallel run.
* test(e2e-ui): let the local harness run on non-default ports
Two checkouts cannot run run_e2e.sh at the same time: it hardcodes 4000/5432/
8090, so the second aborts on "port 4000 is in use" and the only way forward is
to stop someone else's stack.
PROXY_PORT / POSTGRES_PORT / MOCK_LLM_PORT now override those, defaulting to the
historical values so an unset environment behaves exactly as before -- CI, the
CircleCI job and the chart's sidecar all keep working untouched.
Two details that would otherwise make a relocated stack fail confusingly:
- the suite resolves its target from E2E_UI_BASE_URL, which defaults to :4000
independently, so the run would build and boot correctly and then test
whatever was on the default port. run_e2e.sh now derives it.
- the mock server binds its port in server.py, so moving it needs MOCK_LLM_PORT
there too. Its HOST stays loopback-only: 127.0.0.1:8090 from inside the
proxy's own pod is the contract the e2e chart's sidecar is written against.
* test(e2e-ui): cover MCP server edit and delete, verified via the API
mcpServers.spec.ts only ever creates a server, and creation is the one MCP
operation nobody has complained about. The reports are all on the other side:
an alias rename that needs three or four saves to take, a delete that needs two
attempts. Both produce a success toast on the failing attempt, so a toast-only
assertion cannot tell them from working software.
Rename asserts the new alias and the target server_id are on the PUT, then
polls /v1/mcp/server until the stored alias matches -- one save has to be
enough. Delete asserts the server is really gone from the list.
Points at an unreachable URL: these exercise litellm's persistence, never the
upstream, so a live MCP server would add a network dependency for nothing.
mcpTools.spec.ts is where a real upstream is needed.
Both pass against a local stack, as do the mcpTools specs from 5e189e9b1a.
Neither reproduced the reported failures on this build -- they guard, they did
not catch.
* test(e2e-ui): verify team create, invite and delete against the API
Three team mutations stopped at a toast, and one of those toasts is matched as
loosely as /success/i -- almost any notification satisfied it.
- create: the team is readable from /team/list and kept the models chosen in
the modal, rather than trusting the UI's own "Team created"
- invite: the invited address really appears in members_with_roles, which is
the point of the flow
- delete: the team is gone from /team/list. The existing assertion was that
the row vanished, which is the client dropping it from local state and
happens whether or not the delete reached the database.
Shapes read off a live proxy: /team/list is a bare array; /team/info nests the
record under team_info. All 6 tests pass locally.
* test(e2e-ui): verify team-admin member and key mutations against the API
The team-admin flows stopped at a success toast. A member add that lands on
the wrong team, a remove that takes out the wrong row, and a key that comes
back unscoped all produce the same toast as the working case, so the existing
assertions could not tell them apart.
Each mutation now pins what went on the wire and reads the result back:
member add/remove assert team_id and the member identifier on the request,
then poll /team/info's roster; the team key asserts team_id on /key/generate
and reads /key/list back to confirm the key is owned by the admin's own team
rather than orphaned.
* test(e2e-ui): verify model add and limit edits against the stored deployment
The Models specs checked the rendered result: the TPM/RPM edit asserted the
new numbers were visible in view mode, and the two add flows asserted a row
showed up in the table. Both render from state the UI already holds, so a save
the backend dropped and a save it kept look the same.
Each mutation now pins the request and reads the deployment back. The limits
edit also asserts the fields it did not touch -- upstream model and team
ownership -- are unchanged, because handleModelUpdate rebuilds and PATCHes the
whole litellm_params blob, which is how an unrelated field gets clobbered by a
save that reports success.
The two add flows assert model_name, the routed model and custom_llm_provider
on the wire and in storage; a deployment that loses its provider looks correct
in the table and is unroutable.
The Team-BYOK test is unchanged -- it is skipped without a license, so any
change to it would be unverified.
* test(e2e-ui): delete the MCP servers these specs create
MCP servers outlive the test that made them, the MCP page contacts every
server it lists, and most of the ones these specs create point at an
unreachable host. They accumulate, and each one makes navigateToPage's
networkidle wait a little slower to settle.
Measured on a local stack: with eleven leaked servers the whole MCP suite
failed on a 30s navigation timeout, including specs that leaked nothing.
Deleting the leftovers made all five pass. With per-test cleanup added, a
run from a clean slate leaves zero behind and takes 20s instead of 1m24s.
mcpServers.spec.ts carried a note that no teardown was needed because the
runner brings up a fresh database each time. That holds for CI and is why
this went unnoticed; it does not hold for a local stack that is reused.
* test(e2e-ui): say why the team-model setup call failed
The setup that creates a team-scoped model asserted a bare `ok()`, so a
failure read "expected true, received false" and pointed at the UI. The call
is enterprise-gated -- creating a model with model_info.team_id returns 403
without LITELLM_LICENSE -- and that is invisible from the old message. It now
carries the status and body, which names the cause immediately.
The api_base also pointed at the mock's default port rather than the one the
harness started; nothing in the test calls the model, but the two should not
disagree.
* test(e2e-ui): add a model through the UI and serve traffic with it
Every existing Add Model test stops at "the row appears in the table",
which a deployment that cannot serve a single request also does. The
manual-QA item this replaces is the whole loop: fill the form, pass Test
Connect, add it, confirm it works.
The new test ends by calling the model it just created. That is the only
assertion that rules out a dropped api_base, a mangled provider prefix,
or a name the router never registers -- all of which look identical in
the UI.
No provider credential is involved. OpenAI-Compatible is the provider
whose form exposes API Base, so the deployment points at the harness's
own mock LLM. The mock speaks the OpenAI wire format, so Test Connect
performs a real completion against a real endpoint and really succeeds.
Also adds teardown for the deployment it creates. A local run throws its
database away, but the deployed stack does not, and a leaked deployment
shows up in every later Models table and /v2/model/info readback.
Both new assertions were mutation-tested: pointing the traffic poll at a
name that was never created fails the test, and the wire assertion fails
when the typed name is not what reaches /model/new.
* test(e2e-ui): print the proxy log when the proxy dies on its own
In E2E_KEEP_ALIVE=1 mode the harness blocks until the proxy pid goes away, then
printed a bare "Proxy exited." and fell straight into cleanup, which rm -f's the
log. The proxy has now exited by itself twice, minutes after a run had finished,
leaving nothing to look at. Both startup failure paths already tail -n 100 the log
before giving up, so this was the one death that stayed silent
Dump the same 100 lines before exiting. A normal Ctrl-C teardown still deletes the
log and prints nothing, which is why INT and TERM now exit instead of running
cleanup and falling back into the wait loop: under the single trap a SIGTERM
deleted the log, resumed the loop, and would then report "tail: no such file",
besides running cleanup twice
* test(e2e-ui): split the log-drawer copy assertions off the expand test
The copy assertions need `navigator.clipboard`, which the browser only
exposes in a secure context. Locally the suite runs against
http://127.0.0.1 and localhost is trustworthy, so it is there. In CI the
run pod is pointed at a plain-HTTP cluster DNS name, where it is
undefined -- and InputCard.handleCopy calls writeText unguarded, so the
click throws before MessageManager.success and no toast ever renders.
That failed all three attempts of litellm-e2e-ui build 10.
Measured rather than inferred: on http://127.0.0.1:4100
isSecureContext/typeof navigator.clipboard are true/"object", and on a
DNS name resolving to that same address they are false/"undefined",
which reproduces the CI failure exactly.
Splitting keeps the drawer-rendering coverage running everywhere and
confines the skip to the part the browser has actually switched off. The
copy assertions still run in full wherever the origin is trustworthy.
The underlying product behaviour is left alone deliberately: any
deployment served over plain HTTP on a hostname has a copy button that
throws and gives no feedback, and that deserves its own fix rather than
being papered over from a test.
* test(e2e-ui): cut the added comments back to what the code cannot say itself
Greptile flagged the helper commentary, and it was right: CLAUDE.md says not to
write comments unless they explain very complex business logic, and much of what
was added here narrated ordinary test setup and motivation instead.
Trims 310 comment lines across the 14 files this branch touched. Kept only the
notes that record something unrecoverable from the code: why the request
listener is armed before the click, why a locator walks up the DOM, why an
assertion exists beyond the toast. Pre-existing comments are left alone.
No behaviour change. The only non-comment hunk is a prettier reformat.
This commit is contained in:
parent
0e9da56f89
commit
32a5259407
17 changed files with 1335 additions and 70 deletions
|
|
@ -3,6 +3,7 @@ Mock LLM server for UI e2e tests.
|
|||
Responds to OpenAI-format endpoints with canned responses.
|
||||
"""
|
||||
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
import uuid
|
||||
|
|
@ -117,4 +118,12 @@ async def embeddings(request: Request):
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(app, host="127.0.0.1", port=8090)
|
||||
# The port is overridable so two checkouts can run the harness at the same
|
||||
# time; the default keeps every existing caller (run_e2e.sh, the CircleCI
|
||||
# job, the e2e chart's sidecar) working untouched.
|
||||
#
|
||||
# The HOST is deliberately NOT configurable. Binding loopback is what makes
|
||||
# this reachable at 127.0.0.1:8090 from inside the proxy's own pod, which is
|
||||
# the contract the deployed config.yml and the e2e values file are written
|
||||
# against.
|
||||
uvicorn.run(app, host="127.0.0.1", port=int(os.environ.get("MOCK_LLM_PORT", "8090")))
|
||||
|
|
|
|||
65
tests/e2e/ui/helpers/mcp.ts
Normal file
65
tests/e2e/ui/helpers/mcp.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { expect, Page as PwPage } from "@playwright/test";
|
||||
import { navigateToPage } from "./navigation";
|
||||
import { Page } from "../fixtures/pages";
|
||||
import { masterKey } from "./traffic";
|
||||
|
||||
/** Creates an MCP server through the UI's discovery to custom-form flow and returns its name. */
|
||||
export async function createMcpServer(page: PwPage, url: string): Promise<string> {
|
||||
await navigateToPage(page, Page.McpServers);
|
||||
|
||||
await page.getByRole("button", { name: /Add New MCP Server/i }).click();
|
||||
const discovery = page.getByRole("dialog").filter({ hasText: "Add MCP Server" });
|
||||
await expect(discovery).toBeVisible({ timeout: 5_000 });
|
||||
await discovery.getByRole("button", { name: /Custom Server/i }).click();
|
||||
|
||||
const formModal = page.locator(".ant-modal:visible").filter({ hasText: "MCP Server Name" });
|
||||
await expect(formModal).toBeVisible({ timeout: 5_000 });
|
||||
|
||||
// validateMCPServerName rejects spaces and hyphens; the worker index avoids a same-millisecond collision.
|
||||
const name = `e2e_mcp_${process.env.TEST_WORKER_INDEX ?? "0"}_${Date.now()}`;
|
||||
await formModal.locator('input[id="server_name"]').fill(name);
|
||||
|
||||
const transportField = formModal.locator(".ant-form-item", { hasText: "Transport Type" });
|
||||
await transportField.locator(".ant-select").click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("Streamable HTTP").click();
|
||||
|
||||
await formModal.locator('input[id="url"]').fill(url);
|
||||
|
||||
// The auth_type Form.Item has no label prop, so anchor on the enclosing Collapse panel.
|
||||
const authSection = formModal.locator(".ant-collapse-item", { hasText: /^Authentication/ });
|
||||
await authSection.locator(".ant-form-item").first().locator(".ant-select").click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("None", { exact: true }).click();
|
||||
|
||||
await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click();
|
||||
await expect(page.getByText("MCP Server created successfully").first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
const card = page.getByTestId("mcp-servers-grid").getByText(name).first();
|
||||
await expect(card).toBeVisible({ timeout: 10_000 });
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes every server carrying `serverName`. Leaked servers break unrelated MCP specs: the page
|
||||
* reaches out to each one it lists, so unreachable leftovers stall networkidle until it times out.
|
||||
* Errors are swallowed because this runs from afterEach.
|
||||
*/
|
||||
export async function deleteMcpServerByName(page: PwPage, serverName: string): Promise<void> {
|
||||
const headers = { Authorization: `Bearer ${masterKey()}` };
|
||||
try {
|
||||
const res = await page.request.get("/v1/mcp/server", { headers });
|
||||
if (!res.ok()) return;
|
||||
const servers = (await res.json()) as { server_id: string; server_name?: string }[];
|
||||
for (const server of servers.filter((candidate) => candidate.server_name === serverName)) {
|
||||
await page.request.delete(`/v1/mcp/server/${server.server_id}`, { headers });
|
||||
}
|
||||
} catch {
|
||||
// best effort, see above
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens a server from the grid and switches to its MCP Tools tab. */
|
||||
export async function openMcpToolsTab(page: PwPage, serverName: string): Promise<void> {
|
||||
await page.getByTestId("mcp-servers-grid").getByText(serverName).first().click();
|
||||
await expect(page.getByRole("button", { name: /Back to All Servers/i })).toBeVisible({ timeout: 10_000 });
|
||||
await page.getByRole("tab", { name: "MCP Tools" }).click();
|
||||
}
|
||||
46
tests/e2e/ui/helpers/playground.ts
Normal file
46
tests/e2e/ui/helpers/playground.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "./navigation";
|
||||
import { Page } from "../fixtures/pages";
|
||||
|
||||
/** Controls for the Test Key / Playground page, shared with the router-fallback specs. */
|
||||
|
||||
/**
|
||||
* The configuration panel is rendered twice, docked and overlay, with one visible at a time.
|
||||
* Every control is narrowed to the visible copy or it trips strict mode against its hidden twin.
|
||||
*/
|
||||
export const onlyVisible = (locator: Locator): Locator => locator.filter({ visible: true }).first();
|
||||
|
||||
/** The model dropdown, addressed by the placeholder it shows before selection. */
|
||||
export const modelSelect = (page: PlaywrightPage): Locator =>
|
||||
onlyVisible(page.locator('.ant-select:has(.ant-select-selection-placeholder:text-is("Select a Model"))'));
|
||||
|
||||
/** Send button is icon-only (an up-arrow), so there is no accessible name. */
|
||||
export const sendButton = (page: PlaywrightPage): Locator => onlyVisible(page.locator("button:has(.anticon-arrow-up)"));
|
||||
|
||||
/** The Virtual Key Source dropdown, addressed by its currently selected label. */
|
||||
export const keySourceSelect = (page: PlaywrightPage, current: string): Locator =>
|
||||
onlyVisible(page.locator(`.ant-select:has(.ant-select-selection-item[title="${current}"])`));
|
||||
|
||||
export async function openPlayground(page: PlaywrightPage): Promise<void> {
|
||||
await navigateToPage(page, Page.LlmPlayground);
|
||||
await dismissFeedbackPopup(page);
|
||||
await expect(onlyVisible(page.getByText("Virtual Key Source"))).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function selectModel(page: PlaywrightPage, model: string): Promise<void> {
|
||||
const select = modelSelect(page);
|
||||
await select.click();
|
||||
// Virtualized: options outside the rendered window are absent from the DOM, so search first.
|
||||
await select.locator("input.ant-select-selection-search-input").fill(model);
|
||||
// antd portals its dropdown to the body; options carry the value as `title`.
|
||||
await onlyVisible(page.locator(`.ant-select-item-option[title="${model}"]`)).click({ timeout: 15_000 });
|
||||
}
|
||||
|
||||
export async function sendMessage(page: PlaywrightPage, message: string): Promise<void> {
|
||||
const input = onlyVisible(page.getByPlaceholder("Type your message", { exact: false }));
|
||||
await expect(input).toBeVisible({ timeout: 15_000 });
|
||||
await input.fill(message);
|
||||
await sendButton(page).click();
|
||||
}
|
||||
28
tests/e2e/ui/helpers/roundTrip.ts
Normal file
28
tests/e2e/ui/helpers/roundTrip.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { expect, Page } from "@playwright/test";
|
||||
import { masterKey } from "./traffic";
|
||||
|
||||
/**
|
||||
* Runs `action` and returns the parsed body of the first matching request.
|
||||
*
|
||||
* `action` is a callback so the listener is armed before the click; awaiting the
|
||||
* click first lets the request go by, and the test then hangs until timeout.
|
||||
*/
|
||||
export async function captureRequestBody(
|
||||
page: Page,
|
||||
match: { method: string; urlIncludes: string },
|
||||
action: () => Promise<void>,
|
||||
): Promise<Record<string, any>> {
|
||||
const pending = page.waitForRequest((req) => req.method() === match.method && req.url().includes(match.urlIncludes));
|
||||
await action();
|
||||
const request = await pending;
|
||||
return JSON.parse(request.postData() ?? "{}") as Record<string, any>;
|
||||
}
|
||||
|
||||
/** Reads an endpoint as the master key, so a failure is bad data and not an expired UI token. */
|
||||
export async function readBack<T = any>(page: Page, endpoint: string): Promise<T> {
|
||||
const res = await page.request.get(endpoint, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
expect(res.ok(), `GET ${endpoint}`).toBe(true);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
125
tests/e2e/ui/helpers/traffic.ts
Normal file
125
tests/e2e/ui/helpers/traffic.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import { APIRequestContext, expect } from "@playwright/test";
|
||||
|
||||
/** Model names served by fixtures/config.yml, both backed by the mock LLM server. */
|
||||
export const CHAT_MODEL_A = "fake-openai-gpt-4";
|
||||
export const CHAT_MODEL_B = "fake-anthropic-claude";
|
||||
|
||||
/** The only completion text fixtures/mock_llm_server/server.py ever returns. */
|
||||
export const MOCK_RESPONSE_TEXT = "This is a mock response.";
|
||||
|
||||
export const masterKey = (): string => process.env.LITELLM_MASTER_KEY || "sk-1234";
|
||||
|
||||
const rootPath = (): string => process.env.SERVER_ROOT_PATH ?? "";
|
||||
|
||||
interface ChatOptions {
|
||||
model: string;
|
||||
prompt: string;
|
||||
apiKey?: string;
|
||||
/** Sent as `user`, which lands in the spend log's end_user column. */
|
||||
endUser?: string;
|
||||
}
|
||||
|
||||
/** POST /v1/chat/completions and return the completion id (the Logs Request ID). */
|
||||
export async function sendChatCompletion(request: APIRequestContext, opts: ChatOptions): Promise<string> {
|
||||
const res = await request.post(`${rootPath()}/v1/chat/completions`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${opts.apiKey ?? masterKey()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: {
|
||||
model: opts.model,
|
||||
messages: [{ role: "user", content: opts.prompt }],
|
||||
...(opts.endUser ? { user: opts.endUser } : {}),
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `chat completion for ${opts.model} failed (${res.status()}): ${await res.text()}`).toBe(true);
|
||||
const body = await res.json();
|
||||
expect(body.choices?.[0]?.message?.content).toContain(MOCK_RESPONSE_TEXT);
|
||||
return body.id as string;
|
||||
}
|
||||
|
||||
/** `key` is the sk- value to authenticate with; `token` is its hash, which spend aggregates are keyed by. */
|
||||
export async function createVirtualKey(
|
||||
request: APIRequestContext,
|
||||
data: Record<string, unknown> = {},
|
||||
): Promise<{ key: string; token: string; alias?: string }> {
|
||||
const res = await request.post(`${rootPath()}/key/generate`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${masterKey()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data,
|
||||
});
|
||||
expect(res.ok(), `key generate failed (${res.status()}): ${await res.text()}`).toBe(true);
|
||||
const body = await res.json();
|
||||
return {
|
||||
key: body.key as string,
|
||||
token: (body.token ?? body.token_id) as string,
|
||||
alias: body.key_alias as string | undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/** Spend logs are flushed on a timer, so an assertion straight after a completion races the writer. */
|
||||
export async function waitForSpendLog(
|
||||
request: APIRequestContext,
|
||||
requestId: string,
|
||||
timeoutMs = 60_000,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastStatus = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await request.get(`${rootPath()}/spend/logs?request_id=${encodeURIComponent(requestId)}`, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
lastStatus = res.status();
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const rows = Array.isArray(body) ? body : (body?.data ?? []);
|
||||
if (rows.length > 0) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 2_000));
|
||||
}
|
||||
throw new Error(`spend log for request ${requestId} never appeared (last /spend/logs status ${lastStatus})`);
|
||||
}
|
||||
|
||||
const isoDay = (d: Date): string => d.toISOString().slice(0, 10);
|
||||
|
||||
/**
|
||||
* The Usage page reads /user/daily/activity, a rollup written by a background job, and fetches it once
|
||||
* on mount. Navigating before the rollup lands leaves a stale render that never refreshes.
|
||||
*/
|
||||
export async function waitForKeyInDailyActivity(
|
||||
request: APIRequestContext,
|
||||
keyToken: string,
|
||||
timeoutMs = 120_000,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
const start = new Date(now);
|
||||
start.setDate(start.getDate() - 7);
|
||||
const query = `start_date=${isoDay(start)}&end_date=${isoDay(now)}`;
|
||||
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastStatus = 0;
|
||||
while (Date.now() < deadline) {
|
||||
const res = await request.get(`${rootPath()}/user/daily/activity?${query}`, {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
});
|
||||
lastStatus = res.status();
|
||||
if (res.ok()) {
|
||||
const body = await res.json();
|
||||
const seen = (body?.results ?? []).some(
|
||||
(day: { breakdown?: { api_keys?: Record<string, unknown> } }) => keyToken in (day.breakdown?.api_keys ?? {}),
|
||||
);
|
||||
if (seen) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 3_000));
|
||||
}
|
||||
throw new Error(
|
||||
`key ${keyToken} never appeared in /user/daily/activity (last status ${lastStatus}); ` +
|
||||
"the daily spend rollup may not be running",
|
||||
);
|
||||
}
|
||||
|
|
@ -12,6 +12,10 @@ set -euo pipefail
|
|||
# ./run_e2e.sh --repeat-each=5 # Run each test 5 times
|
||||
# ./run_e2e.sh --headed # Run with browser visible
|
||||
#
|
||||
# Ports default to 4000 / 5432 / 8090 and can be moved when another checkout
|
||||
# already holds them:
|
||||
# PROXY_PORT=4100 POSTGRES_PORT=5532 MOCK_LLM_PORT=8190 ./run_e2e.sh
|
||||
#
|
||||
# In CI (CI=true), expects:
|
||||
# - PostgreSQL already running on 127.0.0.1:5432
|
||||
# - DATABASE_URL already set
|
||||
|
|
@ -28,12 +32,50 @@ MOCK_PID=""
|
|||
PROXY_PID=""
|
||||
PROXY_LOG=""
|
||||
|
||||
# Ports, overridable so two checkouts can run this harness at the same time --
|
||||
# otherwise a second run aborts on "port 4000 is in use" and the only way out is
|
||||
# to stop someone else's stack. Defaults are the historical values, so an unset
|
||||
# environment behaves exactly as before (CI, the CircleCI job and the docs all
|
||||
# assume 4000/5432/8090).
|
||||
PROXY_PORT="${PROXY_PORT:-4000}"
|
||||
POSTGRES_PORT="${POSTGRES_PORT:-5432}"
|
||||
MOCK_LLM_PORT="${MOCK_LLM_PORT:-8090}"
|
||||
export MOCK_LLM_PORT
|
||||
|
||||
# --- 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"
|
||||
# Sourcing nvm only makes `nvm` available -- it leaves you on whatever the
|
||||
# default alias points at, which is frequently an older Node than the
|
||||
# dashboard's engines allow. `npm install` then fails EBADENGINE, npm exits
|
||||
# non-zero, and because the install below is `--silent ... || true` the error
|
||||
# is swallowed and the run dies later with the far less obvious
|
||||
# "sh: next: command not found".
|
||||
#
|
||||
# So select a Node that satisfies ui/litellm-dashboard's engines.node, and if
|
||||
# none is available say so here rather than 200 lines downstream.
|
||||
if [ -s "$HOME/.nvm/nvm.sh" ]; then
|
||||
# shellcheck disable=SC1091
|
||||
source "$HOME/.nvm/nvm.sh"
|
||||
required_major="$(sed -nE 's/.*"node"[[:space:]]*:[[:space:]]*">=?([0-9]+).*/\1/p' \
|
||||
"$DASHBOARD_DIR/package.json" 2>/dev/null | head -1)"
|
||||
if [ -n "$required_major" ]; then
|
||||
current_major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/')"
|
||||
if [ -z "$current_major" ] || [ "$current_major" -lt "$required_major" ]; then
|
||||
echo "Node $(node --version 2>/dev/null || echo 'not found') is below the dashboard's required v${required_major}; selecting a newer one via nvm"
|
||||
nvm use "$required_major" >/dev/null 2>&1 || nvm use --lts >/dev/null 2>&1 || true
|
||||
current_major="$(node --version 2>/dev/null | sed -E 's/^v([0-9]+).*/\1/')"
|
||||
if [ -z "$current_major" ] || [ "$current_major" -lt "$required_major" ]; then
|
||||
echo "Error: ui/litellm-dashboard requires Node >= v${required_major}, and no such version is installed."
|
||||
echo " Install one with: nvm install ${required_major}"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
echo "Using Node $(node --version) / npm $(npm --version)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Cleanup on exit ---
|
||||
|
|
@ -47,7 +89,11 @@ cleanup() {
|
|||
fi
|
||||
echo "Done."
|
||||
}
|
||||
trap cleanup EXIT INT TERM
|
||||
on_signal() {
|
||||
exit 130
|
||||
}
|
||||
trap cleanup EXIT
|
||||
trap on_signal INT TERM
|
||||
|
||||
# --- Pre-flight checks ---
|
||||
for cmd in python3 npx uv; do
|
||||
|
|
@ -59,9 +105,14 @@ if [ "$IS_CI" = "false" ]; then
|
|||
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"
|
||||
# Only a LISTENER conflicts with us. Without -sTCP:LISTEN this also matches
|
||||
# ESTABLISHED sockets, so an unrelated *outbound* connection from this machine
|
||||
# to someone else's :5432 (a psql session, a running app, a Prisma engine
|
||||
# talking to a remote database) aborts the run with "port 5432 is in use"
|
||||
# while nothing is actually bound locally.
|
||||
for port in "$PROXY_PORT" "$POSTGRES_PORT" "$MOCK_LLM_PORT"; do
|
||||
if lsof -nP -iTCP:"$port" -sTCP:LISTEN >/dev/null 2>&1; then
|
||||
echo "Error: port $port is in use (override with PROXY_PORT / POSTGRES_PORT / MOCK_LLM_PORT)"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
|
@ -69,12 +120,12 @@ if [ "$IS_CI" = "false" ]; then
|
|||
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}"
|
||||
export DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@127.0.0.1:${POSTGRES_PORT}/${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 \
|
||||
-p "127.0.0.1:${POSTGRES_PORT}:5432" \
|
||||
postgres:16
|
||||
|
||||
echo "Waiting for PostgreSQL..."
|
||||
|
|
@ -91,8 +142,13 @@ fi
|
|||
|
||||
# --- Credentials ---
|
||||
export LITELLM_MASTER_KEY="sk-1234"
|
||||
export MOCK_LLM_URL="http://127.0.0.1:8090/v1"
|
||||
export MOCK_LLM_URL="http://127.0.0.1:${MOCK_LLM_PORT}/v1"
|
||||
export DISABLE_SCHEMA_UPDATE="true"
|
||||
# The suite resolves its target from E2E_UI_BASE_URL (constants.ts), which
|
||||
# otherwise defaults to :4000 -- so without this a relocated stack would be
|
||||
# built and booted correctly and then tested against whatever happens to be
|
||||
# listening on the default port.
|
||||
export E2E_UI_BASE_URL="${E2E_UI_BASE_URL:-http://127.0.0.1:${PROXY_PORT}}"
|
||||
# Ensure the proxy serves UI at /ui (not behind a subpath)
|
||||
export SERVER_ROOT_PATH=""
|
||||
# Boot with an external logout URL so proxyLogoutUrl.spec.ts can assert the
|
||||
|
|
@ -108,7 +164,11 @@ export LITELLM_LICENSE="${LITELLM_LICENSE:-}"
|
|||
# --- Rebuild UI from source ---
|
||||
echo "=== Building UI from source ==="
|
||||
cd "$DASHBOARD_DIR"
|
||||
npm install --silent 2>/dev/null || true
|
||||
# NOT silenced, and NOT `|| true`. Swallowing this is what turns a one-line
|
||||
# EBADENGINE ("dashboard requires node >=24, you have v20") into the
|
||||
# considerably less helpful "sh: next: command not found" from the build below,
|
||||
# because the deps that provide `next` were never installed.
|
||||
npm install
|
||||
npm run build
|
||||
# Copy the fresh build to the proxy's static UI directory
|
||||
cp -r "$DASHBOARD_DIR/out/" "$REPO_ROOT/litellm/proxy/_experimental/out/"
|
||||
|
|
@ -139,7 +199,7 @@ uv run --no-sync python "$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
|
||||
if curl -sf http://127.0.0.1:${MOCK_LLM_PORT}/health >/dev/null 2>&1; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
|
|
@ -149,7 +209,7 @@ cd "$REPO_ROOT"
|
|||
PROXY_LOG="${TMPDIR:-/tmp}/litellm-e2e-proxy-$$.log"
|
||||
uv run --no-sync python -m litellm.proxy.proxy_cli \
|
||||
--config "$SCRIPT_DIR/fixtures/config.yml" \
|
||||
--port 4000 >"$PROXY_LOG" 2>&1 &
|
||||
--port "$PROXY_PORT" >"$PROXY_LOG" 2>&1 &
|
||||
PROXY_PID=$!
|
||||
|
||||
echo "Waiting for proxy (logs: $PROXY_LOG)..."
|
||||
|
|
@ -160,7 +220,7 @@ for i in $(seq 1 180); do
|
|||
tail -n 100 "$PROXY_LOG"
|
||||
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)
|
||||
HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:${PROXY_PORT}/health -H "Authorization: Bearer $LITELLM_MASTER_KEY" 2>/dev/null || true)
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
PROXY_READY=1
|
||||
break
|
||||
|
|
@ -188,9 +248,38 @@ PGPASSWORD="$DB_PASS" psql -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAM
|
|||
# --- Playwright ---
|
||||
echo "=== Installing Playwright dependencies ==="
|
||||
cd "$SCRIPT_DIR"
|
||||
npm install --silent 2>/dev/null || true
|
||||
# Same reasoning as the dashboard install above: a failure here means the suite
|
||||
# has no @playwright/test, and the run should say that rather than fail later.
|
||||
npm install
|
||||
npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium
|
||||
|
||||
# Authoring a new spec means running it over and over against a stack that is
|
||||
# already up -- rebuilding the UI and re-seeding for every iteration costs
|
||||
# minutes each time. E2E_KEEP_ALIVE brings the stack up, then blocks, so you can
|
||||
# run `npx playwright test <spec>` yourself from another shell against it.
|
||||
# Ctrl-C here tears everything down through the usual trap.
|
||||
if [ "${E2E_KEEP_ALIVE:-0}" = "1" ]; then
|
||||
cat <<EOF
|
||||
|
||||
=== Stack is up (E2E_KEEP_ALIVE=1); not running tests ===
|
||||
UI / API : http://127.0.0.1:${PROXY_PORT}
|
||||
Mock LLM : http://127.0.0.1:${MOCK_LLM_PORT}/v1
|
||||
Database : $DATABASE_URL
|
||||
Proxy log: $PROXY_LOG
|
||||
|
||||
Run specs against it from $SCRIPT_DIR:
|
||||
npx playwright test --config playwright.config.ts <spec>
|
||||
|
||||
Press Ctrl-C to tear the stack down.
|
||||
EOF
|
||||
while kill -0 "$PROXY_PID" 2>/dev/null; do
|
||||
sleep 5
|
||||
done
|
||||
echo "Error: proxy process exited unexpectedly. Proxy output:"
|
||||
tail -n 100 "$PROXY_LOG"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "=== Running Playwright tests ==="
|
||||
npx playwright test --config playwright.config.ts "$@"
|
||||
EXIT_CODE=$?
|
||||
|
|
|
|||
221
tests/e2e/ui/tests/logs/logs.spec.ts
Normal file
221
tests/e2e/ui/tests/logs/logs.spec.ts
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import { test, expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { CHAT_MODEL_A, MOCK_RESPONSE_TEXT, sendChatCompletion, waitForSpendLog } from "../../helpers/traffic";
|
||||
|
||||
/**
|
||||
* Anchored to traffic this spec generates itself, with a unique prompt and end user per run, so it
|
||||
* neither depends on seeded spend rows nor collides with other specs under parallelism.
|
||||
*/
|
||||
|
||||
const uniqueSuffix = (): string => `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
/**
|
||||
* Walking up from the label is the only stable handle: the header carries no role, test id or class,
|
||||
* and its copy button is icon-only with a hover-only tooltip.
|
||||
*/
|
||||
const sectionHeader = (drawer: Locator, label: "Input" | "Output"): Locator =>
|
||||
drawer.getByText(label, { exact: true }).locator("xpath=../../..");
|
||||
|
||||
/** Every tab stays mounted, so the DOM holds four tables at once; scope to the visible one. */
|
||||
const requestLogsRows = (page: PlaywrightPage): Locator =>
|
||||
page.locator("table").filter({ visible: true }).first().locator("tbody tr");
|
||||
|
||||
const visibleTestId = (page: PlaywrightPage, id: string): Locator => page.getByTestId(id).filter({ visible: true });
|
||||
|
||||
/** Open the Logs page and filter the table down to a single request id. */
|
||||
async function openLogsForRequest(page: PlaywrightPage, requestId: string): Promise<Locator> {
|
||||
await navigateToPage(page, Page.Logs);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
const search = visibleTestId(page, "datatable-search");
|
||||
await expect(search).toBeVisible({ timeout: 20_000 });
|
||||
await search.fill(requestId);
|
||||
|
||||
const row = requestLogsRows(page).filter({ hasText: requestId });
|
||||
await expect(row, `no logs row for request ${requestId}`).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
return row;
|
||||
}
|
||||
|
||||
test.describe("Logs page", () => {
|
||||
test.use({
|
||||
storageState: ADMIN_STORAGE_PATH,
|
||||
// The copy buttons go through navigator.clipboard, which rejects without these.
|
||||
permissions: ["clipboard-read", "clipboard-write"],
|
||||
});
|
||||
|
||||
test("a served request expands to its request and response", async ({ page, request }) => {
|
||||
const prompt = `logs-detail-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
|
||||
// Expand: clicking the row opens the detail drawer for that request.
|
||||
await row.click();
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer).toBeVisible({ timeout: 20_000 });
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The prompt we sent and the mock server's reply are both rendered.
|
||||
await expect(drawer.getByText(prompt, { exact: false })).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(drawer.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 20_000 });
|
||||
});
|
||||
|
||||
// Split out because only the copy path needs a secure context; folding it in would
|
||||
// take the drawer-rendering coverage down with it.
|
||||
test("the drawer copies the request and the response to the clipboard", async ({ page, request }) => {
|
||||
// `navigator.clipboard` is undefined outside a secure context, and handleCopy calls
|
||||
// writeText unguarded, so on plain HTTP served from a hostname the click throws and no
|
||||
// toast renders. Skipped rather than weakened so the product gap stays visible.
|
||||
await page.goto("/ui");
|
||||
const isSecure = await page.evaluate(() => window.isSecureContext);
|
||||
test.skip(!isSecure, "origin is not a secure context, so navigator.clipboard is unavailable");
|
||||
|
||||
const prompt = `logs-copy-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Copy request: the Input card's copy button puts the prompt on the clipboard.
|
||||
await sectionHeader(drawer, "Input").getByRole("button").click();
|
||||
await expect(page.getByText("Input copied")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(prompt);
|
||||
|
||||
// Copy response: the Output card's copy button puts the completion on it.
|
||||
await sectionHeader(drawer, "Output").getByRole("button").click();
|
||||
await expect(page.getByText("Output copied")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(await page.evaluate(() => navigator.clipboard.readText())).toContain(MOCK_RESPONSE_TEXT);
|
||||
});
|
||||
|
||||
test("the Input card collapses and expands", async ({ page, request }) => {
|
||||
const prompt = `logs-collapse-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// The body collapses via `max-height: 0; overflow: hidden`, which zeroes its own bounding
|
||||
// box, so the wrapper reads as hidden while the clipped text node inside it does not.
|
||||
const header = sectionHeader(drawer, "Input");
|
||||
const body = header.locator("xpath=following-sibling::div[1]");
|
||||
await expect(header.locator(".anticon-up")).toBeVisible();
|
||||
await expect(body).toBeVisible();
|
||||
|
||||
await header.click();
|
||||
await expect(header.locator(".anticon-down")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(body).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await header.click();
|
||||
await expect(header.locator(".anticon-up")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
await expect(body).toBeVisible({ timeout: 10_000 });
|
||||
await expect(drawer.getByText(prompt, { exact: false })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("the JSON view exposes Request and Response tabs", async ({ page, request }) => {
|
||||
const prompt = `logs-json-prompt-${uniqueSuffix()}`;
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
|
||||
const row = await openLogsForRequest(page, requestId);
|
||||
await row.click();
|
||||
|
||||
const drawer = page.locator(".ant-drawer-content").first();
|
||||
await expect(drawer.getByText("Request & Response")).toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
|
||||
// antd Radio.Button hides the <input> under its <label>, which intercepts
|
||||
// the pointer event — click the label, not the radio.
|
||||
await drawer.locator("label.ant-radio-button-wrapper").filter({ hasText: "JSON" }).click();
|
||||
|
||||
const requestTab = drawer.getByRole("tab", { name: "Request" });
|
||||
await expect(requestTab).toBeVisible({ timeout: 10_000 });
|
||||
await requestTab.click();
|
||||
await expect(drawer.getByText(prompt, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await drawer.getByRole("tab", { name: "Response" }).click();
|
||||
await expect(drawer.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
test("the End User filter narrows the table to that customer", async ({ page, request }) => {
|
||||
const endUser = `logs-end-user-${uniqueSuffix()}`;
|
||||
const minePrompt = `logs-filter-mine-${uniqueSuffix()}`;
|
||||
const otherPrompt = `logs-filter-other-${uniqueSuffix()}`;
|
||||
|
||||
const mineId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: minePrompt,
|
||||
endUser,
|
||||
});
|
||||
const otherId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: otherPrompt,
|
||||
});
|
||||
await waitForSpendLog(request, mineId);
|
||||
await waitForSpendLog(request, otherId);
|
||||
|
||||
await navigateToPage(page, Page.Logs);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Both requests are in the unfiltered table.
|
||||
await expect(requestLogsRows(page).filter({ hasText: mineId })).toHaveCount(1, { timeout: 30_000 });
|
||||
await expect(requestLogsRows(page).filter({ hasText: otherId })).toHaveCount(1, { timeout: 30_000 });
|
||||
|
||||
await visibleTestId(page, "datatable-filters-trigger").click();
|
||||
const filters = page.getByRole("dialog").filter({ hasText: "Narrow down request logs" });
|
||||
await expect(filters).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const endUserInput = filters.getByPlaceholder("Search an end user");
|
||||
await endUserInput.click();
|
||||
await endUserInput.fill(endUser);
|
||||
// The combobox popup is portaled to the body, so it is outside the filter
|
||||
// dialog's subtree — scope the option lookup to the page, not the dialog.
|
||||
await page.getByRole("option", { name: endUser, exact: true }).click({ timeout: 30_000 });
|
||||
await filters.getByRole("button", { name: "Apply Filters" }).click();
|
||||
|
||||
// Only the request tagged with this end user survives the filter.
|
||||
await expect(requestLogsRows(page).filter({ hasText: otherId })).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(requestLogsRows(page).filter({ hasText: mineId })).toHaveCount(1);
|
||||
await expect(requestLogsRows(page)).toHaveCount(1);
|
||||
});
|
||||
});
|
||||
92
tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts
Normal file
92
tests/e2e/ui/tests/mcp/mcpServerEdit.spec.ts
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { createMcpServer, deleteMcpServerByName } from "../../helpers/mcp";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
|
||||
/**
|
||||
* Editing and deleting an MCP server, verified against the API. The reported failures are all on
|
||||
* this side: renames that need repeating, deletes that need two attempts, each toasting success on
|
||||
* the failing attempt. The URL is unreachable on purpose; only persistence is under test here.
|
||||
*/
|
||||
const UNREACHABLE_URL = "https://e2e-fake-mcp.test.local/mcp";
|
||||
|
||||
/** GET /v1/mcp/server returns a bare array of servers (useMCPServers types it MCPServer[]). */
|
||||
async function findServerByName(page: PlaywrightPage, serverName: string): Promise<Record<string, any> | undefined> {
|
||||
const servers = await readBack<Record<string, any>[]>(page, "/v1/mcp/server");
|
||||
return servers.find((server) => server.server_name === serverName);
|
||||
}
|
||||
|
||||
test.describe("MCP Servers - edit and delete", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
let serverName: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
serverName = await createMcpServer(page, UNREACHABLE_URL);
|
||||
});
|
||||
|
||||
// The rename test leaves an unreachable server behind, which slows the MCP page for later tests.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteMcpServerByName(page, serverName);
|
||||
});
|
||||
|
||||
test("Renaming a server's alias persists", async ({ page }) => {
|
||||
const before = await findServerByName(page, serverName);
|
||||
expect(before, `created server ${serverName} readable from /v1/mcp/server`).toBeTruthy();
|
||||
|
||||
await page.getByTestId("mcp-servers-grid").getByText(serverName).first().click();
|
||||
await expect(page.getByRole("button", { name: /Back to All Servers/i })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// exact: the server view also renders a "Network Settings" tab.
|
||||
await page.getByRole("tab", { name: "Settings", exact: true }).click();
|
||||
|
||||
// A card click may land straight in edit mode, so only click the button when it rendered.
|
||||
const editSettings = page.getByRole("button", { name: "Edit Settings" });
|
||||
if (await editSettings.isVisible().catch(() => false)) {
|
||||
await editSettings.click();
|
||||
}
|
||||
|
||||
// The create modal stays mounted behind the view with its own #alias and Save.
|
||||
const settingsPanel = page.getByRole("tabpanel", { name: "Settings" });
|
||||
|
||||
const newAlias = `${serverName}_renamed`;
|
||||
const aliasInput = settingsPanel.locator('input[id="alias"]');
|
||||
await expect(aliasInput).toBeVisible({ timeout: 10_000 });
|
||||
await aliasInput.fill(newAlias);
|
||||
|
||||
const update = await captureRequestBody(page, { method: "PUT", urlIncludes: "/v1/mcp/server" }, async () => {
|
||||
await settingsPanel.getByRole("button", { name: "Save Changes" }).click();
|
||||
});
|
||||
expect(update.alias, "new alias on the wire").toBe(newAlias);
|
||||
// An unidentified target is one way a save succeeds and changes nothing.
|
||||
expect(update.server_id, "update targets the server being edited").toBe(before?.server_id);
|
||||
|
||||
// The reported symptom is a first save that returns success and does not stick.
|
||||
await expect
|
||||
.poll(async () => (await findServerByName(page, serverName))?.alias, {
|
||||
message: `alias for ${serverName} did not persist after one save`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBe(newAlias);
|
||||
});
|
||||
|
||||
test("Deleting a server removes it", async ({ page }) => {
|
||||
expect(await findServerByName(page, serverName), `created server ${serverName} exists`).toBeTruthy();
|
||||
|
||||
const card = page.getByTestId("mcp-servers-grid").locator("div").filter({ hasText: serverName }).first();
|
||||
await card.getByRole("button", { name: "Server actions" }).click();
|
||||
await page.getByRole("menuitem", { name: "Delete" }).click();
|
||||
|
||||
const dialog = page.getByRole("alertdialog");
|
||||
await expect(dialog.getByText("Delete MCP Server?")).toBeVisible({ timeout: 5_000 });
|
||||
await dialog.getByRole("button", { name: "Delete", exact: true }).click();
|
||||
|
||||
// One attempt has to be enough; the report is a delete that needs two.
|
||||
await expect
|
||||
.poll(async () => await findServerByName(page, serverName), {
|
||||
message: `server ${serverName} still present after one delete`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test";
|
|||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { deleteMcpServerByName } from "../../helpers/mcp";
|
||||
|
||||
// Coverage scope: only the happy-path Streamable HTTP + None auth create flow.
|
||||
// See E2E_COVERAGE.md (#29 row) for the full list of uncovered MCP surfaces
|
||||
|
|
@ -11,6 +12,15 @@ import { Page } from "../../fixtures/pages";
|
|||
test.describe("MCP Servers", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
let createdServerName = "";
|
||||
|
||||
// The server this test creates is unreachable, and the MCP page contacts
|
||||
// every server it lists, so leaving it behind slows down every later MCP
|
||||
// test. See deleteMcpServerByName for what that actually cost.
|
||||
test.afterEach(async ({ page }) => {
|
||||
if (createdServerName) await deleteMcpServerByName(page, createdServerName);
|
||||
});
|
||||
|
||||
test("Add a custom MCP server via the discovery → custom form", async ({ page }) => {
|
||||
await navigateToPage(page, Page.McpServers);
|
||||
|
||||
|
|
@ -25,6 +35,7 @@ test.describe("MCP Servers", () => {
|
|||
|
||||
// Name — no spaces or hyphens per validateMCPServerName
|
||||
const uniqueName = `e2e_mcp_${Date.now()}`;
|
||||
createdServerName = uniqueName;
|
||||
await formModal.locator('input[id="server_name"]').fill(uniqueName);
|
||||
|
||||
// Transport: Streamable HTTP — the only value the proxy actually accepts is "http"
|
||||
|
|
@ -48,8 +59,6 @@ test.describe("MCP Servers", () => {
|
|||
// Submit
|
||||
await formModal.getByRole("button", { name: /^Add MCP Server$/ }).click();
|
||||
|
||||
// No teardown needed — the e2e runner spins up a fresh DB per invocation.
|
||||
|
||||
// Success toast and the new card in the server grid. Scope the lookup to
|
||||
// the MCP servers grid so the form modal's `server_name` input — which
|
||||
// still holds the timestamped value during its close animation — can't
|
||||
|
|
|
|||
80
tests/e2e/ui/tests/mcp/mcpTools.spec.ts
Normal file
80
tests/e2e/ui/tests/mcp/mcpTools.spec.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { test, expect, Locator } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { createMcpServer, deleteMcpServerByName, openMcpToolsTab } from "../../helpers/mcp";
|
||||
|
||||
// Listing and calling MCP tools, which needs a server that really answers; the create-only spec
|
||||
// points at an unreachable URL on purpose.
|
||||
//
|
||||
// This spec makes a read-only network call to DeepWiki's public MCP server, from the proxy rather
|
||||
// than the browser. It needs no credentials, so there is no secret to leak from a public repo.
|
||||
//
|
||||
// A DeepWiki outage turns this red for something that is not a litellm regression. That is left
|
||||
// visible rather than auto-skipped: skipping on connection trouble also skips when the proxy's own
|
||||
// MCP client breaks, which is the regression this exists to catch. E2E_SKIP_EXTERNAL_MCP=1 opts out.
|
||||
const MCP_SERVER_URL = "https://mcp.deepwiki.com/mcp";
|
||||
const TOOL_NAME = "read_wiki_structure";
|
||||
const TOOL_ARG_REPO = "BerriAI/litellm";
|
||||
|
||||
// Match the h4 heading, not page text: a tool whose description names another tool trips strict mode.
|
||||
const toolCard = (list: Locator, name: string): Locator =>
|
||||
list.locator("h4.font-mono").filter({ hasText: new RegExp(`^${name}$`) });
|
||||
|
||||
test.describe("MCP Tools", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
test.skip(!!process.env.E2E_SKIP_EXTERNAL_MCP, "E2E_SKIP_EXTERNAL_MCP is set");
|
||||
|
||||
let serverName: string;
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
serverName = await createMcpServer(page, MCP_SERVER_URL);
|
||||
await openMcpToolsTab(page, serverName);
|
||||
});
|
||||
|
||||
// The MCP page contacts every server it lists, so leaks slow later tests run by run.
|
||||
test.afterEach(async ({ page }) => {
|
||||
await deleteMcpServerByName(page, serverName);
|
||||
});
|
||||
|
||||
test("MCP Tools tab lists the tools the upstream server advertises", async ({ page }) => {
|
||||
// Fetched through the proxy on mount, so allow for a cold upstream connection.
|
||||
const toolList = page.locator(".mcp-tools-scrollable");
|
||||
await expect(toolList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// Non-empty would still pass if the proxy returned some other server's tools.
|
||||
await expect(toolCard(toolList, TOOL_NAME)).toBeVisible();
|
||||
await expect(toolCard(toolList, "ask_question")).toBeVisible();
|
||||
await expect(toolCard(toolList, "read_wiki_contents")).toBeVisible();
|
||||
|
||||
// No other tool's name or description contains this string, so exactly one card survives.
|
||||
await page.getByPlaceholder("Search tools...").fill(TOOL_NAME);
|
||||
await expect(toolList.locator("h4.font-mono")).toHaveCount(1);
|
||||
await expect(toolCard(toolList, TOOL_NAME)).toBeVisible();
|
||||
});
|
||||
|
||||
test("Calling a tool from the Test Tool panel returns the upstream result", async ({ page }) => {
|
||||
const toolList = page.locator(".mcp-tools-scrollable");
|
||||
await expect(toolList).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await toolCard(toolList, TOOL_NAME).click();
|
||||
|
||||
// Selecting a tool swaps the right-hand pane in for the empty state.
|
||||
await expect(page.getByText("Test Tool:", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("Ready to Call Tool")).toBeVisible();
|
||||
|
||||
// The form is generated from the tool's inputSchema, so `repoName` proves the schema
|
||||
// round-tripped through the proxy instead of the panel falling back to a generic field.
|
||||
const repoInput = page.locator('input[id="repoName"]');
|
||||
await expect(repoInput).toBeVisible();
|
||||
await repoInput.fill(TOOL_ARG_REPO);
|
||||
|
||||
await page.getByRole("button", { name: "Call Tool", exact: true }).click();
|
||||
|
||||
await expect(page.getByText("Tool executed successfully")).toBeVisible({ timeout: 60_000 });
|
||||
// read_wiki_structure answers with the repo's outline, so the pane must name the repo.
|
||||
await expect(page.getByText(TOOL_ARG_REPO).first()).toBeVisible();
|
||||
|
||||
// A second call is offered rather than the button resetting to its
|
||||
// first-run label.
|
||||
await expect(page.getByRole("button", { name: "Call Again", exact: true })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,8 +1,25 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH, E2E_TEAM_CRUD_ID } from "../../constants";
|
||||
import { Role, users } from "../../fixtures/users";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
import { sendChatCompletion } from "../../helpers/traffic";
|
||||
|
||||
/** The mock LLM as the proxy reaches it: same host locally, a sidecar in the deployed stack. */
|
||||
const MOCK_LLM_BASE = `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`;
|
||||
|
||||
/** GET /model/info?litellm_model_id= returns {data: [row]}, the deployment as stored. */
|
||||
async function readDeployment(page: PlaywrightPage, modelId: string): Promise<Record<string, any> | undefined> {
|
||||
const body = await readBack<{ data: Record<string, any>[] }>(page, `/model/info?litellm_model_id=${modelId}`);
|
||||
return body.data[0];
|
||||
}
|
||||
|
||||
/** GET /v2/model/info lists every deployment; created models are found by model_name. */
|
||||
async function findDeploymentByName(page: PlaywrightPage, modelName: string): Promise<Record<string, any> | undefined> {
|
||||
const body = await readBack<{ data: Record<string, any>[] }>(page, "/v2/model/info");
|
||||
return body.data.find((row) => row.model_name === modelName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to select a provider from the Add Model form dropdown.
|
||||
|
|
@ -18,6 +35,28 @@ async function selectProvider(page: any, providerName: string) {
|
|||
test.describe("Add Model", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
// Set by the UI-add test below. The deployed stack keeps its database, so a leak
|
||||
// pollutes every later Models table and readback.
|
||||
let uiAddedModelName = "";
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
if (!uiAddedModelName) return;
|
||||
const name = uiAddedModelName;
|
||||
uiAddedModelName = "";
|
||||
try {
|
||||
const stored = await findDeploymentByName(page, name);
|
||||
const id = stored?.model_info?.id;
|
||||
if (id) {
|
||||
await page.request.post("/model/delete", {
|
||||
headers: { Authorization: `Bearer ${users[Role.ProxyAdmin].password}` },
|
||||
data: { id },
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Teardown must never turn a passing test red or mask a real failure.
|
||||
}
|
||||
});
|
||||
|
||||
test("Able to see all models for a specific provider in the model dropdown", async ({ page }) => {
|
||||
await navigateToPage(page, Page.Models);
|
||||
await page.getByRole("tab", { name: "Add Model" }).click();
|
||||
|
|
@ -37,15 +76,14 @@ test.describe("Add Model", () => {
|
|||
const modelName = `e2e-team-model-${Date.now()}`;
|
||||
|
||||
// Create a team-scoped model via API so the test has something to edit.
|
||||
// The e2e runner spins up a fresh postgres container per invocation, so
|
||||
// there's no cleanup step — the DB is thrown away at the end of the run.
|
||||
const createResponse = await page.request.post("/model/new", {
|
||||
headers: { Authorization: `Bearer ${masterKey}` },
|
||||
data: {
|
||||
model_name: modelName,
|
||||
litellm_params: {
|
||||
model: "openai/fake-gpt-4",
|
||||
api_base: "http://127.0.0.1:8090/v1",
|
||||
// Never called, but the port moves when two checkouts run side by side.
|
||||
api_base: `http://127.0.0.1:${process.env.MOCK_LLM_PORT ?? "8090"}/v1`,
|
||||
api_key: "fake-key",
|
||||
tpm: 100,
|
||||
rpm: 200,
|
||||
|
|
@ -55,7 +93,10 @@ test.describe("Add Model", () => {
|
|||
},
|
||||
},
|
||||
});
|
||||
expect(createResponse.ok()).toBe(true);
|
||||
// A bare toBe(true) sends you looking at the UI for a setup call that never landed.
|
||||
expect(createResponse.ok(), `/model/new failed: ${createResponse.status()} ${await createResponse.text()}`).toBe(
|
||||
true,
|
||||
);
|
||||
const createdModelId = (await createResponse.json()).model_info?.id;
|
||||
expect(createdModelId, "model id from /model/new").toBeTruthy();
|
||||
|
||||
|
|
@ -76,11 +117,93 @@ test.describe("Add Model", () => {
|
|||
await page.getByPlaceholder("Enter TPM").fill("999");
|
||||
await page.getByPlaceholder("Enter RPM").fill("888");
|
||||
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
// handleModelUpdate PATCHes the whole litellm_params blob, so pin what goes on the wire.
|
||||
const patch = await captureRequestBody(
|
||||
page,
|
||||
{ method: "PATCH", urlIncludes: `/model/${createdModelId}/update` },
|
||||
async () => {
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
},
|
||||
);
|
||||
expect(Number(patch.litellm_params?.tpm), "new TPM on the wire").toBe(999);
|
||||
expect(Number(patch.litellm_params?.rpm), "new RPM on the wire").toBe(888);
|
||||
|
||||
// Verify the new values render back in view mode
|
||||
await expect(page.getByText("999", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByText("888", { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// View mode re-renders from the form's own state, so read the deployment back.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const stored = await readDeployment(page, createdModelId);
|
||||
return [Number(stored?.litellm_params?.tpm), Number(stored?.litellm_params?.rpm)];
|
||||
},
|
||||
{ message: "TPM/RPM did not persist on the deployment", timeout: 15_000 },
|
||||
)
|
||||
.toEqual([999, 888]);
|
||||
|
||||
// Pin the fields this edit had no business changing; dropping them looks identical in the UI.
|
||||
const after = await readDeployment(page, createdModelId);
|
||||
expect(after?.litellm_params?.model, "upstream model untouched by a limits edit").toBe("openai/fake-gpt-4");
|
||||
expect(after?.model_info?.team_id, "team ownership untouched by a limits edit").toBe(E2E_TEAM_CRUD_ID);
|
||||
});
|
||||
|
||||
test("Add a model through the UI, pass Test Connect, and serve traffic with it", async ({ page, request }) => {
|
||||
// Every other test here stops at "the row appears", which an unroutable model also does.
|
||||
// OpenAI-Compatible exposes API Base, so this points at the mock LLM and needs no credential.
|
||||
await navigateToPage(page, Page.Models);
|
||||
await page.getByRole("tab", { name: "Add Model" }).click();
|
||||
|
||||
// Labels come from /public/providers/fields, not the frontend Providers enum, and the two differ.
|
||||
await selectProvider(page, "OpenAI-Compatible Endpoints");
|
||||
|
||||
const publicName = `e2e-ui-added-${Date.now()}`;
|
||||
uiAddedModelName = publicName;
|
||||
|
||||
// The model picker's "custom" entry reveals the free-text name field.
|
||||
await page.locator(".ant-select-selection-overflow").first().click();
|
||||
await page.locator(".ant-select-dropdown:visible").getByText("Custom Model Name (Enter below)").click();
|
||||
await page.keyboard.press("Escape");
|
||||
await page.getByPlaceholder("Enter custom model name").fill(publicName);
|
||||
|
||||
// By Form.Item id, not placeholder: placeholders change with the provider selection.
|
||||
await page.locator("#api_base").fill(MOCK_LLM_BASE);
|
||||
await page.locator("#api_key").fill("fake-key");
|
||||
|
||||
await page.getByRole("button", { name: "Test Connect" }).click();
|
||||
await expect(page.getByText("Connection Test Results")).toBeVisible({ timeout: 10_000 });
|
||||
// Assert the success panel is present; "no failure yet" is also true mid-flight.
|
||||
await expect(page.getByTestId("connection-success-msg")).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
// The modal swallows the Add click. Scope to the footer: the dismiss X is also named "Close".
|
||||
const resultsModal = page.locator(".ant-modal:visible").filter({ hasText: "Connection Test Results" });
|
||||
await resultsModal.locator(".ant-modal-footer").getByRole("button", { name: "Close" }).click();
|
||||
await expect(resultsModal).toBeHidden({ timeout: 5_000 });
|
||||
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
});
|
||||
expect(created.model_name, "the model is created under the name that was typed").toBe(publicName);
|
||||
expect(created.litellm_params?.api_base, "the api base survives the form").toBe(MOCK_LLM_BASE);
|
||||
|
||||
await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// Serving one request is the only assertion that rules out a dropped api_base or an
|
||||
// unregistered name. Polled because /model/new returns before the router reloads.
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
try {
|
||||
await sendChatCompletion(request, { model: publicName, prompt: `hello from ${publicName}` });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: `model ${publicName} was added through the UI but never served a request`, timeout: 30_000 },
|
||||
)
|
||||
.toBe(true);
|
||||
});
|
||||
|
||||
test("Test connection with bad credentials shows failure", async ({ page }) => {
|
||||
|
|
@ -126,7 +249,13 @@ test.describe("Add Model", () => {
|
|||
await apiKeyInput.fill("sk-any-key-for-add-test");
|
||||
|
||||
// Click Add Model button by its text
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
});
|
||||
// The form sends custom_llm_provider separately from the name, so both halves have to arrive.
|
||||
expect(created.model_name, "the selected model is what goes on the wire").toBe("claude-haiku-4-5");
|
||||
expect(created.litellm_params?.model, "the model name goes on the wire").toBe("claude-haiku-4-5");
|
||||
expect(created.litellm_params?.custom_llm_provider, "the picked provider goes on the wire").toBe("anthropic");
|
||||
|
||||
// Wait for success notification
|
||||
await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 });
|
||||
|
|
@ -148,19 +277,20 @@ test.describe("Add Model", () => {
|
|||
// Verify the model name appears in the table body
|
||||
const tableBody = page.locator("table tbody");
|
||||
await expect(tableBody.getByText("claude-haiku-4-5").first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// A row proves the name is there, not what the deployment routes to.
|
||||
const stored = await findDeploymentByName(page, "claude-haiku-4-5");
|
||||
expect(stored, "created model readable from /v2/model/info").toBeTruthy();
|
||||
expect(stored?.litellm_params?.model, "stored deployment keeps the model name").toBe("claude-haiku-4-5");
|
||||
expect(stored?.litellm_params?.custom_llm_provider, "stored deployment keeps its provider").toBe("anthropic");
|
||||
});
|
||||
|
||||
test("Add team-only model via Team-BYOK toggle and verify it appears with the team", async ({ page, request }) => {
|
||||
// The Team-BYOK switch is gated on `premiumUser` — without a license set
|
||||
// for the proxy under test, the toggle is disabled and this manual-QA
|
||||
// step cannot be exercised.
|
||||
// The Team-BYOK switch is gated on premiumUser; without a license the toggle is disabled.
|
||||
test.skip(!process.env.LITELLM_LICENSE, "LITELLM_LICENSE not set in test env — Team-BYOK switch is disabled");
|
||||
|
||||
// Make the test idempotent across retries and local reruns: delete any
|
||||
// Cohere model already scoped to the e2e team before we start, and again
|
||||
// after we finish. The sibling "Add wildcard route" test creates a
|
||||
// team-less Cohere wildcard, so we only target rows that have BOTH the
|
||||
// cohere/* model_name AND team_id == e2e-team-crud.
|
||||
// Idempotent across reruns. Only target rows with both the cohere name and the e2e team,
|
||||
// so the sibling wildcard test's team-less model is left alone.
|
||||
const masterKey = users[Role.ProxyAdmin].password;
|
||||
const auth = { Authorization: `Bearer ${masterKey}` };
|
||||
const deleteTeamScopedCohereModels = async () => {
|
||||
|
|
@ -198,11 +328,7 @@ test.describe("Add Model", () => {
|
|||
const teamByokRow = page.locator(".ant-form-item", { hasText: "Team-BYOK Model" });
|
||||
await teamByokRow.getByRole("switch").click();
|
||||
|
||||
// The Team dropdown appears underneath once the switch is on. TeamDropdown
|
||||
// renders its Select.Option children with custom <span>/<Text> markup, so
|
||||
// the popup items don't carry role="option" — match by text content,
|
||||
// scoped to the visible dropdown so a stale tag elsewhere in the form
|
||||
// can't satisfy it.
|
||||
// TeamDropdown's options carry custom markup and no role="option", so match by text.
|
||||
const teamDropdown = page.getByTestId("team-dropdown");
|
||||
await expect(teamDropdown).toBeVisible({ timeout: 5_000 });
|
||||
await teamDropdown.click();
|
||||
|
|
@ -212,36 +338,27 @@ test.describe("Add Model", () => {
|
|||
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
|
||||
// Scope the success toast to antd's notification container so a stale
|
||||
// success message from an earlier test in the same context can't satisfy
|
||||
// the assertion.
|
||||
// Scope to antd's notification container so a stale toast can't satisfy this.
|
||||
await expect(page.locator(".ant-notification").getByText("created successfully").last()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Verify the model is now in All Models with the team_id attached. The
|
||||
// Models table renders team-scoped models with the team id in the row.
|
||||
// The Models table renders team-scoped models with the team id in the row.
|
||||
await page.getByRole("tab", { name: "All Models" }).click();
|
||||
await page.waitForLoadState("networkidle");
|
||||
// Match the sibling tests in this file — networkidle fires before the
|
||||
// table finishes re-rendering, so give it the same 2s settle before
|
||||
// searching.
|
||||
// networkidle fires before the table finishes re-rendering.
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
await page.getByPlaceholder("Search model names").fill("cohere");
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Confirm the search returned at least one result — gives a clear
|
||||
// failure message when the table is empty instead of timing out on a
|
||||
// row assertion.
|
||||
// Clearer failure than timing out on a row assertion when the table is empty.
|
||||
await expect(page.getByTestId("pagination-range")).toHaveText(/Showing \d+-\d+ of \d+/, {
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
// Stronger than "the team appears somewhere in tbody" — pin the assertion
|
||||
// to a single row that has BOTH the cohere model_name AND the seeded
|
||||
// team, so a stale cohere row from "Add wildcard route" (no team) can't
|
||||
// satisfy the check. The Team ID column renders the id, not the alias.
|
||||
// Pin to one row carrying both the name and the team, so the sibling test's
|
||||
// team-less cohere row can't satisfy it.
|
||||
const teamCohereRow = page
|
||||
.locator("table tbody tr")
|
||||
.filter({ hasText: "cohere/" })
|
||||
|
|
@ -270,7 +387,11 @@ test.describe("Add Model", () => {
|
|||
await apiKeyInput.fill("sk-any-key-for-wildcard-test");
|
||||
|
||||
// Click Add Model button by its text
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
const created = await captureRequestBody(page, { method: "POST", urlIncludes: "/model/new" }, async () => {
|
||||
await page.getByRole("button", { name: "Add Model" }).last().click();
|
||||
});
|
||||
// A wildcard with the star stripped becomes a plain "cohere" deployment that matches nothing.
|
||||
expect(created.model_name, "the wildcard route goes on the wire intact").toBe("cohere/*");
|
||||
|
||||
// Wait for success notification
|
||||
await expect(page.getByText("created successfully")).toBeVisible({ timeout: 15_000 });
|
||||
|
|
@ -292,5 +413,10 @@ test.describe("Add Model", () => {
|
|||
// Verify the wildcard model appears in the table body (wildcard models show as "cohere/*")
|
||||
const tableBody = page.locator("table tbody");
|
||||
await expect(tableBody.getByText("cohere/").first()).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
// "cohere/" in the table also matches a plain cohere deployment; require the wildcard exactly.
|
||||
const stored = await findDeploymentByName(page, "cohere/*");
|
||||
expect(stored, "wildcard deployment readable from /v2/model/info").toBeTruthy();
|
||||
expect(stored?.litellm_params?.model, "stored deployment keeps the wildcard route").toBe("cohere/*");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
50
tests/e2e/ui/tests/playground/playground.spec.ts
Normal file
50
tests/e2e/ui/tests/playground/playground.spec.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { CHAT_MODEL_A, CHAT_MODEL_B, MOCK_RESPONSE_TEXT, createVirtualKey } from "../../helpers/traffic";
|
||||
import { keySourceSelect, onlyVisible, openPlayground, selectModel, sendMessage } from "../../helpers/playground";
|
||||
|
||||
/**
|
||||
* The one flow that exercises the dashboard's own LLM call path rather than an admin CRUD endpoint,
|
||||
* so it covers the UI's auth header, endpoint selection and streaming render.
|
||||
*/
|
||||
test.describe("Playground", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
for (const model of [CHAT_MODEL_A, CHAT_MODEL_B]) {
|
||||
test(`chats with ${model} using the current UI session`, async ({ page }) => {
|
||||
await openPlayground(page);
|
||||
|
||||
// "Current UI Session" is the default: the logged-in admin's key, nothing pasted.
|
||||
await expect(onlyVisible(page.getByTitle("Current UI Session"))).toBeVisible();
|
||||
|
||||
await selectModel(page, model);
|
||||
const prompt = `playground ping for ${model}`;
|
||||
await sendMessage(page, prompt);
|
||||
|
||||
// Our prompt is echoed into the transcript, and the mock server replies.
|
||||
await expect(page.getByText(prompt, { exact: false }).first()).toBeVisible({ timeout: 20_000 });
|
||||
await expect(page.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
}
|
||||
|
||||
test("chats using a pasted virtual key instead of the UI session", async ({ page, request }) => {
|
||||
const { key } = await createVirtualKey(request, {
|
||||
key_alias: `e2e-playground-${Date.now()}`,
|
||||
});
|
||||
|
||||
await openPlayground(page);
|
||||
|
||||
// Switch the source to "Virtual Key" and paste the key we just minted.
|
||||
await keySourceSelect(page, "Current UI Session").click();
|
||||
await onlyVisible(page.locator('.ant-select-item-option[title="Virtual Key"]')).click({ timeout: 15_000 });
|
||||
|
||||
const keyInput = onlyVisible(page.getByPlaceholder("Enter custom Virtual Key"));
|
||||
await expect(keyInput).toBeVisible({ timeout: 10_000 });
|
||||
await keyInput.fill(key);
|
||||
|
||||
await selectModel(page, CHAT_MODEL_A);
|
||||
await sendMessage(page, "playground ping via virtual key");
|
||||
|
||||
await expect(page.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import {
|
||||
ADMIN_STORAGE_PATH,
|
||||
E2E_DELETE_KEY_ALIAS,
|
||||
|
|
@ -9,6 +9,19 @@ import {
|
|||
} from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
|
||||
/**
|
||||
* Looks a key up by alias, undefined when none carries it. `return_full_object=true` is what makes
|
||||
* the row carry token / models / tpm_limit; without it the response is aliases only.
|
||||
*/
|
||||
async function findKeyByAlias(page: PlaywrightPage, alias: string): Promise<Record<string, any> | undefined> {
|
||||
const body = await readBack<{ keys: Record<string, any>[] }>(
|
||||
page,
|
||||
`/key/list?key_alias=${encodeURIComponent(alias)}&return_full_object=true&size=100`,
|
||||
);
|
||||
return body.keys.find((row) => row.key_alias === alias);
|
||||
}
|
||||
|
||||
test.describe("Proxy Admin - Keys", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
|
@ -47,12 +60,21 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
// Verify the new key appears in the table
|
||||
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The row above renders from the create response the UI already holds, so it proves nothing.
|
||||
const persisted = await findKeyByAlias(page, keyName);
|
||||
expect(persisted, `key ${keyName} readable from /key/list`).toBeTruthy();
|
||||
expect(typeof persisted?.team_id, "created key is owned by a team, not orphaned").toBe("string");
|
||||
});
|
||||
|
||||
test("Regenerate key", async ({ page }) => {
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Capture the old token first: a modal with a Copy button only proves the UI rendered.
|
||||
const before = await findKeyByAlias(page, E2E_REGENERATE_KEY_ALIAS);
|
||||
expect(before?.token, `seeded key ${E2E_REGENERATE_KEY_ALIAS} has a token`).toBeTruthy();
|
||||
|
||||
// Key IDs are rendered as buttons in the table
|
||||
const keyRow = page.locator("tr", { hasText: E2E_REGENERATE_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
|
|
@ -70,12 +92,24 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
// Success view shows a Copy button in the footer (text varies between modal versions)
|
||||
await expect(modal.getByRole("button", { name: /Copy.*Key/ })).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// The token must be replaced and the alias kept; orphaning it looks identical from the modal.
|
||||
await expect
|
||||
.poll(async () => (await findKeyByAlias(page, E2E_REGENERATE_KEY_ALIAS))?.token, {
|
||||
message: `token for ${E2E_REGENERATE_KEY_ALIAS} did not change after regenerate`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.not.toBe(before?.token);
|
||||
});
|
||||
|
||||
test("Update key TPM and RPM limits", async ({ page }) => {
|
||||
await navigateToPage(page, Page.ApiKeys);
|
||||
await dismissFeedbackPopup(page);
|
||||
|
||||
// Snapshot first, so the end assertions can tell an isolated edit from a collateral one.
|
||||
const before = await findKeyByAlias(page, E2E_UPDATE_LIMITS_KEY_ALIAS);
|
||||
expect(before, `seeded key ${E2E_UPDATE_LIMITS_KEY_ALIAS} exists`).toBeTruthy();
|
||||
|
||||
const keyRow = page.locator("tr", { hasText: E2E_UPDATE_LIMITS_KEY_ALIAS });
|
||||
await expect(keyRow).toBeVisible({ timeout: 10_000 });
|
||||
await keyRow.locator("button").first().click();
|
||||
|
|
@ -87,10 +121,27 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
|
||||
await page.getByRole("spinbutton", { name: "TPM Limit" }).fill("123");
|
||||
await page.getByRole("spinbutton", { name: "RPM Limit" }).fill("456");
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
|
||||
const update = await captureRequestBody(page, { method: "POST", urlIncludes: "/key/update" }, async () => {
|
||||
await page.getByRole("button", { name: "Save Changes" }).click();
|
||||
});
|
||||
|
||||
// The form posts limits at the top level. Compare numerically: the spinbutton yields either type.
|
||||
expect(Number(update.tpm_limit), "TPM limit on the wire").toBe(123);
|
||||
expect(Number(update.rpm_limit), "RPM limit on the wire").toBe(456);
|
||||
|
||||
await expect(page.getByRole("paragraph").filter({ hasText: "TPM: 123" })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(page.getByRole("paragraph").filter({ hasText: "RPM: 456" })).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Read the key back; the rendering above comes from a response the UI already holds.
|
||||
const after = await findKeyByAlias(page, E2E_UPDATE_LIMITS_KEY_ALIAS);
|
||||
expect(after, "key still readable after update").toBeTruthy();
|
||||
expect(Number(after?.tpm_limit), "TPM limit persisted").toBe(123);
|
||||
expect(Number(after?.rpm_limit), "RPM limit persisted").toBe(456);
|
||||
|
||||
// Not hypothetical: bumping a key's budget wiped its MCP toolset (PR #34452), toast said success.
|
||||
expect(after?.models, "editing limits left the key's models untouched").toEqual(before?.models);
|
||||
expect(after?.team_id, "editing limits left the key's team untouched").toEqual(before?.team_id);
|
||||
});
|
||||
|
||||
test("Delete key", async ({ page }) => {
|
||||
|
|
@ -115,6 +166,14 @@ test.describe("Proxy Admin - Keys", () => {
|
|||
await deleteButton.click();
|
||||
|
||||
await expect(page.getByText(/Key deleted/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The key is gone when the management API stops returning it, not when the toast says so.
|
||||
await expect
|
||||
.poll(async () => await findKeyByAlias(page, E2E_DELETE_KEY_ALIAS), {
|
||||
message: `key ${E2E_DELETE_KEY_ALIAS} still readable from /key/list after delete`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
test("See internal user keys in team", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import {
|
||||
ADMIN_STORAGE_PATH,
|
||||
E2E_TEAM_CRUD_ID,
|
||||
|
|
@ -8,6 +8,22 @@ import {
|
|||
} from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation";
|
||||
import { readBack } from "../../helpers/roundTrip";
|
||||
|
||||
/** GET /team/list returns a bare array of teams, each carrying team_alias/team_id. */
|
||||
async function findTeamByAlias(page: PlaywrightPage, alias: string): Promise<Record<string, any> | undefined> {
|
||||
const teams = await readBack<Record<string, any>[]>(page, "/team/list");
|
||||
return teams.find((team) => team.team_alias === alias);
|
||||
}
|
||||
|
||||
/** GET /team/info nests the record under `team_info`; membership lives in members_with_roles. */
|
||||
async function teamMemberEmails(page: PlaywrightPage, teamId: string): Promise<string[]> {
|
||||
const info = await readBack<{ team_info: { members_with_roles?: { user_email?: string }[] } }>(
|
||||
page,
|
||||
`/team/info?team_id=${encodeURIComponent(teamId)}`,
|
||||
);
|
||||
return (info.team_info.members_with_roles ?? []).map((member) => member.user_email ?? "").filter(Boolean);
|
||||
}
|
||||
|
||||
test.describe("Proxy Admin - Teams", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
|
@ -42,6 +58,11 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
|
||||
// Verify success notification
|
||||
await expect(page.getByText("Team created").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// A create that drops its model selection still toasts success.
|
||||
const created = await findTeamByAlias(page, uniqueAlias);
|
||||
expect(created, `team ${uniqueAlias} readable from /team/list`).toBeTruthy();
|
||||
expect(created?.models, "created team kept its model selection").toBeTruthy();
|
||||
});
|
||||
|
||||
test("Invite a user to a team", async ({ page }) => {
|
||||
|
|
@ -71,6 +92,14 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
await modal.getByRole("button", { name: /Add Member/i }).click();
|
||||
|
||||
await expect(page.getByText(/member.*added|success/i).first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The toast is matched loosely enough (/success/i) that almost any notification satisfies it.
|
||||
await expect
|
||||
.poll(async () => await teamMemberEmails(page, E2E_TEAM_CRUD_ID), {
|
||||
message: "invited user never appeared in the team's members",
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toContain("invitable@test.local");
|
||||
});
|
||||
|
||||
test("Edit team member for team proxy admin does not belong to", async ({ page }) => {
|
||||
|
|
@ -106,6 +135,14 @@ test.describe("Proxy Admin - Teams", () => {
|
|||
await modal.getByRole("button", { name: /Force Delete|Delete/i }).click();
|
||||
|
||||
await expect(teamRow).not.toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// A row vanishing is local state, which happens whether or not the delete landed.
|
||||
await expect
|
||||
.poll(async () => await findTeamByAlias(page, E2E_TEAM_DELETE_ALIAS), {
|
||||
message: `team ${E2E_TEAM_DELETE_ALIAS} still readable from /team/list after delete`,
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toBeUndefined();
|
||||
});
|
||||
|
||||
test("Team in org - edit team member", async ({ page }) => {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { ADMIN_STORAGE_PATH } from "../../constants";
|
|||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { Role, users } from "../../fixtures/users";
|
||||
import { MOCK_RESPONSE_TEXT } from "../../helpers/traffic";
|
||||
import { openPlayground, selectModel, sendMessage } from "../../helpers/playground";
|
||||
// Type-only import of the OpenAPI-generated backend schema, erased at runtime by
|
||||
// esbuild. It types the round-trips below so mistakes surface in the editor; the live
|
||||
// test against the real proxy is what actually enforces the contract.
|
||||
|
|
@ -79,7 +81,9 @@ test.describe("Router Settings - Fallbacks", () => {
|
|||
await primarySelect.click();
|
||||
await page.keyboard.type(PRIMARY);
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(modal.getByRole("tab", { name: PRIMARY })).toBeVisible({ timeout: 10_000 });
|
||||
await expect(modal.getByRole("tab", { name: PRIMARY })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
const fallbackSelect = modal.locator(".ant-select").filter({ hasText: "Select fallback models" });
|
||||
await fallbackSelect.click();
|
||||
|
|
@ -88,7 +92,9 @@ test.describe("Router Settings - Fallbacks", () => {
|
|||
await page.keyboard.press("Escape");
|
||||
// The Fallback Chain helper text reads "(N/10 used)"; once it ticks to 1 the
|
||||
// selection has been recorded.
|
||||
await expect(modal.getByText("(1/10 used)")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(modal.getByText("(1/10 used)")).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
// Save
|
||||
await modal.getByRole("button", { name: /Save All Configurations/i }).click();
|
||||
|
|
@ -111,7 +117,9 @@ test.describe("Router Settings - Fallbacks", () => {
|
|||
type ConfigYAML = components["schemas"]["ConfigYAML"];
|
||||
type RouterSettingsResponse = components["schemas"]["RouterSettingsResponse"];
|
||||
|
||||
const ADMIN_AUTH = { Authorization: `Bearer ${users[Role.ProxyAdmin].password}` };
|
||||
const ADMIN_AUTH = {
|
||||
Authorization: `Bearer ${users[Role.ProxyAdmin].password}`,
|
||||
};
|
||||
|
||||
/**
|
||||
* Apply a router_settings patch through the typed /config/update contract. The
|
||||
|
|
@ -172,13 +180,17 @@ test.describe("Router Settings - Loadbalancing", () => {
|
|||
// 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 });
|
||||
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(`/router/settings`, { headers: ADMIN_AUTH });
|
||||
const res = await request.get(`/router/settings`, {
|
||||
headers: ADMIN_AUTH,
|
||||
});
|
||||
const data = (await res.json()) as RouterSettingsResponse;
|
||||
return data.current_values?.num_retries;
|
||||
},
|
||||
|
|
@ -187,3 +199,91 @@ test.describe("Router Settings - Loadbalancing", () => {
|
|||
.toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The test above proves the UI can record a fallback; this proves the fallback is honoured. The
|
||||
* primary is created here because every fixture model is mock-backed and cannot fail on demand.
|
||||
*/
|
||||
test.describe("Router Settings - Fallbacks serve the request", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
const BROKEN_PRIMARY = "e2e-broken-primary";
|
||||
let brokenModelId: string | null = null;
|
||||
|
||||
/** Drop only this test's fallback entry, leaving any others untouched. */
|
||||
async function clearBrokenFallback(request: import("@playwright/test").APIRequestContext) {
|
||||
const current = await request.get("/get/config/callbacks", {
|
||||
headers: ADMIN_AUTH,
|
||||
});
|
||||
if (!current.ok()) return;
|
||||
const router = (await current.json())?.router_settings ?? {};
|
||||
const existing: Array<Record<string, string[]>> = Array.isArray(router.fallbacks) ? router.fallbacks : [];
|
||||
await patchRouterSettings(request, {
|
||||
fallbacks: existing.filter((entry) => !(entry && BROKEN_PRIMARY in entry)),
|
||||
} as Partial<NonNullable<ConfigYAML["router_settings"]>>);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ request }) => {
|
||||
await clearBrokenFallback(request);
|
||||
|
||||
// Port 9 is the discard service: nothing listens, so the connection is
|
||||
// refused immediately rather than hanging until a timeout.
|
||||
const res = await request.post("/model/new", {
|
||||
headers: ADMIN_AUTH,
|
||||
data: {
|
||||
model_name: BROKEN_PRIMARY,
|
||||
litellm_params: {
|
||||
model: "openai/broken",
|
||||
api_base: "http://127.0.0.1:9/v1",
|
||||
api_key: "fake",
|
||||
timeout: 5,
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.ok(), `creating the broken primary failed: ${res.status()} ${await res.text()}`).toBeTruthy();
|
||||
brokenModelId = (await res.json())?.model_id ?? null;
|
||||
});
|
||||
|
||||
test.afterEach(async ({ request }) => {
|
||||
await clearBrokenFallback(request);
|
||||
if (brokenModelId) {
|
||||
await request.post("/model/delete", {
|
||||
headers: ADMIN_AUTH,
|
||||
data: { id: brokenModelId },
|
||||
});
|
||||
brokenModelId = null;
|
||||
}
|
||||
});
|
||||
|
||||
test("a request to an unreachable model is answered by its fallback", async ({ page, request }) => {
|
||||
const chat = async () =>
|
||||
request.post("/v1/chat/completions", {
|
||||
headers: { ...ADMIN_AUTH, "Content-Type": "application/json" },
|
||||
data: {
|
||||
model: BROKEN_PRIMARY,
|
||||
messages: [{ role: "user", content: "fallback probe" }],
|
||||
},
|
||||
});
|
||||
|
||||
// The control: it proves the reply below could only have come from the fallback.
|
||||
expect((await chat()).status(), "broken primary unexpectedly succeeded on its own").toBeGreaterThanOrEqual(400);
|
||||
|
||||
await patchRouterSettings(request, {
|
||||
fallbacks: [{ [BROKEN_PRIMARY]: [PRIMARY] }],
|
||||
} as Partial<NonNullable<ConfigYAML["router_settings"]>>);
|
||||
|
||||
// Same call now succeeds, served by the fallback model.
|
||||
await expect
|
||||
.poll(async () => (await chat()).status(), {
|
||||
timeout: 30_000,
|
||||
message: "fallback never took effect",
|
||||
})
|
||||
.toBe(200);
|
||||
|
||||
// And the playground renders a reply for a model whose own upstream is down.
|
||||
await openPlayground(page);
|
||||
await selectModel(page, BROKEN_PRIMARY);
|
||||
await sendMessage(page, "fallback probe from the playground");
|
||||
await expect(page.getByText(MOCK_RESPONSE_TEXT, { exact: false }).first()).toBeVisible({ timeout: 60_000 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
import { test, expect, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import {
|
||||
E2E_INTERNAL_USER_KEY_ALIAS,
|
||||
E2E_TEAM_CRUD_ALIAS,
|
||||
|
|
@ -6,13 +6,30 @@ import {
|
|||
TEAM_ADMIN_STORAGE_PATH,
|
||||
} from "../../constants";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { navigateToPage, dismissFeedbackPopup, clickTeamId } from "../../helpers/navigation";
|
||||
import { captureRequestBody, readBack } from "../../helpers/roundTrip";
|
||||
|
||||
async function clickTeamId(page: import("@playwright/test").Page, teamId: string) {
|
||||
const cell = page.locator("td").filter({ hasText: teamId }).first();
|
||||
await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
await cell.click();
|
||||
await expect(page.getByText("Back to Teams")).toBeVisible({ timeout: 10_000 });
|
||||
/**
|
||||
* Every identifier a roster is addressable by. Which of user_id / user_email is populated depends on
|
||||
* how the member got there, so flatten both and let assertions name whichever the test typed.
|
||||
*/
|
||||
async function teamMemberIdentities(page: PlaywrightPage, teamId: string): Promise<string[]> {
|
||||
const info = await readBack<{ team_info: { members_with_roles?: { user_id?: string; user_email?: string }[] } }>(
|
||||
page,
|
||||
`/team/info?team_id=${encodeURIComponent(teamId)}`,
|
||||
);
|
||||
return (info.team_info.members_with_roles ?? []).flatMap((member) =>
|
||||
[member.user_id, member.user_email].filter((value): value is string => Boolean(value)),
|
||||
);
|
||||
}
|
||||
|
||||
/** See keys.spec.ts -- return_full_object is what makes the row carry team_id. */
|
||||
async function findKeyByAlias(page: PlaywrightPage, alias: string): Promise<Record<string, any> | undefined> {
|
||||
const body = await readBack<{ keys: Record<string, any>[] }>(
|
||||
page,
|
||||
`/key/list?key_alias=${encodeURIComponent(alias)}&return_full_object=true&size=100`,
|
||||
);
|
||||
return body.keys.find((row) => row.key_alias === alias);
|
||||
}
|
||||
|
||||
test.describe("Team Admin", () => {
|
||||
|
|
@ -56,9 +73,22 @@ test.describe("Team Admin", () => {
|
|||
await expect(emailOption).toBeAttached({ timeout: 10_000 });
|
||||
await page.keyboard.press("Enter");
|
||||
|
||||
await modal.getByRole("button", { name: /Add Member/i }).click();
|
||||
const add = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_add" }, async () => {
|
||||
await modal.getByRole("button", { name: /Add Member/i }).click();
|
||||
});
|
||||
// An add carrying the wrong team_id still toasts success, and the member lands elsewhere.
|
||||
expect(add.team_id, "add targets the team being viewed").toBe(E2E_TEAM_CRUD_ID);
|
||||
expect(add.member?.user_email, "the typed email is what goes on the wire").toBe("invitable-team@test.local");
|
||||
|
||||
await expect(page.getByText("Team member added successfully").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Membership is the point of the flow, so read the roster back.
|
||||
await expect
|
||||
.poll(async () => await teamMemberIdentities(page, E2E_TEAM_CRUD_ID), {
|
||||
message: "added member never appeared in the team's roster",
|
||||
timeout: 15_000,
|
||||
})
|
||||
.toContain("invitable-team@test.local");
|
||||
});
|
||||
|
||||
test("Team admin can remove a member from their team", async ({ page }) => {
|
||||
|
|
@ -77,9 +107,25 @@ test.describe("Team Admin", () => {
|
|||
|
||||
const modal = page.locator(".ant-modal:visible");
|
||||
await expect(modal).toBeVisible({ timeout: 5_000 });
|
||||
await modal.getByRole("button", { name: /^Delete$/ }).click();
|
||||
|
||||
const remove = await captureRequestBody(page, { method: "POST", urlIncludes: "/team/member_delete" }, async () => {
|
||||
await modal.getByRole("button", { name: /^Delete$/ }).click();
|
||||
});
|
||||
// Removing the wrong member is exactly what a success toast hides, so pin both halves.
|
||||
expect(remove.team_id, "delete targets the team being viewed").toBe(E2E_TEAM_CRUD_ID);
|
||||
expect([remove.user_id, remove.user_email], "delete identifies the member whose row was clicked").toContain(
|
||||
"e2e-removable-member",
|
||||
);
|
||||
|
||||
await expect(page.getByText("Team member removed successfully").first()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// The row disappearing is local state, which happens whether or not the write landed.
|
||||
await expect
|
||||
.poll(async () => await teamMemberIdentities(page, E2E_TEAM_CRUD_ID), {
|
||||
message: "removed member is still on the team",
|
||||
timeout: 15_000,
|
||||
})
|
||||
.not.toContain("e2e-removable-member");
|
||||
});
|
||||
|
||||
test("Team admin can create a team key with All Team Models", async ({ page }) => {
|
||||
|
|
@ -103,11 +149,20 @@ test.describe("Team Admin", () => {
|
|||
await page.locator(".ant-select-dropdown:visible").getByText("All Team Models").click();
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
||||
const generate = await captureRequestBody(page, { method: "POST", urlIncludes: "/key/generate" }, async () => {
|
||||
await page.getByRole("button", { name: "Create Key", exact: true }).click();
|
||||
});
|
||||
expect(generate.team_id, "the selected team goes on the wire").toBe(E2E_TEAM_CRUD_ID);
|
||||
|
||||
await expect(page.getByText("Save your Key")).toBeVisible({ timeout: 10_000 });
|
||||
await page.keyboard.press("Escape");
|
||||
|
||||
await expect(page.getByText(keyName)).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// A team-admin key that comes back unscoped, or scoped elsewhere, is a privilege and
|
||||
// billing problem that only a read-back sees.
|
||||
const persisted = await findKeyByAlias(page, keyName);
|
||||
expect(persisted, `key ${keyName} readable from /key/list`).toBeTruthy();
|
||||
expect(persisted?.team_id, "the key is owned by the team admin's own team").toBe(E2E_TEAM_CRUD_ID);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
74
tests/e2e/ui/tests/usage/usagePage.spec.ts
Normal file
74
tests/e2e/ui/tests/usage/usagePage.spec.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { test, expect, type Locator, type Page as PlaywrightPage } from "@playwright/test";
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { navigateToPage, dismissFeedbackPopup } from "../../helpers/navigation";
|
||||
import { Page } from "../../fixtures/pages";
|
||||
import {
|
||||
CHAT_MODEL_A,
|
||||
createVirtualKey,
|
||||
sendChatCompletion,
|
||||
waitForKeyInDailyActivity,
|
||||
waitForSpendLog,
|
||||
} from "../../helpers/traffic";
|
||||
|
||||
/** Covers /ui/usage. The legacy /ui/old-usage view is deprecated and deliberately not covered. */
|
||||
|
||||
/** Stepping up from the title is exact; the page renders several other tables. */
|
||||
const topKeysCard = (page: PlaywrightPage): Locator =>
|
||||
page.getByText("Top Virtual Keys", { exact: true }).locator("xpath=..");
|
||||
|
||||
async function openUsage(page: PlaywrightPage): Promise<Locator> {
|
||||
await navigateToPage(page, Page.NewUsage);
|
||||
await dismissFeedbackPopup(page);
|
||||
const card = topKeysCard(page);
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
// Widen past the default top-5 so other keys in the database cannot crowd this one out.
|
||||
await card.locator(".ant-segmented-item").filter({ hasText: /^50$/ }).click();
|
||||
return card;
|
||||
}
|
||||
|
||||
test.describe("Usage page", () => {
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test("Top Virtual Keys lists a key that served traffic, toggles views, and opens key info", async ({
|
||||
page,
|
||||
request,
|
||||
}) => {
|
||||
const alias = `e2e-usage-key-${Date.now()}`;
|
||||
const { key, token } = await createVirtualKey(request, {
|
||||
key_alias: alias,
|
||||
});
|
||||
|
||||
const requestId = await sendChatCompletion(request, {
|
||||
model: CHAT_MODEL_A,
|
||||
prompt: `usage ping for ${alias}`,
|
||||
apiKey: key,
|
||||
});
|
||||
await waitForSpendLog(request, requestId);
|
||||
// Must land in the aggregate before the page mounts — it fetches once.
|
||||
await waitForKeyInDailyActivity(request, token);
|
||||
|
||||
const card = await openUsage(page);
|
||||
|
||||
// Table view (the default): the key is listed by its alias.
|
||||
const row = card.locator("tbody tr").filter({ hasText: alias });
|
||||
await expect(row, `${alias} missing from Top Virtual Keys`).toHaveCount(1, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Chart view swaps the table out for the bar chart, and back.
|
||||
await card.getByText("Chart View", { exact: true }).click();
|
||||
await expect(card.locator("tbody tr")).toHaveCount(0, { timeout: 10_000 });
|
||||
await card.getByText("Table View", { exact: true }).click();
|
||||
await expect(row).toHaveCount(1, { timeout: 10_000 });
|
||||
|
||||
// Clicking the Key ID cell fetches key info and opens the detail panel.
|
||||
// The alias is already in the row behind the modal, so match the panel's own controls.
|
||||
await row.locator("td").first().click();
|
||||
const keyInfo = page.getByRole("tab", { name: "Overview", exact: true });
|
||||
await expect(keyInfo, "key info panel did not open").toBeVisible({
|
||||
timeout: 20_000,
|
||||
});
|
||||
await expect(page.getByRole("tab", { name: "Settings", exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Back to Keys", { exact: false })).toBeVisible();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue