346 lines
12 KiB
TypeScript
346 lines
12 KiB
TypeScript
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-008A 提交态附加优先提示但消息区只显示原文", 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']");
|
||
const userInput = "请根据引用文件给出摘要";
|
||
await textarea.fill(`${userInput} `);
|
||
|
||
await openReferencePicker(page);
|
||
const firstReference = page.getByTestId("mention-candidate-item").first();
|
||
await expect(firstReference).toBeVisible();
|
||
const referenceName =
|
||
REFERENCE_UPLOAD_FIXTURES[0]?.filename ?? "fixture-01.md";
|
||
await firstReference.click();
|
||
await expect(page.getByTestId("reference-chip")).toHaveCount(1);
|
||
|
||
const submitRequest = page.waitForRequest(
|
||
(request) =>
|
||
request.method() === "POST" &&
|
||
request
|
||
.url()
|
||
.includes(
|
||
`/api/langgraph/threads/${THREAD_WITH_REFERENCE_FIXTURE}/runs/stream`,
|
||
),
|
||
);
|
||
|
||
await page.locator("button[aria-label='Submit']").click();
|
||
const request = await submitRequest;
|
||
const requestBody = request.postData() ?? "";
|
||
|
||
expect(requestBody).toContain(userInput);
|
||
expect(requestBody).toContain("优先使用【");
|
||
expect(requestBody).toContain(referenceName);
|
||
await expect(
|
||
page.locator(".is-user").filter({ hasText: "优先使用【" }),
|
||
).toHaveCount(0);
|
||
});
|
||
|
||
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();
|
||
});
|
||
});
|