deer-flow/frontend/tests/e2e/branch-thread.spec.ts
Xinmin Zeng 943d148e5e
feat(threads): distinguish branched conversations (#4983)
* feat(threads): number branched conversation titles

* feat(frontend): show branch lineage in recent chats

* fix(threads): allocate unique branch suffixes

* fix(threads): preserve suffix and filter semantics
2026-08-25 08:29:18 +08:00

176 lines
5.1 KiB
TypeScript

import { expect, test } from "@playwright/test";
import {
MOCK_THREAD_ID,
MOCK_THREAD_ID_2,
mockLangGraphAPI,
THREAD_PINNED_METADATA_KEY,
} from "./utils/mock-api";
test.describe("Branch from turn", () => {
test("creates a new chat branch from a completed assistant turn", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Original chat",
messages: [
{
type: "human",
id: "human-1",
content: [{ type: "text", text: "First question" }],
},
{
type: "ai",
id: "ai-1",
content: "First answer",
},
{
type: "human",
id: "human-2",
content: [{ type: "text", text: "Second question" }],
},
{
type: "ai",
id: "ai-2",
content: "Intermediate answer",
},
{
type: "ai",
id: "ai-3",
content: "",
tool_calls: [
{
id: "tool-call-1",
name: "write_todos",
args: { todos: [] },
},
],
},
{
type: "tool",
id: "tool-1",
name: "write_todos",
tool_call_id: "tool-call-1",
content: "Todos updated",
},
{
type: "ai",
id: "ai-4",
content: "Final answer",
},
],
},
],
});
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
const historicalTurn = page
.locator("[data-assistant-turn]")
.filter({ hasText: "First answer" });
const intermediateTurn = page
.locator("[data-assistant-turn]")
.filter({ hasText: "Intermediate answer" });
const targetTurn = page
.locator("[data-assistant-turn]")
.filter({ hasText: "Final answer" });
await expect(historicalTurn).toBeVisible();
await historicalTurn.hover();
await expect(
historicalTurn.getByRole("button", { name: /branch conversation/i }),
).toBeVisible();
await expect(intermediateTurn).toBeVisible();
await intermediateTurn.hover();
await expect(
intermediateTurn.getByRole("button", { name: /branch conversation/i }),
).toHaveCount(0);
await expect(targetTurn).toBeVisible();
await targetTurn.hover();
await targetTurn
.getByRole("button", { name: /branch conversation/i })
.click();
await expect(page).toHaveURL(
new RegExp(`/workspace/chats/${MOCK_THREAD_ID_2}$`),
);
await expect(page.getByText("Final answer")).toBeVisible();
const branchThreadLink = page.locator(
`a[href="/workspace/chats/${MOCK_THREAD_ID_2}"]`,
);
await expect(branchThreadLink).toContainText("Original chat (2)");
await expect(branchThreadLink).not.toContainText("Branch:");
await expect(branchThreadLink).toHaveAttribute(
"data-branch-parent-id",
MOCK_THREAD_ID,
);
await expect(branchThreadLink).toHaveAttribute("data-branch-depth", "1");
await expect(branchThreadLink).toHaveAttribute(
"aria-label",
"Original chat (2), branch of Original chat",
);
await expect(branchThreadLink.getByTestId("thread-branch-stem")).toHaveText(
"└─",
);
const recentChatHrefs = await page
.locator(
'a[data-sidebar="menu-button"][href^="/workspace/chats/"]:not([href="/workspace/chats/new"])',
)
.evaluateAll((links) => links.map((link) => link.getAttribute("href")));
expect(recentChatHrefs).toEqual([
`/workspace/chats/${MOCK_THREAD_ID}`,
`/workspace/chats/${MOCK_THREAD_ID_2}`,
]);
});
test("keeps a pinned branch top-level when its parent is unpinned", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: MOCK_THREAD_ID,
title: "Unpinned parent",
updated_at: "2026-08-24T00:00:00Z",
},
{
thread_id: MOCK_THREAD_ID_2,
title: "Pinned branch (2)",
updated_at: "2026-08-24T00:01:00Z",
metadata: {
deerflow_branch: true,
branch_parent_thread_id: MOCK_THREAD_ID,
[THREAD_PINNED_METADATA_KEY]: true,
},
},
],
});
await page.goto("/workspace/chats/new");
const branchLink = page.locator(
`a[href="/workspace/chats/${MOCK_THREAD_ID_2}"]`,
);
await expect(branchLink).toBeVisible();
await expect(branchLink).not.toHaveAttribute("data-branch-depth");
await expect(branchLink.getByTestId("thread-branch-stem")).toHaveCount(0);
const recentChatHrefs = await page
.locator(
'a[data-sidebar="menu-button"][href^="/workspace/chats/"]:not([href="/workspace/chats/new"])',
)
.evaluateAll((links) => links.map((link) => link.getAttribute("href")));
expect(recentChatHrefs).toEqual([
`/workspace/chats/${MOCK_THREAD_ID_2}`,
`/workspace/chats/${MOCK_THREAD_ID}`,
]);
});
});