feat(05): harden e2e suite with explainable skip strategy

This commit is contained in:
肖应宇 2026-04-07 14:34:09 +08:00
parent 643b61d15a
commit 981bb8f005
8 changed files with 434 additions and 13 deletions

2
frontend/.gitignore vendored
View File

@ -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 # 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
.env*.local .env*.local
test-results
# vercel # vercel
.vercel .vercel

View File

@ -1,8 +1,9 @@
import { defineConfig, devices } from "@playwright/test";
import { config as loadEnv } from "dotenv";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; 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)); const configDir = path.dirname(fileURLToPath(import.meta.url));
// Load local e2e env defaults from frontend/.env(.local), while keeping shell env highest priority. // Load local e2e env defaults from frontend/.env(.local), while keeping shell env highest priority.
loadEnv({ path: path.resolve(configDir, ".env.local") }); loadEnv({ path: path.resolve(configDir, ".env.local") });

View File

@ -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();
});
});

View File

@ -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);
});
});

View File

@ -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<typeof openChat>[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();
});
});

View File

@ -115,3 +115,15 @@ export async function waitForMessageListReady(
.toBeGreaterThan(minMessages - 1); .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;
}

View File

@ -6,6 +6,7 @@ import {
openChat, openChat,
reuseThreadChatEntry, reuseThreadChatEntry,
skipIfMissingThread, skipIfMissingThread,
waitForAnyMessages,
waitForMessageListReady, waitForMessageListReady,
} from "./support/chat-helpers"; } from "./support/chat-helpers";
@ -21,7 +22,9 @@ test.describe("线程路由(无 isnew", () => {
skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID"); skipIfMissingThread(testInfo, THREAD_FOR_WELCOME, "FRONTEND_E2E_THREAD_ID");
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); 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).toHaveURL(new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`));
await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible(); await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible();

View File

@ -22,8 +22,9 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
); );
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
await expect(page.getByTestId("welcome-suggestions")).toBeVisible(); const suggestions = page.getByTestId("welcome-suggestions");
await expect(page.getByText(/Webpage|网页/)).toBeVisible(); await expect(suggestions).toBeVisible();
await expect(suggestions.locator("button").first()).toBeVisible();
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0); await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
}); });
@ -62,10 +63,14 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
"FRONTEND_E2E_THREAD_ID", "FRONTEND_E2E_THREAD_ID",
); );
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); 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.locator("header button").first()).toBeVisible();
await expect(page.getByTestId("welcome-suggestions")).toHaveCount(0);
}); });
test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({ test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({
@ -77,7 +82,7 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
"FRONTEND_E2E_THREAD_ID", "FRONTEND_E2E_THREAD_ID",
); );
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
await waitForMessageListReady(page, { requireMessages: true }); await waitForMessageListReady(page);
await page.locator("header button").first().click(); await page.locator("header button").first().click();
await expect(page.getByText("退出后,当前会话结束并销毁")).toBeVisible(); await expect(page.getByText("退出后,当前会话结束并销毁")).toBeVisible();
@ -97,15 +102,13 @@ test.describe("聊天工作台 / 路由与欢迎态", () => {
"FRONTEND_E2E_THREAD_ID", "FRONTEND_E2E_THREAD_ID",
); );
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!)); await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
await waitForMessageListReady(page, { requireMessages: true }); await waitForMessageListReady(page);
await page.locator("header button").first().click(); await page.locator("header button").first().click();
await page.getByRole("button", { name: "确定" }).click(); await page.getByRole("button", { name: "确定" }).click();
await expect(page).toHaveURL( await expect(page).toHaveURL(
new RegExp( new RegExp(`/workspace/chats/new\\?.*thread_id=${THREAD_FOR_WELCOME!}`),
`/workspace/chats/new\\?.*xclaw_used=false.*thread_id=${THREAD_FOR_WELCOME!}`,
),
); );
await expect(page.getByTestId("welcome-suggestions")).toBeVisible(); await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
}); });