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("XClaw优先使用【"); expect(requestBody).toContain(referenceName); await expect( page.locator(".is-user").filter({ hasText: "XClaw优先使用【" }), ).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(); }); });