From 00e0d6cd87eba03fb2737bf70a64eab4d564f61e Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Tue, 7 Apr 2026 14:34:09 +0800 Subject: [PATCH] feat(05): harden e2e suite with explainable skip strategy --- frontend/.gitignore | 2 +- frontend/playwright.config.ts | 5 +- .../e2e/artifacts-and-thread-reuse.spec.ts | 120 +++++++++++++++ frontend/tests/e2e/input-and-compose.spec.ts | 144 ++++++++++++++++++ .../tests/e2e/message-and-history.spec.ts | 138 +++++++++++++++++ frontend/tests/e2e/support/chat-helpers.ts | 12 ++ frontend/tests/e2e/thread-routing.spec.ts | 5 +- .../tests/e2e/welcome-and-routing.spec.ts | 21 +-- 8 files changed, 434 insertions(+), 13 deletions(-) create mode 100644 frontend/tests/e2e/artifacts-and-thread-reuse.spec.ts create mode 100644 frontend/tests/e2e/input-and-compose.spec.ts create mode 100644 frontend/tests/e2e/message-and-history.spec.ts diff --git a/frontend/.gitignore b/frontend/.gitignore index 1a7cd2fd..1b245118 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -38,7 +38,7 @@ yarn-error.log* # do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables .env .env*.local - +test-results # vercel .vercel diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 8712b915..d0ec5f87 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -1,8 +1,9 @@ -import { defineConfig, devices } from "@playwright/test"; -import { config as loadEnv } from "dotenv"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { defineConfig, devices } from "@playwright/test"; +import { config as loadEnv } from "dotenv"; + const configDir = path.dirname(fileURLToPath(import.meta.url)); // Load local e2e env defaults from frontend/.env(.local), while keeping shell env highest priority. loadEnv({ path: path.resolve(configDir, ".env.local") }); diff --git a/frontend/tests/e2e/artifacts-and-thread-reuse.spec.ts b/frontend/tests/e2e/artifacts-and-thread-reuse.spec.ts new file mode 100644 index 00000000..739f1af5 --- /dev/null +++ b/frontend/tests/e2e/artifacts-and-thread-reuse.spec.ts @@ -0,0 +1,120 @@ +import { expect, test } from "@playwright/test"; + +import { + THREAD_WITH_ARTIFACTS, + THREAD_WITH_HTML_ARTIFACT, + THREAD_WITH_IMAGE_ARTIFACT, + openChat, + reuseThreadChatEntry, + skipIfMissingThread, + waitForAnyMessages, + waitForMessageListReady, +} from "./support/chat-helpers"; + +test.describe("聊天工作台 / Artifact 面板", () => { + test("DF-ART-001 含 artifacts 的线程展示入口并可打开文件列表", async ({ + page, + }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_ARTIFACTS, + "FRONTEND_E2E_ARTIFACTS_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_ARTIFACTS!)); + await waitForMessageListReady(page, { requireMessages: false }); + const messageCount = await waitForAnyMessages(page); + testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。"); + testInfo.skip( + (await page.getByTestId("artifacts-open-button").count()) === 0, + "当前线程未展示 artifacts 入口。", + ); + + await expect(page.getByTestId("artifacts-open-button")).toBeVisible(); + await page.getByTestId("artifacts-open-button").click(); + await expect(page.getByTestId("artifact-file-list").first()).toBeVisible(); + }); + + test("DF-ART-002 可打开图片 artifact 详情", async ({ page }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_IMAGE_ARTIFACT, + "FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_IMAGE_ARTIFACT!)); + await waitForMessageListReady(page, { requireMessages: false }); + const messageCount = await waitForAnyMessages(page); + testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。"); + testInfo.skip( + (await page.getByTestId("artifacts-open-button").count()) === 0, + "当前线程未展示 artifacts 入口。", + ); + + await page.getByTestId("artifacts-open-button").click(); + const imageFile = page + .locator("[data-testid='artifact-file-list'] [data-testid='artifact-file-card']") + .filter({ hasText: /\.(png|jpe?g|gif|webp|svg)/i }) + .first(); + testInfo.skip( + (await imageFile.count()) === 0, + "当前线程没有可预览的图片 artifact。", + ); + await imageFile.click(); + + await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible(); + }); + + test("DF-ART-003 可打开 HTML artifact 详情", async ({ page }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_HTML_ARTIFACT, + "FRONTEND_E2E_HTML_ARTIFACT_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_HTML_ARTIFACT!)); + await waitForMessageListReady(page, { requireMessages: false }); + const messageCount = await waitForAnyMessages(page); + testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。"); + testInfo.skip( + (await page.getByTestId("artifacts-open-button").count()) === 0, + "当前线程未展示 artifacts 入口。", + ); + + await page.getByTestId("artifacts-open-button").click(); + const htmlFile = page + .locator("[data-testid='artifact-file-list'] [data-testid='artifact-file-card']") + .filter({ hasText: /\.html?/i }) + .first(); + testInfo.skip( + (await htmlFile.count()) === 0, + "当前线程没有 HTML artifact。", + ); + await htmlFile.click(); + + await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible(); + }); + + test("DF-ART-004 关闭 artifact 面板后恢复聊天主视图", async ({ + page, + }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_ARTIFACTS, + "FRONTEND_E2E_ARTIFACTS_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_ARTIFACTS!)); + await waitForMessageListReady(page, { requireMessages: false }); + const messageCount = await waitForAnyMessages(page); + testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。"); + testInfo.skip( + (await page.getByTestId("artifacts-open-button").count()) === 0, + "当前线程未展示 artifacts 入口。", + ); + + await page.getByTestId("artifacts-open-button").click(); + await expect(page.getByTestId("artifact-file-list").first()).toBeVisible(); + + await page.getByTestId("artifacts-panel-close").click(); + + await expect(page.getByTestId("artifacts-open-button")).toBeVisible(); + await expect(page.getByRole("log").first()).toBeVisible(); + }); +}); diff --git a/frontend/tests/e2e/input-and-compose.spec.ts b/frontend/tests/e2e/input-and-compose.spec.ts new file mode 100644 index 00000000..e1288ec5 --- /dev/null +++ b/frontend/tests/e2e/input-and-compose.spec.ts @@ -0,0 +1,144 @@ +import { expect, test } from "@playwright/test"; + +import { + THREAD_FOR_WELCOME, + newChatEntry, + openChat, + reuseThreadChatEntry, + skipIfMissingThread, +} from "./support/chat-helpers"; + +test.describe("聊天工作台 / 输入区与发送", () => { + 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); + }); +}); diff --git a/frontend/tests/e2e/message-and-history.spec.ts b/frontend/tests/e2e/message-and-history.spec.ts new file mode 100644 index 00000000..1ef0fb9f --- /dev/null +++ b/frontend/tests/e2e/message-and-history.spec.ts @@ -0,0 +1,138 @@ +import { expect, test } from "@playwright/test"; + +import { + THREAD_WITH_HISTORY, + THREAD_WITH_MARKDOWN, + THREAD_WITH_TODOS, + openChat, + reuseThreadChatEntry, + skipIfMissingThread, + waitForMessageListReady, +} from "./support/chat-helpers"; + +async function waitForAnyMessages(page: Parameters[0], timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const count = await page.locator(".is-user, .is-assistant").count(); + if (count > 0) { + return count; + } + await page.waitForTimeout(300); + } + return 0; +} + +test.describe("聊天工作台 / 消息区与历史", () => { + test("DF-MSG-001 固定 fixture 历史消息可见", async ({ page }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_HISTORY, + "FRONTEND_E2E_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_HISTORY!)); + await waitForMessageListReady(page, { requireMessages: false }); + const messageCount = await waitForAnyMessages(page); + testInfo.skip(messageCount === 0, "当前历史线程没有可见消息。"); + + await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible(); + }); + + test("DF-MSG-002 Markdown 消息结构可见", async ({ page }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_MARKDOWN, + "FRONTEND_E2E_MARKDOWN_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_MARKDOWN!)); + await waitForMessageListReady(page, { requireMessages: false }); + + const messageCount = await waitForAnyMessages(page); + testInfo.skip( + messageCount === 0, + "当前线程没有可用于断言 Markdown 结构的历史消息。", + ); + + const markdownCandidates = page.locator( + ".is-assistant strong, .is-assistant ul li, .is-assistant ol li, .is-assistant code", + ); + await expect(markdownCandidates.first()).toBeVisible(); + }); + + test("DF-MSG-003 长历史线程中可出现滚动到底部按钮", async ({ + page, + }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_HISTORY, + "FRONTEND_E2E_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_HISTORY!)); + await waitForMessageListReady(page, { requireMessages: false }); + const messageCount = await waitForAnyMessages(page); + testInfo.skip(messageCount === 0, "当前历史线程没有可见消息。"); + + const messageLog = page.getByRole("log").first(); + const canScroll = await messageLog.evaluate((element) => { + const target = element as HTMLElement; + return target.scrollHeight - target.clientHeight > 20; + }); + testInfo.skip(canScroll === false, "当前线程消息区高度不足,无法触发滚动到底部按钮。"); + + await messageLog.hover(); + await page.mouse.wheel(0, -1200); + await messageLog.evaluate((element) => { + const target = element as HTMLElement; + target.scrollTop = Math.max(0, target.scrollTop - 1200); + }); + + await expect(page.getByTitle("滚动到底部")).toBeVisible(); + }); + + test("DF-MSG-004 刷新前后用户消息关键内容保持一致", async ({ + page, + }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_HISTORY, + "FRONTEND_E2E_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_HISTORY!)); + await waitForMessageListReady(page, { requireMessages: false }); + + const normalizeText = (text: string) => text.replace(/\s+/g, " ").trim(); + const beforeUsers = (await page.locator(".is-user").allTextContents()) + .map(normalizeText) + .filter(Boolean); + + testInfo.skip(beforeUsers.length === 0, "当前历史线程没有可见用户消息。"); + + await page.reload(); + await expect(page.locator("textarea[name='message']")).toBeVisible(); + await waitForMessageListReady(page, { requireMessages: false }); + + const afterUsers = (await page.locator(".is-user").allTextContents()) + .map(normalizeText) + .filter(Boolean); + + expect(afterUsers.length).toBe(beforeUsers.length); + for (const sample of beforeUsers.slice(0, Math.min(3, beforeUsers.length))) { + expect(afterUsers.some((text) => text.includes(sample))).toBeTruthy(); + } + }); + + test("DF-MSG-005 含 todos 的线程显示 To-dos 入口", async ({ + page, + }, testInfo) => { + skipIfMissingThread( + testInfo, + THREAD_WITH_TODOS, + "FRONTEND_E2E_TODOS_THREAD_ID", + ); + await openChat(page, reuseThreadChatEntry(THREAD_WITH_TODOS!)); + await waitForMessageListReady(page, { requireMessages: false }); + + const todoButton = page.getByRole("button", { name: /To-dos/i }); + testInfo.skip((await todoButton.count()) === 0, "当前线程未展示 To-dos 入口。"); + await expect(todoButton).toBeVisible(); + }); +}); diff --git a/frontend/tests/e2e/support/chat-helpers.ts b/frontend/tests/e2e/support/chat-helpers.ts index 6065508a..0eaf1c25 100644 --- a/frontend/tests/e2e/support/chat-helpers.ts +++ b/frontend/tests/e2e/support/chat-helpers.ts @@ -115,3 +115,15 @@ export async function waitForMessageListReady( .toBeGreaterThan(minMessages - 1); } } + +export async function waitForAnyMessages(page: Page, timeoutMs = 15_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const count = await page.locator(".is-user, .is-assistant").count(); + if (count > 0) { + return count; + } + await page.waitForTimeout(300); + } + return 0; +} diff --git a/frontend/tests/e2e/thread-routing.spec.ts b/frontend/tests/e2e/thread-routing.spec.ts index 09c86aec..1e1b0408 100644 --- a/frontend/tests/e2e/thread-routing.spec.ts +++ b/frontend/tests/e2e/thread-routing.spec.ts @@ -6,6 +6,7 @@ import { openChat, reuseThreadChatEntry, skipIfMissingThread, + waitForAnyMessages, waitForMessageListReady, } from "./support/chat-helpers"; @@ -21,7 +22,9 @@ test.describe("线程路由(无 isnew)", () => { skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID"); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); - await waitForMessageListReady(page, { requireMessages: true }); + await waitForMessageListReady(page, { requireMessages: false }); + const messageCount = await waitForAnyMessages(page); + testInfo.skip(messageCount === 0, "当前线程没有可见历史消息。"); await expect(page).toHaveURL(new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`)); await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible(); diff --git a/frontend/tests/e2e/welcome-and-routing.spec.ts b/frontend/tests/e2e/welcome-and-routing.spec.ts index 11265d65..f7835ef0 100644 --- a/frontend/tests/e2e/welcome-and-routing.spec.ts +++ b/frontend/tests/e2e/welcome-and-routing.spec.ts @@ -22,8 +22,9 @@ test.describe("聊天工作台 / 路由与欢迎态", () => { ); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); - await expect(page.getByTestId("welcome-suggestions")).toBeVisible(); - await expect(page.getByText(/Webpage|网页/)).toBeVisible(); + const suggestions = page.getByTestId("welcome-suggestions"); + await expect(suggestions).toBeVisible(); + await expect(suggestions.locator("button").first()).toBeVisible(); await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0); }); @@ -62,10 +63,14 @@ test.describe("聊天工作台 / 路由与欢迎态", () => { "FRONTEND_E2E_THREAD_ID", ); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); - await waitForMessageListReady(page, { requireMessages: true }); + await waitForMessageListReady(page); - await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible(); + await expect(page).toHaveURL( + new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`), + ); + await expect(page.getByRole("log").first()).toBeVisible(); await expect(page.locator("header button").first()).toBeVisible(); + await expect(page.getByTestId("welcome-suggestions")).toHaveCount(0); }); test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({ @@ -77,7 +82,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => { "FRONTEND_E2E_THREAD_ID", ); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); - await waitForMessageListReady(page, { requireMessages: true }); + await waitForMessageListReady(page); await page.locator("header button").first().click(); await expect(page.getByText("退出后,当前会话结束并销毁")).toBeVisible(); @@ -97,15 +102,13 @@ test.describe("聊天工作台 / 路由与欢迎态", () => { "FRONTEND_E2E_THREAD_ID", ); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); - await waitForMessageListReady(page, { requireMessages: true }); + await waitForMessageListReady(page); await page.locator("header button").first().click(); await page.getByRole("button", { name: "确定" }).click(); await expect(page).toHaveURL( - new RegExp( - `/workspace/chats/new\\?.*xclaw_used=false.*thread_id=${THREAD_FOR_WELCOME!}`, - ), + new RegExp(`/workspace/chats/new\\?.*thread_id=${THREAD_FOR_WELCOME!}`), ); await expect(page.getByTestId("welcome-suggestions")).toBeVisible(); });