deer-flow/frontend/tests/e2e/model-catalog.spec.ts
Wenchao An 2145c9ab1f
feat(models): discover provider catalogs and search chat models (#7871)
* feat(models): discover provider catalogs and search chat models

* docs(models): scope catalog guidance to owning directories

* test(guidance): register model catalog scoped instructions

* test(memory): pin capacity scenarios to a stable clock

* fix(models): guard catalog imports and align picker interactions

* test(memory): share the fixed clock with updater capacity tests

* fix(models): clear provider filters removed by catalog refresh

* refactor(models): share connection fields across model forms

* fix(models): keep active provider filters clearable

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-11-05 06:43:01 +08:00

269 lines
9.3 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
for (const mobile of [false, true]) {
test(`catalog batch import and searchable picker ${mobile ? "mobile" : "desktop"}`, async ({
page,
}, testInfo) => {
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
mockLangGraphAPI(page, { threads: [{ thread_id: MOCK_THREAD_ID }] });
const models: Record<string, unknown>[] = [];
const added: string[] = [];
await page.route("**/api/models", (route) =>
route.fulfill({
json: {
models: models.filter((m) => m.enabled),
token_usage: { enabled: false },
},
}),
);
await page.route("**/api/managed-models", (route) =>
route.fulfill({ json: { models } }),
);
await page.route("**/api/managed-models/providers", (route) =>
route.fulfill({
json: {
providers: [
{
id: "openai-compatible",
display_name: "Custom",
requires_api_key: false,
custom_endpoint: true,
},
{
id: "openai",
display_name: "OpenAI",
requires_api_key: true,
custom_endpoint: false,
},
{
id: "qwen-cn",
display_name: "Alibaba Cloud Bailian / Qwen (China)",
requires_api_key: true,
custom_endpoint: false,
},
{
id: "doubao",
display_name: "Volcengine / Doubao (Beijing, API)",
requires_api_key: true,
custom_endpoint: false,
},
],
},
}),
);
await page.route("**/api/managed-models/discover", (route) => {
expect(route.request().postDataJSON()).toMatchObject({
provider: "openai-compatible",
base_url: "https://custom.example/v1",
api_key: "fixture-key",
});
return route.fulfill({
json: {
models: [
{ id: "alpha", configured: false },
{ id: "beta", configured: false },
{ id: "no-tools", configured: false },
],
truncated: false,
},
});
});
await page.route("**/api/managed-models/import", (route) => {
const body = route.request().postDataJSON() as { model: string };
added.push(body.model);
if (body.model === "no-tools")
return route.fulfill({
json: { status: "failed", message: "tool_call_missing" },
});
const model = {
name: body.model,
model: body.model,
display_name: body.model,
provider: "OpenAI",
enabled: true,
source: "managed",
revision: "1",
has_api_key: true,
};
models.push(model);
return route.fulfill({ json: { status: "saved", model } });
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}?settings=models`);
await page
.getByRole("button", { name: "Fetch and add models", exact: true })
.waitFor();
await page.screenshot({
path: testInfo.outputPath("entry.png"),
animations: "disabled",
});
await page.getByRole("button", { name: "Add model", exact: true }).click();
const editor = page.getByRole("dialog", { name: "Add model", exact: true });
await editor
.getByRole("combobox", { name: "Provider", exact: true })
.selectOption("openai-compatible");
await editor
.getByLabel("Base URL", { exact: true })
.fill("https://custom.example/v1");
await editor
.getByLabel("API Key", { exact: true })
.fill("single-model-draft-key");
await editor.getByLabel("Model ID", { exact: true }).fill("manual-model");
await expect(editor).toHaveCSS("opacity", "1");
const singleEndpointBox = (await editor
.getByLabel("Base URL", { exact: true })
.boundingBox())!;
const singleKeyBox = (await editor
.getByLabel("API Key", { exact: true })
.boundingBox())!;
expect(singleEndpointBox.y + singleEndpointBox.height).toBeLessThanOrEqual(
singleKeyBox.y,
);
await editor.screenshot({
path: testInfo.outputPath("shared-editor.png"),
animations: "disabled",
});
await editor.getByRole("button", { name: "Cancel", exact: true }).click();
await page
.getByRole("button", { name: "Fetch and add models", exact: true })
.click();
const dialog = page.getByRole("dialog", {
name: "Fetch and add models",
exact: true,
});
const provider = dialog.getByRole("combobox", {
name: "Provider",
exact: true,
});
const apiKey = dialog.getByLabel("API Key", { exact: true });
await expect(apiKey).toHaveValue("");
await expect(dialog).toHaveCSS("opacity", "1");
const providerBox = (await provider.boundingBox())!;
const keyBox = (await apiKey.boundingBox())!;
const dialogBox = (await dialog.boundingBox())!;
expect(providerBox.x + providerBox.width).toBeLessThanOrEqual(
dialogBox.x + dialogBox.width,
);
if (!mobile)
expect(providerBox.x + providerBox.width).toBeLessThanOrEqual(keyBox.x);
await dialog.screenshot({
path: testInfo.outputPath("connection.png"),
animations: "disabled",
});
await provider.selectOption("openai-compatible");
const endpoint = dialog.getByLabel("Base URL", { exact: true });
await endpoint.fill("https://custom.example/v1");
await apiKey.fill("fixture-key");
const endpointBox = (await endpoint.boundingBox())!;
const customKeyBox = (await apiKey.boundingBox())!;
expect(endpointBox.y + endpointBox.height).toBeLessThanOrEqual(
customKeyBox.y,
);
await dialog.screenshot({
path: testInfo.outputPath("custom-connection.png"),
animations: "disabled",
});
await dialog
.getByRole("button", { name: "Fetch models", exact: true })
.click();
await expect(dialog.getByText("alpha", { exact: true })).toBeVisible();
expect(added).toEqual([]);
await expect(dialog.getByLabel("API Key", { exact: true })).toBeHidden();
await dialog.getByRole("button", { name: "Select visible" }).click();
await dialog.getByRole("button", { name: "Test and add (3)" }).click();
await expect(dialog.getByText(/did not return a tool call/)).toBeVisible();
expect(added).toEqual(["alpha", "beta", "no-tools"]);
await expect(
dialog.getByRole("button", { name: "Test and add (1)" }),
).toBeEnabled();
await page.screenshot({
path: testInfo.outputPath("catalog.png"),
animations: "disabled",
});
await dialog.getByRole("button", { name: "Done", exact: true }).click();
await page.keyboard.press("Escape");
await page
.getByRole("button", { name: "alpha", exact: true })
.first()
.click();
const picker = page.getByRole("dialog", { name: "Choose a model" });
await picker.getByRole("textbox", { name: "Search models…" }).fill("BETA");
await expect(
picker.getByRole("button", { name: "beta (beta)", exact: true }),
).toBeVisible();
await expect(
picker.getByRole("button", { name: "alpha (alpha)", exact: true }),
).toHaveCount(0);
await picker
.getByRole("button", { name: "beta (beta)", exact: true })
.click();
await page
.getByRole("button", { name: "beta", exact: true })
.first()
.click();
await expect(
picker.getByRole("group", { name: "Recently used" }),
).toBeVisible();
await expect(picker).toHaveCSS("opacity", "1");
await page.screenshot({
path: testInfo.outputPath("picker.png"),
animations: "disabled",
});
const box = await picker.boundingBox();
expect(box!.x).toBeGreaterThanOrEqual(0);
expect(box!.x + box!.width).toBeLessThanOrEqual(page.viewportSize()!.width);
});
}
for (const mobile of [false, true]) {
test(`provider group keyboard order ${mobile ? "mobile" : "desktop"}`, async ({
page,
}, testInfo) => {
if (mobile) await page.setViewportSize({ width: 390, height: 844 });
mockLangGraphAPI(page, { threads: [{ thread_id: MOCK_THREAD_ID }] });
const models = [
{
name: "a1",
model: "a1",
display_name: "Alpha 1",
provider: "Provider A",
},
{
name: "b1",
model: "b1",
display_name: "Beta 1",
provider: "Provider B",
},
{
name: "a2",
model: "a2",
display_name: "Alpha 2",
provider: "Provider A",
},
];
await page.route("**/api/models", (route) =>
route.fulfill({ json: { models, token_usage: { enabled: false } } }),
);
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await page
.getByRole("button", { name: "Alpha 1", exact: true })
.first()
.click();
const picker = page.getByRole("dialog", { name: "Choose a model" });
const options = picker.locator('[data-model-picker-option="true"]');
await expect(options).toHaveText(["Alpha 1a1", "Alpha 2a2", "Beta 1b1"]);
await options.nth(0).focus();
for (const index of [1, 2, 0]) {
await page.keyboard.press("ArrowDown");
await expect(options.nth(index)).toBeFocused();
}
await page.keyboard.press("ArrowUp");
await expect(options.nth(2)).toBeFocused();
await picker.screenshot({
path: testInfo.outputPath("grouped-picker.png"),
animations: "disabled",
});
});
}