mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-29 09:26:00 +00:00
* fix(frontend): preserve message order during long runs * test(frontend): fix history pagination regression mock * fix(frontend): validate thread history sequences
718 lines
23 KiB
TypeScript
718 lines
23 KiB
TypeScript
import { expect, test, type Route } from "@playwright/test";
|
||
|
||
import {
|
||
mockLangGraphAPI,
|
||
MOCK_THREAD_ID,
|
||
MOCK_THREAD_ID_2,
|
||
} from "./utils/mock-api";
|
||
|
||
const THREADS = [
|
||
{
|
||
thread_id: MOCK_THREAD_ID,
|
||
title: "First conversation",
|
||
updated_at: "2025-06-01T12:00:00Z",
|
||
},
|
||
{
|
||
thread_id: MOCK_THREAD_ID_2,
|
||
title: "Second conversation",
|
||
updated_at: "2025-06-02T12:00:00Z",
|
||
},
|
||
];
|
||
const DEMO_THREAD_ID = "7cfa5f8f-a2f8-47ad-acbd-da7137baf990";
|
||
const SVG_PROMPT_THREAD_ID = "00000000-0000-0000-0000-000000000777";
|
||
const SVG_PROMPT_MARKER = "LEAK-STRICT-SVG-PROMPT-SHOULD-DISAPPEAR";
|
||
const OPTIMISTIC_PROMPT_MARKER = "LEAK-OPTIMISTIC-SVG-PROMPT-SHOULD-DISAPPEAR";
|
||
|
||
test.describe("Thread history", () => {
|
||
test("sidebar shows existing threads", async ({ page }) => {
|
||
mockLangGraphAPI(page, { threads: THREADS });
|
||
|
||
await page.goto("/workspace/chats/new");
|
||
|
||
// Both thread titles should appear in the sidebar
|
||
await expect(page.getByText("First conversation")).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
await expect(page.getByText("Second conversation")).toBeVisible();
|
||
});
|
||
|
||
test("clicking a thread in sidebar navigates to it", async ({ page }) => {
|
||
mockLangGraphAPI(page, { threads: THREADS });
|
||
|
||
await page.goto("/workspace/chats/new");
|
||
|
||
// Wait for sidebar to populate
|
||
const firstThread = page.getByText("First conversation");
|
||
await expect(firstThread).toBeVisible({ timeout: 15_000 });
|
||
|
||
// Click on the first thread
|
||
await firstThread.click();
|
||
|
||
// Should navigate to that thread's URL
|
||
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID}`);
|
||
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
|
||
});
|
||
|
||
test("clicking blank space in a sidebar thread row navigates to it", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page, { threads: THREADS });
|
||
|
||
await page.goto("/workspace/chats/new");
|
||
|
||
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||
const firstThreadItem = sidebar
|
||
.locator("[data-sidebar='menu-item']")
|
||
.filter({ hasText: "First conversation" })
|
||
.first();
|
||
await expect(firstThreadItem).toBeVisible({ timeout: 15_000 });
|
||
|
||
const firstThreadLink = firstThreadItem.getByRole("link");
|
||
await expect(firstThreadLink).toBeVisible();
|
||
|
||
const box = await firstThreadLink.boundingBox();
|
||
expect(box).not.toBeNull();
|
||
if (!box) {
|
||
return;
|
||
}
|
||
|
||
await firstThreadLink.click({ position: { x: 4, y: box.height / 2 } });
|
||
|
||
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID}`);
|
||
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
|
||
});
|
||
|
||
test("existing thread loads historical messages", async ({ page }) => {
|
||
mockLangGraphAPI(page, { threads: THREADS });
|
||
|
||
// Navigate directly to an existing thread
|
||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||
|
||
// The historical AI response should be displayed
|
||
await expect(
|
||
page.getByText("Response in thread First conversation"),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
});
|
||
|
||
test("keeps rendered messages ordered when the latest history page advances", async ({
|
||
page,
|
||
}) => {
|
||
const originalPrompt = "/ppt-master Build the quarterly presentation";
|
||
const followUpPrompt = "Continue with the approved default layout";
|
||
const olderRows = Array.from({ length: 50 }, (_, index) => {
|
||
const seq = index + 1;
|
||
if (index === 0) {
|
||
return {
|
||
run_id: "run-initial",
|
||
seq,
|
||
content: {
|
||
type: "human",
|
||
id: "history-prompt",
|
||
content: [{ type: "text", text: originalPrompt }],
|
||
},
|
||
metadata: { caller: "lead_agent" },
|
||
created_at: "2025-06-03T12:00:00Z",
|
||
};
|
||
}
|
||
if (index === 1) {
|
||
return {
|
||
run_id: "run-initial",
|
||
seq,
|
||
content: {
|
||
type: "ai",
|
||
id: "history-answer",
|
||
content: "Initial design is ready",
|
||
},
|
||
metadata: { caller: "lead_agent" },
|
||
created_at: "2025-06-03T12:00:01Z",
|
||
};
|
||
}
|
||
return {
|
||
run_id: "run-initial",
|
||
seq,
|
||
content: {
|
||
type: "ai",
|
||
id: `history-step-${seq}`,
|
||
content: `Historical presentation step ${seq}`,
|
||
...(index === 49
|
||
? { additional_kwargs: { turn_duration: 704 } }
|
||
: {}),
|
||
},
|
||
metadata: { caller: "lead_agent" },
|
||
created_at: "2025-06-03T12:00:02Z",
|
||
};
|
||
});
|
||
const initialRows = Array.from({ length: 50 }, (_, index) => {
|
||
const seq = index + 51;
|
||
return {
|
||
run_id: "run-initial",
|
||
seq,
|
||
content: {
|
||
type: "ai",
|
||
id: `history-step-${seq}`,
|
||
content: `Historical presentation step ${seq}`,
|
||
},
|
||
metadata: { caller: "lead_agent" },
|
||
created_at: "2025-06-03T12:00:03Z",
|
||
};
|
||
});
|
||
const shiftedRows = Array.from({ length: 50 }, (_, index) => {
|
||
const seq = index + 101;
|
||
return {
|
||
run_id: "run-shifted",
|
||
seq,
|
||
content: {
|
||
type: "ai",
|
||
id: `shifted-step-${seq}`,
|
||
content: `New presentation step ${seq}`,
|
||
},
|
||
metadata: { caller: "lead_agent" },
|
||
created_at: "2025-06-03T12:01:00Z",
|
||
};
|
||
});
|
||
let latestPageRequestCount = 0;
|
||
let cursorPageRequestCount = 0;
|
||
|
||
mockLangGraphAPI(page, {
|
||
threads: [
|
||
{
|
||
thread_id: MOCK_THREAD_ID,
|
||
title: "Long presentation task",
|
||
updated_at: "2025-06-03T12:00:00Z",
|
||
// This scenario exercises persisted run-event pagination. Keep the
|
||
// checkpoint empty so its generic mock messages do not interfere
|
||
// with optimistic -> server reconciliation after the follow-up.
|
||
messages: [],
|
||
},
|
||
],
|
||
});
|
||
await page.route(
|
||
new RegExp(`/api/threads/${MOCK_THREAD_ID}/messages/page(?:\\?.*)?$`),
|
||
async (route) => {
|
||
if (route.request().method() !== "GET") {
|
||
return route.fallback();
|
||
}
|
||
|
||
const beforeSeq = new URL(route.request().url()).searchParams.get(
|
||
"before_seq",
|
||
);
|
||
const isLatestPage = beforeSeq === null;
|
||
const rows = isLatestPage
|
||
? latestPageRequestCount === 0
|
||
? initialRows
|
||
: shiftedRows
|
||
: beforeSeq === "101"
|
||
? initialRows
|
||
: olderRows;
|
||
const hasMore = isLatestPage || beforeSeq === "101";
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
data: rows,
|
||
has_more: hasMore,
|
||
next_before_seq: hasMore ? (rows[0]?.seq ?? null) : null,
|
||
}),
|
||
});
|
||
if (isLatestPage) {
|
||
latestPageRequestCount += 1;
|
||
} else {
|
||
cursorPageRequestCount += 1;
|
||
}
|
||
},
|
||
);
|
||
|
||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||
await expect
|
||
.poll(() => cursorPageRequestCount, { timeout: 15_000 })
|
||
.toBeGreaterThan(0);
|
||
await expect(page.getByText(originalPrompt)).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
await expect(page.getByText("Completed in 11m 44s")).toBeVisible();
|
||
|
||
const latestPageRequestsBeforeSubmit = latestPageRequestCount;
|
||
const textarea = page.locator("textarea[name='message']");
|
||
await textarea.fill(followUpPrompt);
|
||
await textarea.press("Enter");
|
||
|
||
await expect
|
||
.poll(() => latestPageRequestCount, { timeout: 15_000 })
|
||
.toBeGreaterThan(latestPageRequestsBeforeSubmit);
|
||
await expect(page.getByText(originalPrompt)).toBeVisible();
|
||
await expect(page.getByText(followUpPrompt)).toBeVisible();
|
||
await expect(page.getByText("Completed in 11m 44s")).toBeVisible();
|
||
|
||
const originalBox = await page.getByText(originalPrompt).boundingBox();
|
||
const followUpBox = await page.getByText(followUpPrompt).boundingBox();
|
||
expect(originalBox).not.toBeNull();
|
||
expect(followUpBox).not.toBeNull();
|
||
expect(originalBox!.y).toBeLessThan(followUpBox!.y);
|
||
});
|
||
|
||
test("shows a completed run duration once after multi-step history", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page, {
|
||
threads: [
|
||
{
|
||
thread_id: MOCK_THREAD_ID,
|
||
title: "Multi-step duration",
|
||
updated_at: "2025-06-03T12:00:00Z",
|
||
messages: [
|
||
{
|
||
type: "human",
|
||
id: "msg-human-duration",
|
||
content: [{ type: "text", text: "Complete several steps" }],
|
||
},
|
||
{
|
||
type: "ai",
|
||
id: "msg-ai-duration-1",
|
||
content: "Intermediate result",
|
||
additional_kwargs: { turn_duration: 114 },
|
||
},
|
||
{
|
||
type: "ai",
|
||
id: "msg-ai-duration-2",
|
||
content: "Final result",
|
||
additional_kwargs: {
|
||
turn_duration: 114,
|
||
reasoning_content: "Final synthesis reasoning",
|
||
},
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||
await expect(page.getByText("Final result")).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
|
||
await expect(page.getByTestId("run-duration")).toHaveCount(1);
|
||
await expect(page.getByText("Completed in 1m 54s")).toBeVisible();
|
||
await expect(
|
||
page.getByRole("button", { name: "Reasoning", exact: true }),
|
||
).toBeVisible();
|
||
await expect(page.getByText("Thought for 114 seconds")).toHaveCount(0);
|
||
});
|
||
|
||
test("input box recalls previous prompts with arrow keys", async ({
|
||
page,
|
||
}) => {
|
||
const firstPrompt = "Summarize the latest quarterly report";
|
||
const secondPrompt = "Turn the summary into an action plan";
|
||
|
||
mockLangGraphAPI(page, {
|
||
threads: [
|
||
{
|
||
thread_id: MOCK_THREAD_ID,
|
||
title: "Prompt history conversation",
|
||
updated_at: "2025-06-03T12:00:00Z",
|
||
messages: [
|
||
{
|
||
type: "human",
|
||
id: "msg-human-prompt-history-1",
|
||
content: [{ type: "text", text: firstPrompt }],
|
||
},
|
||
{
|
||
type: "ai",
|
||
id: "msg-ai-prompt-history-1",
|
||
content: "First answer",
|
||
},
|
||
{
|
||
type: "human",
|
||
id: "msg-human-prompt-history-2",
|
||
content: [{ type: "text", text: secondPrompt }],
|
||
},
|
||
{
|
||
type: "ai",
|
||
id: "msg-ai-prompt-history-2",
|
||
content: "Second answer",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||
await expect(page.getByText("Second answer")).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
|
||
const textarea = page.locator("textarea[name='message']");
|
||
await expect(textarea).toBeVisible();
|
||
|
||
await textarea.focus();
|
||
await textarea.press("ArrowUp");
|
||
await expect(textarea).toHaveValue(secondPrompt);
|
||
|
||
await textarea.press("ArrowUp");
|
||
await expect(textarea).toHaveValue(firstPrompt);
|
||
|
||
await textarea.press("ArrowDown");
|
||
await expect(textarea).toHaveValue(secondPrompt);
|
||
|
||
await textarea.press("ArrowDown");
|
||
await expect(textarea).toHaveValue("");
|
||
|
||
await textarea.fill("draft should not be overwritten");
|
||
await textarea.press("ArrowUp");
|
||
await expect(textarea).toHaveValue("draft should not be overwritten");
|
||
});
|
||
|
||
test("deleting an inactive chat keeps the current chat open", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page, { threads: THREADS });
|
||
|
||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||
await expect(
|
||
page.getByText("Response in thread First conversation"),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
|
||
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||
const inactiveThreadItem = sidebar
|
||
.locator("[data-sidebar='menu-item']")
|
||
.filter({
|
||
has: page.getByRole("button", { name: /more/i }),
|
||
hasText: "Second conversation",
|
||
})
|
||
.first();
|
||
await expect(inactiveThreadItem).toBeVisible();
|
||
await inactiveThreadItem.hover();
|
||
await inactiveThreadItem.getByRole("button", { name: /more/i }).click();
|
||
await page.getByRole("menuitem", { name: /delete/i }).click();
|
||
|
||
await expect(page).toHaveURL(new RegExp(MOCK_THREAD_ID));
|
||
await expect(
|
||
page.getByText("Response in thread First conversation"),
|
||
).toBeVisible();
|
||
await expect(sidebar.getByText("Second conversation")).toHaveCount(0);
|
||
});
|
||
|
||
test("new chat does not show previous thread messages after client-side navigation", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page, {
|
||
threads: [
|
||
{
|
||
thread_id: SVG_PROMPT_THREAD_ID,
|
||
title: "SVG artifact prompt",
|
||
updated_at: "2025-06-03T12:00:00Z",
|
||
messages: [
|
||
{
|
||
type: "human",
|
||
id: "msg-human-svg-prompt",
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: `请严格执行:\n1. 使用 write_file 创建 /mnt/user-data/outputs/shared.svg,内容包含 ${SVG_PROMPT_MARKER}\n2. 最终回复只输出 Markdown 图片。`,
|
||
},
|
||
],
|
||
},
|
||
{
|
||
type: "ai",
|
||
id: "msg-ai-svg-prompt",
|
||
content: "",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
|
||
await page.goto(`/workspace/chats/${SVG_PROMPT_THREAD_ID}`);
|
||
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
|
||
await page
|
||
.locator("[data-sidebar='sidebar'] a[href='/workspace/chats/new']")
|
||
.click();
|
||
await page.waitForURL("**/workspace/chats/new");
|
||
|
||
await expect(page.getByText(SVG_PROMPT_MARKER)).toBeHidden();
|
||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||
});
|
||
|
||
test("new chat does not show previous optimistic user message after client-side navigation", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page, {
|
||
threads: [
|
||
{
|
||
thread_id: MOCK_THREAD_ID_2,
|
||
title: "Destination conversation",
|
||
updated_at: "2025-06-04T12:00:00Z",
|
||
},
|
||
],
|
||
});
|
||
|
||
const metadataOnlyStream = async (route: Route) => {
|
||
const body = [
|
||
{
|
||
event: "metadata",
|
||
data: {
|
||
run_id: "00000000-0000-0000-0000-000000000778",
|
||
thread_id: MOCK_THREAD_ID,
|
||
},
|
||
},
|
||
{ event: "end", data: {} },
|
||
]
|
||
.map((e) => `event: ${e.event}\ndata: ${JSON.stringify(e.data)}\n\n`)
|
||
.join("");
|
||
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: "text/event-stream",
|
||
body,
|
||
});
|
||
};
|
||
|
||
await page.route("**/api/langgraph/runs/stream", metadataOnlyStream);
|
||
await page.route(
|
||
"**/api/langgraph/threads/*/runs/stream",
|
||
metadataOnlyStream,
|
||
);
|
||
|
||
await page.goto("/workspace/chats/new");
|
||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||
await textarea.fill(
|
||
`请严格执行:使用 write_file 创建 shared.svg,内容包含 ${OPTIMISTIC_PROMPT_MARKER}。`,
|
||
);
|
||
await textarea.press("Enter");
|
||
|
||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toBeVisible();
|
||
|
||
await page.getByText("Destination conversation").click();
|
||
await page.waitForURL(`**/workspace/chats/${MOCK_THREAD_ID_2}`);
|
||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||
|
||
await page
|
||
.locator("[data-sidebar='sidebar'] a[href='/workspace/chats/new']")
|
||
.click();
|
||
await page.waitForURL("**/workspace/chats/new");
|
||
|
||
await expect(page.getByText(OPTIMISTIC_PROMPT_MARKER)).toHaveCount(0);
|
||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||
});
|
||
|
||
test("new chat resets immediately after a history-only thread URL update", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page);
|
||
|
||
await page.goto("/workspace/chats/new");
|
||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||
await textarea.fill("Message that must disappear in the next new chat");
|
||
await textarea.press("Enter");
|
||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
|
||
// A newly created chat changes the URL with history.replaceState so the
|
||
// active stream is not remounted. Reproduce that history-only transition:
|
||
// the canonical pathname becomes the UUID while useParams can stay "new".
|
||
await page.evaluate((threadId) => {
|
||
history.replaceState(null, "", `/workspace/chats/${threadId}`);
|
||
}, MOCK_THREAD_ID);
|
||
|
||
const newChatLink = page.locator(
|
||
"[data-sidebar='sidebar'] a[href='/workspace/chats/new']",
|
||
);
|
||
await expect(page).toHaveURL(
|
||
new RegExp(`/workspace/chats/${MOCK_THREAD_ID}$`),
|
||
);
|
||
await expect(newChatLink).toHaveAttribute("data-active", "false");
|
||
|
||
// One click must reset the chat without a second click or unrelated UI
|
||
// interaction forcing another render.
|
||
await newChatLink.click();
|
||
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
|
||
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
|
||
await expect(textarea).toBeVisible();
|
||
});
|
||
|
||
test("deleting the active newly created chat returns to the new chat screen", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page);
|
||
await page.route(/\/api\/threads\/[^/]+$/, (route) => {
|
||
if (route.request().method() === "DELETE") {
|
||
return route.fulfill({
|
||
status: 500,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ detail: "Local cleanup failed" }),
|
||
});
|
||
}
|
||
return route.fallback();
|
||
});
|
||
|
||
await page.goto("/workspace/chats/new");
|
||
const textarea = page.getByPlaceholder(/how can i assist you/i);
|
||
await expect(textarea).toBeVisible({ timeout: 15_000 });
|
||
await textarea.fill("What should disappear after deletion?");
|
||
await textarea.press("Enter");
|
||
|
||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
|
||
const sidebar = page.locator("[data-sidebar='sidebar']");
|
||
const recentThreadItem = sidebar
|
||
.locator("[data-sidebar='menu-item']")
|
||
.filter({
|
||
has: page.getByRole("button", { name: /more/i }),
|
||
hasText: "New Chat",
|
||
})
|
||
.first();
|
||
await expect(recentThreadItem).toBeVisible();
|
||
await recentThreadItem.hover();
|
||
await recentThreadItem.getByRole("button", { name: /more/i }).click();
|
||
await page.getByRole("menuitem", { name: /delete/i }).click();
|
||
|
||
await expect(page).toHaveURL(/\/workspace\/chats\/new$/);
|
||
await expect(page.getByText("Previous question")).toHaveCount(0);
|
||
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
|
||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||
|
||
await page.goto(`/workspace/chats/${MOCK_THREAD_ID}`);
|
||
await page.waitForURL("**/workspace/chats/new");
|
||
await expect(page.getByText("Hello from DeerFlow!")).toHaveCount(0);
|
||
await expect(page.getByPlaceholder(/how can i assist you/i)).toBeVisible();
|
||
});
|
||
|
||
test("mock thread does not load real backend run history", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page, {
|
||
threads: [
|
||
{
|
||
thread_id: DEMO_THREAD_ID,
|
||
title: "Forecasting 2026 Trends and Opportunities",
|
||
updated_at: "2025-06-01T12:00:00Z",
|
||
messages: [
|
||
{
|
||
type: "human",
|
||
id: `run-human-${DEMO_THREAD_ID}`,
|
||
content: [
|
||
{
|
||
type: "text",
|
||
text: "This run-message endpoint should not be called.",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
});
|
||
const backendRunHistoryUrls: string[] = [];
|
||
await page.route(
|
||
/\/api\/langgraph\/threads\/[^/]+\/runs(?:\?|$)/,
|
||
(route) => {
|
||
if (
|
||
route.request().method() === "GET" &&
|
||
route
|
||
.request()
|
||
.url()
|
||
.includes(`/api/langgraph/threads/${DEMO_THREAD_ID}/runs`)
|
||
) {
|
||
backendRunHistoryUrls.push(route.request().url());
|
||
return route.fulfill({
|
||
status: 500,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
error: "mock=true must not load real runs",
|
||
}),
|
||
});
|
||
}
|
||
return route.fallback();
|
||
},
|
||
);
|
||
await page.route(
|
||
/\/api\/threads\/[^/]+\/runs\/[^/]+\/messages(?:\?|$)/,
|
||
(route) => {
|
||
if (
|
||
route.request().method() === "GET" &&
|
||
route.request().url().includes(`/api/threads/${DEMO_THREAD_ID}/runs/`)
|
||
) {
|
||
backendRunHistoryUrls.push(route.request().url());
|
||
return route.fulfill({
|
||
status: 500,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
error: "mock=true must not load real run messages",
|
||
}),
|
||
});
|
||
}
|
||
return route.fallback();
|
||
},
|
||
);
|
||
|
||
await page.goto(`/workspace/chats/${DEMO_THREAD_ID}?mock=true`);
|
||
|
||
await expect(
|
||
page.getByText("What might be the trends and opportunities in 2026?"),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
await expect(
|
||
page.getByText("I've created a modern, minimalist website"),
|
||
).toBeVisible();
|
||
expect(backendRunHistoryUrls).toEqual([]);
|
||
});
|
||
|
||
test("chats list page shows all threads", async ({ page }) => {
|
||
mockLangGraphAPI(page, { threads: THREADS });
|
||
|
||
await page.goto("/workspace/chats");
|
||
|
||
// Both threads should be listed in the main content area
|
||
const main = page.locator("main");
|
||
await expect(main.getByText("First conversation")).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
await expect(main.getByText("Second conversation")).toBeVisible();
|
||
});
|
||
|
||
test("IM channel threads show their source in thread lists", async ({
|
||
page,
|
||
}) => {
|
||
mockLangGraphAPI(page, {
|
||
threads: [
|
||
{
|
||
thread_id: MOCK_THREAD_ID,
|
||
title: "Feishu conversation",
|
||
updated_at: "2025-06-03T12:00:00Z",
|
||
metadata: {
|
||
channel_source: {
|
||
type: "im_channel",
|
||
provider: "feishu",
|
||
chat_id: "oc_mock",
|
||
},
|
||
},
|
||
},
|
||
],
|
||
});
|
||
|
||
await page.goto("/workspace/chats/new");
|
||
|
||
const sidebarThread = page.locator(
|
||
`a[href='/workspace/chats/${MOCK_THREAD_ID}']`,
|
||
);
|
||
await expect(sidebarThread).toBeVisible({ timeout: 15_000 });
|
||
await expect(sidebarThread.getByLabel("Feishu channel")).toBeVisible();
|
||
|
||
await page.goto("/workspace/chats");
|
||
|
||
const mainThread = page
|
||
.locator("main")
|
||
.locator(`a[href='/workspace/chats/${MOCK_THREAD_ID}']`);
|
||
await expect(mainThread.getByText("Feishu conversation")).toBeVisible({
|
||
timeout: 15_000,
|
||
});
|
||
await expect(mainThread.getByText("Feishu", { exact: true })).toBeVisible();
|
||
});
|
||
});
|