deerflow2/frontend/tests/e2e/input-and-compose.spec.ts

299 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { expect, test } from "@playwright/test";
import {
THREAD_FOR_WELCOME,
THREAD_WITH_REFERENCE_FIXTURE,
THREAD_WITH_STALE_REFERENCE,
collectMentionCandidateKeys,
expandComposer,
mentionCandidateByKey,
newChatEntry,
openChat,
openReferencePicker,
rewriteFirstReferenceAsArtifact,
reuseThreadChatEntry,
skipIfMissingThread,
stubReferenceFixtures,
toastByText,
} from "./support/chat-helpers";
const REFERENCE_ARTIFACT_PATH = "/generated/reference-contract.md";
const REFERENCE_UPLOAD_FIXTURES = Array.from({ length: 11 }, (_, index) => {
const fileNumber = String(index + 1).padStart(2, "0");
return {
filename: `fixture-${fileNumber}.md`,
virtual_path: `/mnt/user-data/uploads/fixture-${fileNumber}.md`,
};
});
test.describe("聊天工作台 / 输入区与发送", () => {
test.describe.configure({ mode: "serial" });
test("DF-INPUT-001 欢迎态输入框默认展开", async ({ page }, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']");
await expect
.poll(async () => {
return await textarea.evaluate((element) => {
return Math.round(
(element as HTMLTextAreaElement).getBoundingClientRect().height,
);
});
})
.toBeGreaterThan(120);
});
test("DF-INPUT-002 非欢迎态输入框可展开并在失焦后收起", async ({
page,
}, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']");
const inputHeight = async () =>
await textarea.evaluate((element) => {
return Math.round(
(element as HTMLTextAreaElement).getBoundingClientRect().height,
);
});
await expect.poll(inputHeight).toBeLessThan(110);
await page.locator("div.absolute.inset-0.z-1.cursor-text").click();
await expect.poll(inputHeight).toBeGreaterThan(120);
await page
.getByRole("main")
.first()
.click({ position: { x: 20, y: 20 } });
await expect.poll(inputHeight).toBeLessThan(110);
});
test("DF-INPUT-003 点击欢迎态建议词不会导致输入区异常", async ({
page,
}, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const suggestions = page.getByTestId("welcome-suggestions");
await expect(suggestions).toBeVisible();
await suggestions.locator("button").first().click();
const textarea = page.locator("textarea[name='message']");
await expect(textarea).toBeVisible();
await expect(page.locator(".is-user")).toHaveCount(0);
});
test("DF-INPUT-004 空消息不可提交", async ({ page }, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']");
const submit = page.locator("button[aria-label='Submit']");
await textarea.fill(" ");
await expect(submit).toBeDisabled();
await expect(page.locator(".is-user")).toHaveCount(0);
});
test("DF-INPUT-005 发送后输入框清空且只产生一条用户消息", async ({
page,
}, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']");
const submit = page.locator("button[aria-label='Submit']");
await textarea.click();
await textarea.fill("你好,测试发送");
await submit.evaluate((button) => {
(button as HTMLButtonElement).click();
});
await expect(
page.locator(".is-user").filter({ hasText: /你好,测试发送|测试发送/ }),
).toHaveCount(1);
await expect(textarea).toHaveValue("");
});
test("DF-INPUT-006 快速重复点击发送不会重复提交", async ({
page,
}, testInfo) => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
const textarea = page.locator("textarea[name='message']");
const submit = page.locator("button[aria-label='Submit']");
await textarea.fill("重复提交测试");
await submit.evaluate((button) => {
const target = button as HTMLButtonElement;
target.click();
target.click();
target.click();
});
await expect(
page.locator(".is-user").filter({ hasText: "重复提交测试" }),
).toHaveCount(1);
});
test("DF-INPUT-007 输入@时展示文件候选并可选择为引用 chip", async (
{ page },
testInfo,
) => {
skipIfMissingThread(
testInfo,
THREAD_WITH_REFERENCE_FIXTURE,
"FRONTEND_E2E_ARTIFACTS_THREAD_ID 或 FRONTEND_E2E_THREAD_ID",
);
await stubReferenceFixtures(page, {
threadId: THREAD_WITH_REFERENCE_FIXTURE!,
artifactPaths: [REFERENCE_ARTIFACT_PATH],
uploadFiles: REFERENCE_UPLOAD_FIXTURES,
});
await openChat(page, reuseThreadChatEntry(THREAD_WITH_REFERENCE_FIXTURE!));
await expandComposer(page);
const textarea = page.locator("textarea[name='message']");
await textarea.fill("请基于这个文件回答 ");
const { panel } = await openReferencePicker(page);
const items = page.getByTestId("mention-candidate-item");
const itemCount = await items.count();
testInfo.skip(itemCount === 0, "当前线程没有可引用文件候选。");
await items.first().click();
await expect(textarea).toBeFocused();
await expect(textarea).toHaveValue(/请基于这个文件回答/);
await expect(page.getByTestId("reference-inline-preview")).toBeVisible();
await expect(page.getByTestId("reference-chip")).toHaveCount(1);
await expect(page.getByTestId("reference-chip-remove")).toHaveCount(1);
await expect(panel).toBeHidden();
});
test("DF-INPUT-008 失效引用不会阻断文本发送(可解释 skip", async (
{ page },
testInfo,
) => {
skipIfMissingThread(
testInfo,
THREAD_WITH_STALE_REFERENCE,
"FRONTEND_E2E_ARTIFACTS_THREAD_ID 或 FRONTEND_E2E_THREAD_ID",
);
await stubReferenceFixtures(page, {
threadId: THREAD_WITH_STALE_REFERENCE!,
artifactPaths: [REFERENCE_ARTIFACT_PATH],
uploadFiles: REFERENCE_UPLOAD_FIXTURES,
});
await openChat(page, reuseThreadChatEntry(THREAD_WITH_STALE_REFERENCE!));
await expandComposer(page);
const textarea = page.locator("textarea[name='message']");
await textarea.fill("stale 引用回归验证 ");
await openReferencePicker(page);
const uploadItem = page
.locator(
'[data-testid="mention-candidate-item"][data-candidate-key^="upload:"]',
)
.first();
await expect(uploadItem).toBeVisible();
await uploadItem.click();
await expect(page.getByTestId("reference-chip")).toHaveCount(1);
const stalePath = REFERENCE_ARTIFACT_PATH;
let staleArtifactRequested = false;
await rewriteFirstReferenceAsArtifact(page, stalePath);
await expect(page.getByTestId("reference-chip").first()).toContainText(
"生成文件",
);
await page.route(
new RegExp(
`/api/threads/${THREAD_WITH_STALE_REFERENCE}/artifacts${stalePath.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}(\\?.*)?$`,
),
async (route) => {
staleArtifactRequested = true;
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "e2e stale artifact" }),
});
},
{ times: 1 },
);
const submitRequest = page.waitForRequest(
(request) =>
request.method() === "POST" &&
request
.url()
.includes(`/api/langgraph/threads/${THREAD_WITH_STALE_REFERENCE}/runs/stream`),
);
await page.locator("button[aria-label='Submit']").click();
await expect.poll(() => staleArtifactRequested).toBe(true);
await submitRequest;
await expect(
toastByText(page, "部分引用文件已失效,已自动移除并继续发送。"),
).toBeVisible();
await expect(page.locator("textarea[name='message']")).toHaveValue("");
});
test("DF-INPUT-009 引用上限为 10第 11 个被阻止并提示", async (
{ page },
testInfo,
) => {
skipIfMissingThread(
testInfo,
THREAD_WITH_REFERENCE_FIXTURE,
"FRONTEND_E2E_ARTIFACTS_THREAD_ID 或 FRONTEND_E2E_THREAD_ID",
);
await stubReferenceFixtures(page, {
threadId: THREAD_WITH_REFERENCE_FIXTURE!,
artifactPaths: [REFERENCE_ARTIFACT_PATH],
uploadFiles: REFERENCE_UPLOAD_FIXTURES,
});
await openChat(page, reuseThreadChatEntry(THREAD_WITH_REFERENCE_FIXTURE!));
await expandComposer(page);
const textarea = page.locator("textarea[name='message']");
await textarea.fill("请参考这些文件 ");
await openReferencePicker(page);
const candidateKeys = await collectMentionCandidateKeys(page);
testInfo.skip(
candidateKeys.length < 11,
"当前线程候选文件不足 11 个,无法验证 10 个上限。",
);
for (const [index, key] of candidateKeys.slice(0, 10).entries()) {
await openReferencePicker(page);
const candidate = mentionCandidateByKey(page, key);
await expect(candidate).toBeVisible();
await candidate.evaluate((element) => {
(element as HTMLElement).click();
});
await expect(page.getByTestId("reference-chip-remove")).toHaveCount(
index + 1,
);
}
await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10);
await openReferencePicker(page);
const blockedCandidate = mentionCandidateByKey(page, candidateKeys[10]!);
await expect(blockedCandidate).toBeVisible();
await blockedCandidate.evaluate((element) => {
(element as HTMLElement).click();
});
await expect(page.getByTestId("reference-chip-remove")).toHaveCount(10);
await expect(
toastByText(page, "单条消息最多引用 10 个文件"),
).toBeVisible();
});
});