Files
2026-07-13 11:59:58 +08:00

830 lines
26 KiB
TypeScript

import { expect, test } from "@playwright/test";
import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
test.describe("Chat workspace", () => {
test.beforeEach(async ({ page }) => {
mockLangGraphAPI(page);
});
test("new chat page loads with input box", async ({ 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 expect(page.getByRole("button", { name: /load more/i })).toBeHidden();
});
test("can type a message in the input box", async ({ 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("Hello, DeerFlow!");
await expect(textarea).toHaveValue("Hello, DeerFlow!");
});
test("polishes draft input before sending", async ({ page }) => {
let polishRequest: { text?: string; model_name?: string } | undefined;
let submittedText: string | undefined;
let finishPolish!: () => void;
const polishCanFinish = new Promise<void>((resolve) => {
finishPolish = resolve;
});
await page.route("**/api/input-polish", async (route) => {
polishRequest = route.request().postDataJSON() as {
text?: string;
model_name?: string;
};
await polishCanFinish;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
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("summarize report");
await page.getByTestId("polish-input-button").click();
await expect
.poll(() => polishRequest?.text, { timeout: 10_000 })
.toBe("summarize report");
expect(polishRequest?.model_name).toBeUndefined();
await expect(textarea).toBeDisabled();
await expect(page.getByText("Polishing input...")).toBeVisible();
finishPolish();
await expect(textarea).toHaveValue(
"Please summarize the uploaded report clearly.",
);
await expect(textarea).toBeEnabled();
await expect(page.getByTestId("polish-input-button")).toHaveAccessibleName(
"Undo polish",
);
await textarea.press("Enter");
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe("Please summarize the uploaded report clearly.");
});
test("undoes polished draft from the polish button", async ({ page }) => {
await page.route("**/api/input-polish", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
}),
);
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("summarize report");
await page.getByTestId("polish-input-button").click();
await expect(textarea).toHaveValue(
"Please summarize the uploaded report clearly.",
);
const polishButton = page.getByTestId("polish-input-button");
await expect(polishButton).toHaveAccessibleName("Undo polish");
await polishButton.click();
await expect(textarea).toHaveValue("summarize report");
await expect(polishButton).toHaveAccessibleName("Polish input");
});
test("cancels an in-flight polish request", async ({ page }) => {
// Hold the polish response open so the request stays in flight while we
// exercise the cancel affordance.
let releasePolish!: () => void;
const polishHeld = new Promise<void>((resolve) => {
releasePolish = resolve;
});
await page.route("**/api/input-polish", async (route) => {
await polishHeld;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
rewritten_text: "Please summarize the uploaded report clearly.",
changed: true,
}),
});
});
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("summarize report");
await page.getByTestId("polish-input-button").click();
await expect(page.getByText("Polishing input...")).toBeVisible();
await expect(textarea).toBeDisabled();
await page.getByTestId("cancel-polish-input-button").click();
// Cancelling aborts the request, re-enables the composer, and leaves the
// original draft untouched (no rewrite applied).
await expect(page.getByText("Polishing input...")).toBeHidden();
await expect(textarea).toBeEnabled();
await expect(textarea).toHaveValue("summarize report");
await expect(page.getByTestId("polish-input-button")).toHaveAccessibleName(
"Polish input",
);
releasePolish();
});
test("suggests matching skills after a leading slash", async ({ page }) => {
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
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("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await expect(
page.getByRole("option", { name: /disabled-skill/i }),
).toBeHidden();
await textarea.press("Enter");
await expect(page.getByText("/data-analysis")).toBeVisible();
const skillInput = page.getByRole("textbox", {
name: /how can i assist you/i,
});
await expect(skillInput).toBeVisible();
await skillInput.fill("summarize this dataset");
await skillInput.press("Enter");
await expect
.poll(() => submittedText)
.toBe("/data-analysis summarize this dataset");
});
test("goal command sets a goal and starts an agent run", async ({ page }) => {
let streamCalls = 0;
await page.goto("/workspace/chats/new");
await page.route("**/runs/stream", (route) => {
streamCalls += 1;
return route.fallback();
});
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await textarea.fill("/go");
await expect(page.getByRole("option", { name: /goal/i })).toBeVisible();
await textarea.fill("/goal finish all tests");
await textarea.press("Enter");
await expect(
page.locator("span.font-medium", { hasText: "finish all tests" }),
).toBeVisible();
await expect.poll(() => streamCalls).toBe(1);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible();
});
test("goal command keeps the welcome header clear of the goal status", async ({
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(
"/goal finish a small repo check and report the result",
);
await textarea.press("Enter");
const goal = page.locator("span.font-medium", {
hasText: "finish a small repo check",
});
await expect(goal).toBeVisible();
await expect(page.getByText(/welcome to/i)).toBeHidden();
const overlaps = await page.evaluate(() => {
const welcome = [...document.querySelectorAll("p")].find((el) =>
el.textContent?.toLowerCase().includes("welcome to"),
);
const goal = [...document.querySelectorAll("span")].find((el) =>
el.textContent?.includes(
"finish a small repo check and report the result",
),
);
if (!welcome || !goal) {
return false;
}
const welcomeRect = welcome.getBoundingClientRect();
const goalRect = goal.getBoundingClientRect();
return !(
welcomeRect.right < goalRect.left ||
goalRect.right < welcomeRect.left ||
welcomeRect.bottom < goalRect.top ||
goalRect.bottom < welcomeRect.top
);
});
expect(overlaps).toBe(false);
});
test("uses arrow keys to navigate skill suggestions before prompt history", async ({
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("/");
const dataAnalysis = page.getByRole("option", {
name: /data-analysis/i,
});
const frontendDesign = page.getByRole("option", {
name: /frontend-design/i,
});
await expect(dataAnalysis).toBeVisible();
await expect(frontendDesign).toBeVisible();
await expect(dataAnalysis).toHaveAttribute("aria-selected", "true");
await textarea.press("ArrowDown");
await expect(textarea).toHaveValue("/");
await expect(dataAnalysis).toHaveAttribute("aria-selected", "false");
await expect(frontendDesign).toHaveAttribute("aria-selected", "true");
await textarea.press("ArrowUp");
await expect(textarea).toHaveValue("/");
await expect(dataAnalysis).toHaveAttribute("aria-selected", "true");
await expect(frontendDesign).toHaveAttribute("aria-selected", "false");
await textarea.press("ArrowDown");
await textarea.press("Enter");
await expect(page.getByText("/frontend-design")).toBeVisible();
await expect(
page.getByRole("textbox", { name: /how can i assist you/i }),
).toBeVisible();
});
test("keeps Shift+Enter as newline while skill suggestions are visible", async ({
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("/dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeVisible();
await textarea.press("Shift+Enter");
await expect(textarea).toHaveValue("/dat\n");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
});
test("does not suggest skills for slash text away from the prompt start", async ({
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("please /dat");
await expect(
page.getByRole("option", { name: /data-analysis/i }),
).toBeHidden();
});
test("sending a message triggers API call and shows response", async ({
page,
}) => {
let streamCalled = false;
await page.route("**/runs/stream", (route) => {
streamCalled = true;
return handleRunStream(route);
});
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("Hello");
await textarea.press("Enter");
await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy();
// The AI response should appear in the chat
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("blocks suggestion template placeholders until replaced", async ({
page,
}) => {
let streamCalled = false;
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
streamCalled = true;
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: /research/i }).click();
await expect(textarea).toHaveValue(
"Conduct a deep dive research on [topic], and summarize the findings.",
);
await textarea.press("Enter");
await page.waitForTimeout(500);
expect(streamCalled).toBe(false);
await expect(textarea).toHaveValue(
"Conduct a deep dive research on [topic], and summarize the findings.",
);
await expect
.poll(
() =>
textarea.evaluate((element) => {
const input = element as HTMLTextAreaElement;
return input.value.slice(input.selectionStart, input.selectionEnd);
}),
{ timeout: 5_000 },
)
.toBe("[topic]");
await textarea.pressSequentially("AI agents");
await expect(textarea).toHaveValue(
"Conduct a deep dive research on AI agents, and summarize the findings.",
);
await textarea.press("Enter");
await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy();
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(
"Conduct a deep dive research on AI agents, and summarize the findings.",
);
});
test("slash skill command is submitted as normal chat text", async ({
page,
}) => {
const slashCommand = "/data-analysis analyze uploads/foo.csv";
let submittedText: string | undefined;
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: { messages?: Array<{ content?: unknown }> };
};
const content = body.input?.messages?.at(-1)?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
return handleRunStream(route);
});
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(slashCommand);
await textarea.press("Enter");
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(slashCommand);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("slash skill command with attachment preserves command text and file metadata", async ({
page,
}) => {
const slashCommand = "/data-analysis analyze report.docx";
let uploadCalled = false;
let submittedText: string | undefined;
let submittedFiles:
| Array<{ filename?: string; path?: string; status?: string }>
| undefined;
await page.route("**/api/threads/*/uploads", async (route) => {
uploadCalled = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Uploaded",
files: [
{
filename: "report.docx",
size: 12,
path: "report.docx",
virtual_path: "/mnt/user-data/uploads/report.docx",
artifact_url: "/api/threads/test/uploads/report.docx",
extension: ".docx",
},
],
}),
});
});
await page.route("**/runs/stream", (route) => {
const body = route.request().postDataJSON() as {
input?: {
messages?: Array<{
content?: unknown;
additional_kwargs?: {
files?: Array<{
filename?: string;
path?: string;
status?: string;
}>;
};
}>;
};
};
const message = body.input?.messages?.at(-1);
const content = message?.content;
if (typeof content === "string") {
submittedText = content;
} else if (Array.isArray(content)) {
submittedText = content
.map((block) =>
typeof block === "object" &&
block !== null &&
"text" in block &&
typeof block.text === "string"
? block.text
: "",
)
.join("");
}
submittedFiles = message?.additional_kwargs?.files;
return handleRunStream(route);
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Upload files").setInputFiles({
name: "report.docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
buffer: Buffer.from("fake docx"),
});
await textarea.fill(slashCommand);
await textarea.press("Enter");
await expect.poll(() => uploadCalled, { timeout: 10_000 }).toBeTruthy();
await expect
.poll(() => submittedText, { timeout: 10_000 })
.toBe(slashCommand);
await expect
.poll(() => submittedFiles, { timeout: 10_000 })
.toEqual([
{
filename: "report.docx",
size: 12,
path: "/mnt/user-data/uploads/report.docx",
status: "uploaded",
},
]);
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
});
test("shows gateway upload limits on the attachment entry point", async ({
page,
}) => {
await page.goto("/workspace/chats/new");
const addAttachments = page.getByTestId("add-attachments-button");
await expect(addAttachments).toBeVisible({ timeout: 15_000 });
await addAttachments.hover();
await expect(page.getByRole("tooltip")).toContainText("50 MiB");
await expect(page.getByRole("tooltip")).toContainText("100 MiB");
});
test("rejects an oversized attachment before upload", async ({ page }) => {
let uploadCalled = false;
await page.route("**/api/threads/*/uploads", (route) => {
if (route.request().method() === "POST") {
uploadCalled = true;
}
return route.fallback();
});
await page.route("**/api/threads/*/uploads/limits", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
max_files: 10,
max_file_size: 5,
max_total_size: 20,
}),
}),
);
await page.goto("/workspace/chats/new");
const addAttachments = page.getByTestId("add-attachments-button");
await addAttachments.hover();
await expect(page.getByRole("tooltip")).toContainText("5 B");
await page.getByLabel("Upload files").setInputFiles({
name: "too-large.txt",
mimeType: "text/plain",
buffer: Buffer.from("123456"),
});
await expect(
page.locator("[data-sonner-toast]").filter({ hasText: "too-large.txt" }),
).toBeVisible();
await expect(page.locator("form").getByText("too-large.txt")).toBeHidden();
const textarea = page.locator('textarea[name="message"]');
await textarea.fill("Continue without the rejected attachment");
await textarea.press("Enter");
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
expect(uploadCalled).toBe(false);
});
test("keeps valid attachments in order when the total limit is exceeded", async ({
page,
}) => {
await page.route("**/api/threads/*/uploads/limits", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
max_files: 3,
max_file_size: 10,
max_total_size: 5,
}),
}),
);
await page.goto("/workspace/chats/new");
const addAttachments = page.getByTestId("add-attachments-button");
await addAttachments.hover();
await expect(page.getByRole("tooltip")).toContainText("5 B");
await page.getByLabel("Upload files").setInputFiles([
{
name: "first.txt",
mimeType: "text/plain",
buffer: Buffer.from("1234"),
},
{
name: "over-total.txt",
mimeType: "text/plain",
buffer: Buffer.from("12"),
},
{
name: "second.txt",
mimeType: "text/plain",
buffer: Buffer.from("1"),
},
]);
const promptForm = page.locator("form").filter({
has: page.locator('textarea[name="message"]'),
});
await expect(promptForm.getByText("first.txt")).toBeVisible();
await expect(promptForm.getByText("second.txt")).toBeVisible();
await expect(promptForm.getByText("over-total.txt")).toBeHidden();
await expect(
page.locator("[data-sonner-toast]").filter({ hasText: "5 B" }),
).toBeVisible();
});
test("keeps attachments visible while upload submit is pending", async ({
page,
}) => {
let releaseUpload!: () => void;
const uploadCanFinish = new Promise<void>((resolve) => {
releaseUpload = resolve;
});
let uploadStarted!: () => void;
const uploadStartedPromise = new Promise<void>((resolve) => {
uploadStarted = resolve;
});
await page.route("**/api/threads/*/uploads", async (route) => {
uploadStarted();
await uploadCanFinish;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
success: true,
message: "Uploaded",
files: [
{
filename: "report.docx",
size: 12,
path: "report.docx",
virtual_path: "/mnt/user-data/uploads/report.docx",
artifact_url: "/api/threads/test/uploads/report.docx",
extension: ".docx",
},
],
}),
});
});
await page.goto("/workspace/chats/new");
const textarea = page.getByPlaceholder(/how can i assist you/i);
await expect(textarea).toBeVisible({ timeout: 15_000 });
const promptForm = page.locator("form").filter({ has: textarea });
await page.getByLabel("Upload files").setInputFiles({
name: "report.docx",
mimeType:
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
buffer: Buffer.from("fake docx"),
});
await expect(promptForm.getByText("report.docx")).toBeVisible();
await textarea.fill("Summarize this document");
await textarea.press("Enter");
await uploadStartedPromise;
await expect(promptForm.getByText("report.docx")).toBeVisible();
releaseUpload();
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await expect(promptForm.getByText("report.docx")).toBeHidden();
});
test("does not fetch follow-up suggestions when disabled in config", async ({
page,
}) => {
await page.route("**/api/suggestions/config", (route) => {
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ enabled: false }),
});
});
let suggestionsFetched = false;
await page.route("**/api/threads/*/suggestions", (route) => {
suggestionsFetched = true;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ suggestions: [] }),
});
});
let streamCalled = false;
await page.route("**/runs/stream", (route) => {
streamCalled = true;
return handleRunStream(route);
});
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("Hello");
await textarea.press("Enter");
await expect.poll(() => streamCalled, { timeout: 10_000 }).toBeTruthy();
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
timeout: 10_000,
});
await page.waitForTimeout(1000);
expect(suggestionsFetched).toBe(false);
});
});