mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-24 00:52:24 +00:00
Merge pull request #41773 from BerriAI/litellm_dashboard-form-happy-paths
test(ui): cover dashboard form journeys
This commit is contained in:
commit
a158aa878f
5 changed files with 298 additions and 2 deletions
|
|
@ -12,17 +12,111 @@ export async function captureRequestBody(
|
|||
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));
|
||||
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> {
|
||||
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;
|
||||
}
|
||||
|
||||
type OperationOutcome =
|
||||
| { readonly status: "success" }
|
||||
| { readonly status: "failure"; readonly error: unknown };
|
||||
|
||||
type RunFailure =
|
||||
| { readonly status: "action_failure"; readonly error: unknown }
|
||||
| { readonly status: "cleanup_failure"; readonly error: unknown }
|
||||
| {
|
||||
readonly status: "action_and_cleanup_failure";
|
||||
readonly actionError: unknown;
|
||||
readonly cleanupError: unknown;
|
||||
};
|
||||
|
||||
function toRunFailure(
|
||||
actionOutcome: OperationOutcome,
|
||||
cleanupOutcome: OperationOutcome,
|
||||
): RunFailure | null {
|
||||
if (
|
||||
actionOutcome.status === "failure" &&
|
||||
cleanupOutcome.status === "failure"
|
||||
) {
|
||||
return {
|
||||
status: "action_and_cleanup_failure",
|
||||
actionError: actionOutcome.error,
|
||||
cleanupError: cleanupOutcome.error,
|
||||
};
|
||||
}
|
||||
if (actionOutcome.status === "failure") {
|
||||
return { status: "action_failure", error: actionOutcome.error };
|
||||
}
|
||||
if (cleanupOutcome.status === "failure") {
|
||||
return { status: "cleanup_failure", error: cleanupOutcome.error };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function raiseRunFailure(failure: RunFailure): never {
|
||||
switch (failure.status) {
|
||||
case "action_failure":
|
||||
throw failure.error;
|
||||
case "cleanup_failure":
|
||||
throw failure.error;
|
||||
case "action_and_cleanup_failure":
|
||||
throw new AggregateError(
|
||||
[failure.actionError, failure.cleanupError],
|
||||
"Action and cleanup failed",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAction(
|
||||
action: () => void | Promise<void>,
|
||||
): Promise<OperationOutcome> {
|
||||
return Promise.resolve()
|
||||
.then(action)
|
||||
.then(
|
||||
() => ({ status: "success" as const }),
|
||||
(error: unknown) => ({ status: "failure" as const, error }),
|
||||
);
|
||||
}
|
||||
|
||||
async function runCleanup(
|
||||
cleanup: () => boolean | Promise<boolean>,
|
||||
): Promise<OperationOutcome> {
|
||||
return Promise.resolve()
|
||||
.then(cleanup)
|
||||
.then(
|
||||
(succeeded) =>
|
||||
succeeded
|
||||
? { status: "success" as const }
|
||||
: {
|
||||
status: "failure" as const,
|
||||
error: new Error("Failed to clean up UI E2E resource"),
|
||||
},
|
||||
(error: unknown) => ({ status: "failure" as const, error }),
|
||||
);
|
||||
}
|
||||
|
||||
export async function runWithCleanup(
|
||||
action: () => void | Promise<void>,
|
||||
cleanup: () => boolean | Promise<boolean>,
|
||||
): Promise<void> {
|
||||
const actionOutcome = await runAction(action);
|
||||
const cleanupOutcome = await runCleanup(cleanup);
|
||||
const failure = toRunFailure(actionOutcome, cleanupOutcome);
|
||||
if (failure !== null) raiseRunFailure(failure);
|
||||
}
|
||||
|
|
|
|||
75
tests/e2e/ui/tests/prompts/addPrompt.spec.ts
Normal file
75
tests/e2e/ui/tests/prompts/addPrompt.spec.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { Page as DashboardPage } from "../../fixtures/pages";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import { runWithCleanup } from "../../helpers/roundTrip";
|
||||
import { masterKey, uniqueSuffix } from "../../helpers/traffic";
|
||||
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test.describe("Prompt upload form", () => {
|
||||
test("uploads a prompt file and reads the created prompt back", async ({
|
||||
page,
|
||||
}) => {
|
||||
const promptId = `e2e-prompt-${uniqueSuffix()}`;
|
||||
const promptContent = "Hello {{name}}";
|
||||
|
||||
await runWithCleanup(
|
||||
async () => {
|
||||
await navigateToPage(page, DashboardPage.Prompts);
|
||||
await page.getByRole("button", { name: "Upload .prompt File" }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Add New Prompt" }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Prompt ID").fill(promptId);
|
||||
await page.locator('input[type="file"]').setInputFiles({
|
||||
name: "e2e.prompt",
|
||||
mimeType: "text/plain",
|
||||
buffer: Buffer.from(
|
||||
`---\nmodel: fake-openai-gpt-4\n---\n${promptContent}\n`,
|
||||
),
|
||||
});
|
||||
await expect(page.getByText("Selected: e2e.prompt")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Create Prompt" }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await page.request.get(
|
||||
`/prompts/${encodeURIComponent(promptId)}/info?environment=development`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
},
|
||||
);
|
||||
return response.ok();
|
||||
})
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await page.request.get(
|
||||
`/prompts/${encodeURIComponent(promptId)}/info?environment=development`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
},
|
||||
);
|
||||
if (!response.ok()) return undefined;
|
||||
const promptInfo = (await response.json()) as {
|
||||
raw_prompt_template?: { content?: string };
|
||||
};
|
||||
return promptInfo.raw_prompt_template?.content;
|
||||
})
|
||||
.toBe(promptContent);
|
||||
await expect(page.getByText(promptId, { exact: true })).toBeVisible();
|
||||
},
|
||||
async () => {
|
||||
const response = await page.request.delete(
|
||||
`/prompts/${encodeURIComponent(promptId)}?environment=development`,
|
||||
{
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
},
|
||||
);
|
||||
return response.ok();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
84
tests/e2e/ui/tests/tagManagement/tagManagement.spec.ts
Normal file
84
tests/e2e/ui/tests/tagManagement/tagManagement.spec.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { test, expect } from "@playwright/test";
|
||||
|
||||
import { ADMIN_STORAGE_PATH } from "../../constants";
|
||||
import { Page as DashboardPage } from "../../fixtures/pages";
|
||||
import { navigateToPage } from "../../helpers/navigation";
|
||||
import {
|
||||
captureRequestBody,
|
||||
readBack,
|
||||
runWithCleanup,
|
||||
} from "../../helpers/roundTrip";
|
||||
import { masterKey, uniqueSuffix } from "../../helpers/traffic";
|
||||
|
||||
test.use({ storageState: ADMIN_STORAGE_PATH });
|
||||
|
||||
test.describe("Tag management", () => {
|
||||
test("creates, edits, reopens, and reads back a tag", async ({ page }) => {
|
||||
const tagName = `e2e-tag-${uniqueSuffix()}`;
|
||||
const description = "synthetic tag description";
|
||||
const updatedDescription = `${description} updated`;
|
||||
|
||||
await runWithCleanup(
|
||||
async () => {
|
||||
await navigateToPage(page, DashboardPage.TagManagement);
|
||||
await page.getByRole("button", { name: "+ Create New Tag" }).click();
|
||||
await expect(
|
||||
page.getByRole("dialog", { name: "Create New Tag" }),
|
||||
).toBeVisible();
|
||||
await page.getByLabel("Tag Name").fill(tagName);
|
||||
await page.getByLabel("Description").fill(description);
|
||||
await page.getByRole("button", { name: "Create Tag" }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const response = await readBack<Array<{ name: string }>>(
|
||||
page,
|
||||
"/tag/list",
|
||||
);
|
||||
return response.some((tag) => tag.name === tagName);
|
||||
})
|
||||
.toBe(true);
|
||||
await expect(page.getByText(tagName, { exact: true })).toBeVisible();
|
||||
|
||||
await page.getByText(tagName, { exact: true }).click();
|
||||
await expect(page.getByText("Tag Name:")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Edit Tag" }).click();
|
||||
await page.getByLabel("Description").fill(updatedDescription);
|
||||
const updateBody = await captureRequestBody(
|
||||
page,
|
||||
{ method: "POST", urlIncludes: "/tag/update" },
|
||||
() => page.getByRole("button", { name: "Save Changes" }).click(),
|
||||
);
|
||||
expect(updateBody).toMatchObject({
|
||||
name: tagName,
|
||||
description: updatedDescription,
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const infoResponse = await page.request.post("/tag/info", {
|
||||
headers: { Authorization: `Bearer ${masterKey()}` },
|
||||
data: { names: [tagName] },
|
||||
});
|
||||
expect(infoResponse.ok()).toBe(true);
|
||||
const info = (await infoResponse.json()) as Record<
|
||||
string,
|
||||
{ description?: string }
|
||||
>;
|
||||
return info[tagName]?.description;
|
||||
})
|
||||
.toBe(updatedDescription);
|
||||
},
|
||||
async () => {
|
||||
const response = await page.request.post("/tag/delete", {
|
||||
headers: {
|
||||
Authorization: `Bearer ${masterKey()}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data: { name: tagName },
|
||||
});
|
||||
return response.ok();
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -111,4 +111,36 @@ describe("SearchSelect", () => {
|
|||
expect(screen.queryByText("Growth")).not.toBeInTheDocument();
|
||||
expect(onValueChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("supports keyboard select, clear, and reselect", async () => {
|
||||
const onValueChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
function Controlled() {
|
||||
const [value, setValue] = useState<string | null>(null);
|
||||
return (
|
||||
<SearchSelect
|
||||
options={OPTIONS}
|
||||
value={value}
|
||||
onValueChange={(next) => {
|
||||
setValue(next);
|
||||
onValueChange(next);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Controlled />);
|
||||
const input = screen.getByRole("combobox");
|
||||
await user.tab();
|
||||
await user.keyboard("{Enter}");
|
||||
await user.keyboard("{ArrowDown}{Enter}");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith("team-1");
|
||||
const clear = screen.getByRole("button", { name: "Clear" });
|
||||
clear.focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith(null);
|
||||
input.focus();
|
||||
await user.keyboard("{Enter}{ArrowDown}{Enter}");
|
||||
expect(onValueChange).toHaveBeenLastCalledWith("team-1");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -344,4 +344,15 @@ describe("RequestLogsFilters", () => {
|
|||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.CACHE_STATUS, undefined);
|
||||
});
|
||||
|
||||
it("clears the raw Error Code combobox through the undefined filter contract", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { set } = renderFilters({ [LOG_FILTER_IDS.ERROR_CODE]: "429" });
|
||||
const input = await screen.findByPlaceholderText("Select or type an error code");
|
||||
|
||||
await user.click(input);
|
||||
await user.click(screen.getByRole("button", { name: "Clear", hidden: true }));
|
||||
|
||||
expect(set).toHaveBeenCalledWith(LOG_FILTER_IDS.ERROR_CODE, undefined);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue