mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-05 20:38:41 +00:00
* feat(frontend): reopen the skill list after a skill is selected Selecting a skill closed the composer's skill list for good: `/` no longer reopened it, so a skill could not be looked up or swapped without deleting the chip first. The list now reopens from the editable text beside the chip, and picking an entry swaps the chip rather than stacking a second activation, since the wire format carries exactly one leading /skill. Builtin commands are withheld in that state because they own the whole composer line, and Enter navigates the list before submitting except while an IME is composing. The trigger is unchanged: a slash still opens the list only at the start of the input. * fix(frontend): keep builtin names reserved in the reopened skill list Withholding the builtin list from getMatchingSkillSuggestions in chip mode also disabled the reserved-name filter it drives, so a custom skill named after a builtin command became selectable there. Nothing rejects such a name at install time, and submitting the resulting chip runs the command instead of the skill. Pass the builtin list as before and drop the builtin entries from the result instead. The new regression covers both sides of the reservation, and the reopen test now waits for the list before pressing Enter. * fix(frontend): hide skills the slash parsers refuse from the picker The composer picker reserved only the two builtin command names, while both slash parsers refuse the seven names in the shared contract. A skill named bootstrap, help, memory, models, new or status was therefore offered, could be selected into a chip, and submitted — and then activated nothing, because parse_slash_skill_reference drops the name on the way in. The turn reached the model as literal text with no skill loaded and no error anywhere. Reserve the contract names alongside the builtin ones, so the picker cannot offer what the parsers will not honour.
1189 lines
37 KiB
TypeScript
1189 lines
37 KiB
TypeScript
import { expect, test } from "@playwright/test";
|
||
|
||
import { handleRunStream, mockLangGraphAPI } from "./utils/mock-api";
|
||
|
||
function textFromMessageContent(content: unknown) {
|
||
if (typeof content === "string") {
|
||
return content;
|
||
}
|
||
if (!Array.isArray(content)) {
|
||
return undefined;
|
||
}
|
||
return content
|
||
.map((block) =>
|
||
typeof block === "object" &&
|
||
block !== null &&
|
||
"text" in block &&
|
||
typeof block.text === "string"
|
||
? block.text
|
||
: "",
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
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("shows the localized AI disclaimer", async ({ page }) => {
|
||
await page.goto("/workspace/chats/new");
|
||
await page.evaluate(() => {
|
||
document.cookie = "locale=zh-CN; path=/; SameSite=Lax";
|
||
});
|
||
await page.reload();
|
||
|
||
await expect(
|
||
page.getByText("内容由AI生成,重要信息请务必核查", { exact: true }),
|
||
).toBeVisible({ timeout: 15_000 });
|
||
});
|
||
|
||
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("restores a draft after reload and clears it after sending", 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("Keep this unfinished draft");
|
||
|
||
await page.reload();
|
||
|
||
const restoredTextarea = page.getByPlaceholder(/how can i assist you/i);
|
||
await expect(restoredTextarea).toHaveValue("Keep this unfinished draft");
|
||
await restoredTextarea.press("Enter");
|
||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||
timeout: 10_000,
|
||
});
|
||
|
||
await page.reload();
|
||
await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue(
|
||
"",
|
||
);
|
||
});
|
||
|
||
test("restores a repeated draft that matches the last sent prompt", 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("Repeat this request");
|
||
await textarea.press("Enter");
|
||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||
timeout: 10_000,
|
||
});
|
||
await expect(textarea).toHaveValue("");
|
||
|
||
await textarea.fill("Repeat this request");
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
|
||
)
|
||
.toContain("Repeat this request");
|
||
|
||
await page.reload();
|
||
await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue(
|
||
"Repeat this request",
|
||
);
|
||
});
|
||
|
||
test("restores a selected slash skill draft after reload", 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 textarea.press("Enter");
|
||
|
||
await expect(page.getByText("/data-analysis")).toBeVisible();
|
||
const skillInput = page.getByRole("textbox", {
|
||
name: /how can i assist you/i,
|
||
});
|
||
await skillInput.fill("Analyze the latest results");
|
||
await expect
|
||
.poll(() =>
|
||
page.evaluate(() => Object.values(window.sessionStorage).join("\n")),
|
||
)
|
||
.toContain("Analyze the latest results");
|
||
|
||
await page.reload();
|
||
|
||
await expect(page.getByText("/data-analysis")).toBeVisible();
|
||
await expect(
|
||
page.getByRole("textbox", {
|
||
name: /how can i assist you/i,
|
||
}),
|
||
).toHaveText("Analyze the latest results");
|
||
});
|
||
|
||
test("continues without draft persistence when sessionStorage is blocked", async ({
|
||
page,
|
||
}) => {
|
||
let submittedText: string | undefined;
|
||
await page.addInitScript(() => {
|
||
const realSessionStorage = window.sessionStorage;
|
||
Reflect.set(window, "__blockComposerDraftStorage", false);
|
||
Object.defineProperty(window, "sessionStorage", {
|
||
configurable: true,
|
||
get() {
|
||
if (Reflect.get(window, "__blockComposerDraftStorage") === true) {
|
||
throw new DOMException("Blocked", "SecurityError");
|
||
}
|
||
return realSessionStorage;
|
||
},
|
||
});
|
||
});
|
||
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;
|
||
submittedText = textFromMessageContent(content);
|
||
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.evaluate(() => {
|
||
Reflect.set(window, "__blockComposerDraftStorage", true);
|
||
});
|
||
await textarea.fill("Send while storage is blocked");
|
||
await textarea.press("Enter");
|
||
|
||
await expect
|
||
.poll(() => submittedText, { timeout: 10_000 })
|
||
.toBe("Send while storage is blocked");
|
||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||
timeout: 10_000,
|
||
});
|
||
});
|
||
|
||
test("does not rewrite an accepted attachment draft from a stale debounce", async ({
|
||
page,
|
||
}) => {
|
||
let releaseUpload!: () => void;
|
||
const uploadHeld = new Promise<void>((resolve) => {
|
||
releaseUpload = resolve;
|
||
});
|
||
let submittedText: string | undefined;
|
||
|
||
await page.route("**/api/threads/*/uploads", async (route) => {
|
||
await uploadHeld;
|
||
return route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
success: true,
|
||
message: "Uploaded",
|
||
files: [
|
||
{
|
||
filename: "notes.txt",
|
||
size: 12,
|
||
path: "notes.txt",
|
||
virtual_path: "/mnt/user-data/uploads/notes.txt",
|
||
artifact_url: "/api/threads/test/uploads/notes.txt",
|
||
extension: ".txt",
|
||
},
|
||
],
|
||
}),
|
||
});
|
||
});
|
||
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;
|
||
submittedText = textFromMessageContent(content);
|
||
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: "notes.txt",
|
||
mimeType: "text/plain",
|
||
buffer: Buffer.from("fake notes"),
|
||
});
|
||
await textarea.fill("Send this immediately");
|
||
await textarea.press("Enter");
|
||
|
||
await page.waitForTimeout(500);
|
||
expect(
|
||
await page.evaluate(() =>
|
||
Object.values(window.sessionStorage).join("\n"),
|
||
),
|
||
).not.toContain("Send this immediately");
|
||
|
||
releaseUpload();
|
||
await expect
|
||
.poll(() => submittedText, { timeout: 10_000 })
|
||
.toBe("Send this immediately");
|
||
await expect(page.getByText("Hello from DeerFlow!")).toBeVisible({
|
||
timeout: 10_000,
|
||
});
|
||
|
||
await page.reload();
|
||
await expect(page.getByPlaceholder(/how can i assist you/i)).toHaveValue(
|
||
"",
|
||
);
|
||
});
|
||
|
||
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("reopens the skill list with a slash after a skill is selected", async ({
|
||
page,
|
||
}) => {
|
||
let submittedText: string | undefined;
|
||
await page.route("**/runs/stream", (route) => {
|
||
const body = route.request().postDataJSON() as {
|
||
input?: { messages?: Array<{ content?: unknown }> };
|
||
};
|
||
submittedText = textFromMessageContent(
|
||
body.input?.messages?.at(-1)?.content,
|
||
);
|
||
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 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.pressSequentially("/");
|
||
|
||
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();
|
||
// Builtin commands own the whole composer line, so they stay out of the
|
||
// list while a skill is selected even though an empty query matches them.
|
||
await expect(page.getByRole("option", { name: /goal/i })).toBeHidden();
|
||
|
||
await skillInput.pressSequentially("fro");
|
||
await expect(frontendDesign).toHaveAttribute("aria-selected", "true");
|
||
|
||
await skillInput.press("Enter");
|
||
|
||
await expect(page.getByText("/frontend-design")).toBeVisible();
|
||
await expect(page.getByText("/data-analysis")).toBeHidden();
|
||
|
||
await skillInput.pressSequentially("polish the composer");
|
||
await skillInput.press("Enter");
|
||
|
||
await expect
|
||
.poll(() => submittedText)
|
||
.toBe("/frontend-design polish the composer");
|
||
});
|
||
|
||
test("does not offer a skill whose name a slash command owns", async ({
|
||
page,
|
||
}) => {
|
||
// Registered after the shared mock, so it wins: nothing rejects these
|
||
// names when the skill is created.
|
||
await page.route("**/api/skills", (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
skills: [
|
||
{
|
||
name: "data-analysis",
|
||
description: "Analyze structured data and produce charts.",
|
||
category: "public",
|
||
enabled: true,
|
||
},
|
||
{
|
||
name: "compact",
|
||
description: "A custom skill named after a builtin command.",
|
||
category: "custom",
|
||
enabled: true,
|
||
},
|
||
{
|
||
name: "status",
|
||
description: "A custom skill named after a reserved command.",
|
||
category: "custom",
|
||
enabled: 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("/comp");
|
||
// Reserved outside chip mode: the builtin is offered, the skill is not.
|
||
await expect(
|
||
page.getByRole("option", { name: /compact/i }),
|
||
).toHaveAccessibleName(/Compact earlier context/i);
|
||
|
||
// A contract-reserved name has no builtin standing in for it, so the list
|
||
// is empty rather than showing a skill both slash parsers would refuse.
|
||
await textarea.fill("/stat");
|
||
await expect(page.getByRole("option", { name: /status/i })).toBeHidden();
|
||
|
||
await textarea.fill("/dat");
|
||
await expect(
|
||
page.getByRole("option", { name: /data-analysis/i }),
|
||
).toBeVisible();
|
||
await textarea.press("Enter");
|
||
await expect(page.getByText("/data-analysis")).toBeVisible();
|
||
|
||
const skillInput = page.getByRole("textbox", {
|
||
name: /how can i assist you/i,
|
||
});
|
||
await skillInput.pressSequentially("/comp");
|
||
|
||
// Reserved in chip mode too. Selecting it would set a `/compact` chip that
|
||
// `parseCompactCommand` intercepts on submit, so context compaction would
|
||
// run instead of the skill.
|
||
await expect(page.getByRole("option", { name: /compact/i })).toBeHidden();
|
||
|
||
await skillInput.fill("/stat");
|
||
await expect(page.getByRole("option", { name: /status/i })).toBeHidden();
|
||
});
|
||
|
||
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);
|
||
});
|
||
});
|