deer-flow/frontend/tests/e2e/thread-archive.spec.ts
Ryker_Feng 98b8e4657e
feat(chats): add archive and restore (#5236)
* feat(chats): add archive and restore

* test(chats): observe archive search requests in pagination e2e

* docs(gateway): move thread lifecycle details out of inherited guidance

* docs(chats): add concise archive and restore RFC

* docs(chats): move archive RFC discussion to issue 5237
2026-09-06 22:30:26 +08:00

184 lines
5.8 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { mockLangGraphAPI } from "./utils/mock-api";
const CHAT = "00000000-0000-0000-0000-000000000901";
const OTHER = "00000000-0000-0000-0000-000000000902";
test("archive keeps the open conversation, supports undo and restores from the archive", async ({
page,
}, testInfo) => {
mockLangGraphAPI(page, {
threads: [
{
thread_id: CHAT,
title: "Finished report",
updated_at: "2026-07-04T10:00:00Z",
metadata: { deerflow_pinned: true },
},
{
thread_id: OTHER,
title: "Current work",
updated_at: "2026-07-05T10:00:00Z",
},
],
});
await page.goto(`/workspace/chats/${CHAT}`, {
waitUntil: "domcontentloaded",
});
const sidebarLink = page.locator(
`a[data-sidebar="menu-button"][href="/workspace/chats/${CHAT}"]`,
);
const archive = async () => {
await sidebarLink.hover();
await sidebarLink
.locator("xpath=..")
.getByRole("button", { name: "More" })
.click();
await page
.getByRole("menuitem", { name: "Archive chat", exact: true })
.click();
};
await expect(sidebarLink).toBeVisible();
await archive();
await expect(sidebarLink).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(CHAT));
await expect(
page.getByRole("button", { name: "Restore chat", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "Undo", exact: true }).click();
await expect(sidebarLink).toBeVisible();
await archive();
await expect(sidebarLink).toHaveCount(0);
await page.reload({ waitUntil: "domcontentloaded" });
await expect(
page.getByRole("button", { name: "Restore chat", exact: true }),
).toBeVisible();
await page.goto("/workspace/chats", { waitUntil: "domcontentloaded" });
await expect(
page.locator("main").getByText("Current work", { exact: true }),
).toBeVisible();
await page.getByRole("tab", { name: "Archived", exact: true }).click();
await expect(
page.locator("main").getByText("Finished report", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("tab", { name: "Archived", exact: true }),
).toHaveAttribute("aria-selected", "true");
await page.screenshot({
path: testInfo.outputPath("archived-list.png"),
animations: "disabled",
});
await page
.locator("main")
.getByRole("button", { name: "Restore chat", exact: true })
.click();
await expect(
page.getByText("No archived chats", { exact: true }),
).toBeVisible();
await expect(sidebarLink).toBeVisible();
});
test("failed archive keeps the chat visible", async ({ page }) => {
mockLangGraphAPI(page, { threads: [{ thread_id: CHAT, title: "Keep me" }] });
await page.route(`**/api/threads/${CHAT}`, (route) =>
route.request().method() === "PATCH"
? route.fulfill({
status: 500,
contentType: "application/json",
body: JSON.stringify({ detail: "Unavailable" }),
})
: route.fallback(),
);
await page.goto("/workspace/chats/new");
const link = page.locator(
`a[data-sidebar="menu-button"][href="/workspace/chats/${CHAT}"]`,
);
await link.hover();
await link.locator("xpath=..").getByRole("button", { name: "More" }).click();
await page
.getByRole("menuitem", { name: "Archive chat", exact: true })
.click();
await expect(
page.getByText("Failed to update archived chat", { exact: true }),
).toBeVisible();
await expect(link).toBeVisible();
});
test("active list includes legacy chats beyond a full page of archived chats", async ({
page,
}) => {
mockLangGraphAPI(page, {
threads: [
...Array.from({ length: 55 }, (_, index) => ({
thread_id: `archived-${index}`,
title: `Archived ${index}`,
updated_at: new Date(
Date.UTC(2026, 7, 1) - index * 60000,
).toISOString(),
metadata: { deerflow_archived: true },
})),
{
thread_id: CHAT,
title: "Legacy chat",
updated_at: "2020-01-01T00:00:00Z",
},
],
});
await page.goto("/workspace/chats", { waitUntil: "domcontentloaded" });
await expect(
page.locator("main").getByText("Legacy chat", { exact: true }),
).toBeVisible();
await expect(
page.locator("main").getByText("Archived 0", { exact: true }),
).toHaveCount(0);
await page.getByRole("tab", { name: "Archived", exact: true }).click();
await expect(
page.locator("main").getByText("Archived 0", { exact: true }),
).toBeVisible();
});
for (const customAgent of [false, true]) {
test(`archived ${customAgent ? "custom agent" : "default"} chat can be restored from its mobile header`, async ({
page,
}, testInfo) => {
await page.setViewportSize({ width: 390, height: 844 });
mockLangGraphAPI(page, {
agents: [{ name: "researcher", description: "Research assistant" }],
threads: [
{
thread_id: CHAT,
title: "Archived report",
metadata: {
deerflow_archived: true,
...(customAgent ? { agent_name: "researcher" } : {}),
},
},
],
});
const url = customAgent
? `/workspace/agents/researcher/chats/${CHAT}`
: `/workspace/chats/${CHAT}`;
await page.goto(url, { waitUntil: "domcontentloaded" });
const restore = page.getByRole("button", {
name: "Restore chat",
exact: true,
});
await expect(restore).toBeVisible({ timeout: 15000 });
await expect
.poll(() =>
page
.getByText("Archived report", { exact: true })
.evaluate((element) => element.getBoundingClientRect().height),
)
.toBeLessThanOrEqual(24);
await page.screenshot({
path: testInfo.outputPath("archived-header-mobile.png"),
animations: "disabled",
});
await restore.click();
await expect(restore).toHaveCount(0);
await expect(page).toHaveURL(new RegExp(CHAT));
});
}