deer-flow/frontend/tests/e2e/model-favorites.spec.ts
Ryker_Feng 6ca12c6c8f
feat(models): add user model favorites (#5441)
* feat(models): add user model favorites

* fix(models): use anchored favorites picker

* fix(models): keep model picker compact

* fix(models): remove obsolete favorites search path

* fix(models): address picker review feedback
2026-09-16 18:42:13 +08:00

350 lines
11 KiB
TypeScript

import { expect, test, type Page } from "@playwright/test";
import { type Model } from "@/core/models/types";
import { mockLangGraphAPI, MOCK_THREAD_ID } from "./utils/mock-api";
const MODELS: Model[] = [
{
id: "alpha-api",
name: "alpha-api",
model: "alpha-api",
display_name: "Alpha",
supports_thinking: false,
supports_reasoning_effort: false,
},
{
id: "beta-api",
name: "beta-api",
model: "beta-api",
display_name: "Shared",
supports_thinking: true,
supports_reasoning_effort: true,
},
{
id: "beta-duplicate",
name: "beta-duplicate",
model: "beta-duplicate",
display_name: "Shared",
supports_thinking: true,
supports_reasoning_effort: true,
},
{
id: "very-long-model-name",
name: "very-long-model-name",
model: "provider/very-long-model-name-that-must-stay-inside-the-dialog",
display_name:
"A very long model display name that must truncate on narrow screens",
supports_thinking: true,
supports_reasoning_effort: true,
},
];
type InstallOptions = Parameters<typeof mockLangGraphAPI>[1];
async function installPageMocks(
page: Page,
options?: InstallOptions,
): Promise<{ setModels: (models: Model[]) => void }> {
mockLangGraphAPI(page, options);
let models = MODELS;
// Register this after the shared mock so every page explicitly supplies the
// model catalog exercised by this spec.
await page.route("**/api/models", (route) => {
if (route.request().method() !== "GET") {
return route.fallback();
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
models,
token_usage: { enabled: false },
}),
});
});
return {
setModels(nextModels) {
models = nextModels;
},
};
}
function picker(page: Page) {
return page.getByRole("dialog", { name: "Choose a model" });
}
function favoriteButton(page: Page, modelName: string) {
const model = MODELS.find((candidate) => candidate.name === modelName);
if (!model) {
throw new Error(`Unknown model fixture: ${modelName}`);
}
return picker(page).getByRole("button", {
name: `Favorite ${model.display_name} (${model.name})`,
});
}
function favoriteGroup(page: Page) {
return picker(page).getByRole("group", { name: "Favorites" });
}
function modelButton(page: Page, modelName: string) {
const model = MODELS.find((candidate) => candidate.name === modelName);
if (!model) {
throw new Error(`Unknown model fixture: ${modelName}`);
}
return picker(page).getByRole("button", {
name: `${model.display_name} (${model.name})`,
exact: true,
});
}
async function openMainModelPicker(page: Page, name = "Alpha") {
const trigger = page.getByRole("button", { name, exact: true }).first();
await expect(trigger).toBeVisible();
await trigger.click();
await expect(picker(page)).toBeVisible();
return trigger;
}
async function selectAssistantText(page: Page, text: string) {
await page.evaluate((targetText) => {
const root = document.querySelector('[data-testid="main-message-list"]');
if (!root) {
throw new Error("Main message list was not found");
}
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
let node = walker.nextNode();
while (node) {
const value = node.textContent ?? "";
const start = value.indexOf(targetText);
if (start >= 0) {
const range = document.createRange();
range.setStart(node, start);
range.setEnd(node, start + targetText.length);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
node.parentElement?.dispatchEvent(
new MouseEvent("mouseup", { bubbles: true }),
);
return;
}
node = walker.nextNode();
}
throw new Error(`Unable to find assistant text: ${targetText}`);
}, text);
await expect(
page.getByRole("button", { name: "Ask in side chat" }),
).toBeVisible();
}
test("favorites a model without selecting it and persists the choice after refresh", async ({
page,
}) => {
await installPageMocks(page);
const runRequests: string[] = [];
page.on("request", (request) => {
if (request.method() === "POST" && request.url().includes("/runs/stream")) {
runRequests.push(request.url());
}
});
await page.goto("/workspace/chats/new");
const alphaTrigger = await openMainModelPicker(page);
await expect(page.locator('[data-slot="dialog-overlay"]')).toHaveCount(0);
const pickerBox = await picker(page).boundingBox();
const triggerBox = await alphaTrigger.boundingBox();
expect(pickerBox).not.toBeNull();
expect(triggerBox).not.toBeNull();
const pickerSide = await picker(page).getAttribute("data-side");
expect(["top", "bottom"]).toContain(pickerSide);
const anchorGap =
pickerSide === "top"
? triggerBox!.y - (pickerBox!.y + pickerBox!.height)
: pickerBox!.y - (triggerBox!.y + triggerBox!.height);
expect(anchorGap).toBeGreaterThanOrEqual(0);
expect(anchorGap).toBeLessThanOrEqual(12);
expect(pickerBox!.x).toBeGreaterThanOrEqual(0);
expect(pickerBox!.x + pickerBox!.width).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
);
const betaFavorite = favoriteButton(page, "beta-api");
await betaFavorite.click();
await expect(betaFavorite).toHaveAttribute("aria-pressed", "true");
await expect(alphaTrigger).toContainText("Alpha");
expect(runRequests).toEqual([]);
const favorites = favoriteGroup(page);
await expect(favorites).toBeVisible();
await expect(modelButton(page, "beta-api")).toBeVisible();
await modelButton(page, "beta-api").click();
await expect(picker(page)).toBeHidden();
const sharedTrigger = page.getByRole("button", {
name: "Shared",
exact: true,
});
await expect(sharedTrigger).toBeVisible();
expect(runRequests).toEqual([]);
await page.reload();
await expect(sharedTrigger).toBeVisible();
await sharedTrigger.click();
await expect(favoriteButton(page, "beta-api")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(
favoriteGroup(page).getByRole("button", {
name: "Shared (beta-api)",
exact: true,
}),
).toBeVisible();
});
test("synchronizes favorite additions and removals across real tabs", async ({
context,
page,
}) => {
await installPageMocks(page);
await page.goto("/workspace/chats/new");
await openMainModelPicker(page);
const secondPage = await context.newPage();
await installPageMocks(secondPage);
await secondPage.goto("/workspace/chats/new");
await openMainModelPicker(secondPage);
const firstTabFavorite = favoriteButton(page, "beta-api");
const secondTabFavorite = favoriteButton(secondPage, "beta-api");
await expect(firstTabFavorite).toHaveAttribute("aria-pressed", "false");
await expect(secondTabFavorite).toHaveAttribute("aria-pressed", "false");
await firstTabFavorite.click();
await expect(firstTabFavorite).toHaveAttribute("aria-pressed", "true");
await expect(secondTabFavorite).toHaveAttribute("aria-pressed", "true");
await secondTabFavorite.click();
await expect(secondTabFavorite).toHaveAttribute("aria-pressed", "false");
await expect(firstTabFavorite).toHaveAttribute("aria-pressed", "false");
});
test("shows main-chat favorites in the side chat without changing its current model", async ({
page,
}) => {
const assistantText = "Use this answer for a focused follow-up.";
await installPageMocks(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Favorites side chat",
messages: [
{ type: "human", id: "favorites-human", content: "Help me." },
{ type: "ai", id: "favorites-ai", content: assistantText },
],
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
await expect(page.getByText(assistantText)).toBeVisible();
await openMainModelPicker(page);
await favoriteButton(page, "beta-api").click();
await page.keyboard.press("Escape");
await selectAssistantText(page, assistantText);
await page.getByRole("button", { name: "Ask in side chat" }).click();
await expect(
page.getByRole("heading", { name: "Ask a follow-up" }),
).toBeVisible();
const sidecarForm = page.locator("form").filter({
has: page.getByPlaceholder(/deeper follow-up/i),
});
const sidecarTrigger = sidecarForm.getByRole("button", {
name: "Alpha",
exact: true,
});
await expect(sidecarTrigger).toBeVisible();
await sidecarTrigger.click();
await expect(modelButton(page, "beta-api")).toBeVisible();
await page.keyboard.press("Escape");
await expect(picker(page)).toBeHidden();
await expect(sidecarTrigger).toBeFocused();
await expect(sidecarTrigger).toHaveAccessibleName("Alpha");
});
test("restores a temporarily unavailable favorite and keeps the narrow picker usable", async ({
page,
}) => {
await page.setViewportSize({ width: 375, height: 720 });
const modelAPI = await installPageMocks(page);
await page.goto("/workspace/chats/new");
const trigger = await openMainModelPicker(page);
const longNameFavorite = favoriteButton(page, "very-long-model-name");
await expect(longNameFavorite).toBeVisible();
const narrowMetrics = await picker(page).evaluate((dialog) => {
const dialogBox = dialog.getBoundingClientRect();
const longRow = dialog
.querySelector(
'button[aria-label="Favorite A very long model display name that must truncate on narrow screens (very-long-model-name)"]',
)
?.closest("li")
?.getBoundingClientRect();
return {
viewportWidth: document.documentElement.clientWidth,
documentWidth: document.documentElement.scrollWidth,
dialogLeft: dialogBox.left,
dialogRight: dialogBox.right,
longRowRight: longRow?.right ?? Number.POSITIVE_INFINITY,
};
});
expect(narrowMetrics.documentWidth).toBeLessThanOrEqual(
narrowMetrics.viewportWidth,
);
expect(narrowMetrics.dialogLeft).toBeGreaterThanOrEqual(0);
expect(narrowMetrics.dialogRight).toBeLessThanOrEqual(
narrowMetrics.viewportWidth,
);
expect(narrowMetrics.longRowRight).toBeLessThanOrEqual(
narrowMetrics.dialogRight,
);
await longNameFavorite.click();
await expect(longNameFavorite).toHaveAttribute("aria-pressed", "true");
await page.keyboard.press("Escape");
await expect(picker(page)).toBeHidden();
await expect(trigger).toBeFocused();
modelAPI.setModels(
MODELS.filter((model) => model.name !== "very-long-model-name"),
);
await page.reload();
await openMainModelPicker(page);
await expect(
picker(page).getByText("very-long-model-name", { exact: true }),
).toHaveCount(0);
await page.keyboard.press("Escape");
modelAPI.setModels(MODELS);
await page.reload();
await openMainModelPicker(page);
await expect(favoriteButton(page, "very-long-model-name")).toHaveAttribute(
"aria-pressed",
"true",
);
await expect(
favoriteGroup(page).getByRole("button", {
name: `${MODELS[3]!.display_name} (very-long-model-name)`,
exact: true,
}),
).toBeVisible();
});