test(frontend): 新增 Playwright E2E 用例并移除 mock 模式测试路径
This commit is contained in:
parent
2f9e682445
commit
84c4931eda
|
|
@ -46,6 +46,8 @@ logs/
|
||||||
sandbox_image_cache.tar
|
sandbox_image_cache.tar
|
||||||
|
|
||||||
frontend/imports
|
frontend/imports
|
||||||
|
frontend/test-results
|
||||||
|
frontend/.cache
|
||||||
|
|
||||||
# ignore the legacy `web` folder
|
# ignore the legacy `web` folder
|
||||||
web/
|
web/
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
"format:write": "prettier --write .",
|
"format:write": "prettier --write .",
|
||||||
"lint": "eslint . --ext .ts,.tsx --ignore-pattern imports/**",
|
"lint": "eslint . --ext .ts,.tsx --ignore-pattern imports/**",
|
||||||
"lint:fix": "eslint . --ext .ts,.tsx --ignore-pattern imports/** --fix",
|
"lint:fix": "eslint . --ext .ts,.tsx --ignore-pattern imports/** --fix",
|
||||||
|
"test:e2e": "playwright test",
|
||||||
|
"test:e2e:ui": "playwright test --ui",
|
||||||
|
"test:e2e:headed": "playwright test --headed",
|
||||||
"preview": "next build && next start",
|
"preview": "next build && next start",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
|
|
@ -93,6 +96,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.3.1",
|
"@eslint/eslintrc": "^3.3.1",
|
||||||
|
"@playwright/test": "^1.48.0",
|
||||||
"@tailwindcss/postcss": "^4.0.15",
|
"@tailwindcss/postcss": "^4.0.15",
|
||||||
"@types/gsap": "^3.0.0",
|
"@types/gsap": "^3.0.0",
|
||||||
"@types/node": "^20.14.10",
|
"@types/node": "^20.14.10",
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
import { defineConfig, devices } from "@playwright/test";
|
||||||
|
import { config as loadEnv } from "dotenv";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
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") });
|
||||||
|
loadEnv({ path: path.resolve(configDir, ".env") });
|
||||||
|
|
||||||
|
const baseURL = process.env.FRONTEND_E2E_BASE_URL ?? "http://127.0.0.1:3000";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 10_000,
|
||||||
|
},
|
||||||
|
fullyParallel: true,
|
||||||
|
retries: process.env.CI ? 1 : 0,
|
||||||
|
reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list",
|
||||||
|
use: {
|
||||||
|
baseURL,
|
||||||
|
trace: "on-first-retry",
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
video: "retain-on-failure",
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: "chromium",
|
||||||
|
use: { ...devices["Desktop Chrome"] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
10800
frontend/pnpm-lock.yaml
10800
frontend/pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -56,7 +56,14 @@ export default function ChatPage() {
|
||||||
setFullscreen: setArtifactsFullscreen,
|
setFullscreen: setArtifactsFullscreen,
|
||||||
fullscreen,
|
fullscreen,
|
||||||
} = useArtifacts();
|
} = useArtifacts();
|
||||||
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } = useThreadChat();
|
const {
|
||||||
|
threadId,
|
||||||
|
isNewThread,
|
||||||
|
setIsNewThread,
|
||||||
|
isMock,
|
||||||
|
showWelcomeStyle,
|
||||||
|
invalidNewRoute,
|
||||||
|
} = useThreadChat();
|
||||||
|
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
// History render rules:
|
// History render rules:
|
||||||
|
|
@ -140,7 +147,7 @@ export default function ChatPage() {
|
||||||
}, [thread.values?.title]);
|
}, [thread.values?.title]);
|
||||||
|
|
||||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||||
const showInputBox = !(showWelcomeStyle && thread.isThreadLoading);
|
const showInputBox = !invalidNewRoute && !(showWelcomeStyle && thread.isThreadLoading);
|
||||||
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
|
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -312,6 +319,7 @@ export default function ChatPage() {
|
||||||
{artifacts?.length > 0 && !artifactsOpen && (
|
{artifacts?.length > 0 && !artifactsOpen && (
|
||||||
<Tooltip content="点击可查看生成的文件结果">
|
<Tooltip content="点击可查看生成的文件结果">
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="artifacts-open-button"
|
||||||
className="text-[#150033] hover:text-[#150033]/80"
|
className="text-[#150033] hover:text-[#150033]/80"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
@ -333,6 +341,26 @@ export default function ChatPage() {
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex size-full justify-center">
|
<div className="flex size-full justify-center">
|
||||||
|
{invalidNewRoute ? (
|
||||||
|
<div className="flex size-full items-center justify-center px-6">
|
||||||
|
<div
|
||||||
|
className="max-w-md rounded-2xl border border-[#E5DDF2] bg-white px-6 py-5 text-center shadow-sm"
|
||||||
|
data-testid="missing-thread-id-state"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
<h2 className="text-base font-semibold text-[#150033]">
|
||||||
|
缺少 thread_id 参数
|
||||||
|
</h2>
|
||||||
|
<p className="mt-2 text-sm text-[#666666]">
|
||||||
|
访问
|
||||||
|
<span className="mx-1 font-mono">/workspace/chats/new</span>
|
||||||
|
时必须显式传入
|
||||||
|
<span className="mx-1 font-mono">?thread_id=...</span>
|
||||||
|
,当前页面不会继续使用本地缓存兜底。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<MessageList
|
<MessageList
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-full",
|
"size-full",
|
||||||
|
|
@ -346,10 +374,10 @@ export default function ChatPage() {
|
||||||
: thread.messages.slice(historyCutoff)
|
: thread.messages.slice(historyCutoff)
|
||||||
}
|
}
|
||||||
paddingBottom={todoListCollapsed ? 160 : 280}
|
paddingBottom={todoListCollapsed ? 160 : 280}
|
||||||
// !showWelcomeStyle || hasSubmitted
|
|
||||||
showScrollToBottomButton={!showWelcomeStyle}
|
showScrollToBottomButton={!showWelcomeStyle}
|
||||||
scrollButtonClassName="bottom-[112px]"
|
scrollButtonClassName="bottom-[112px]"
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -381,6 +409,7 @@ export default function ChatPage() {
|
||||||
<div className="relative flex size-full justify-center px-[20px]">
|
<div className="relative flex size-full justify-center px-[20px]">
|
||||||
<div className="absolute top-2 right-2 z-30">
|
<div className="absolute top-2 right-2 z-30">
|
||||||
<Button
|
<Button
|
||||||
|
data-testid="artifacts-panel-close"
|
||||||
size="icon-sm"
|
size="icon-sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|
|
||||||
|
|
@ -73,11 +73,15 @@ export function ArtifactFileList({
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul className={cn("flex w-full flex-col gap-4", className)}>
|
<ul
|
||||||
|
className={cn("flex w-full flex-col gap-4", className)}
|
||||||
|
data-testid="artifact-file-list"
|
||||||
|
>
|
||||||
{files.map((file) => (
|
{files.map((file) => (
|
||||||
<Card
|
<Card
|
||||||
key={file}
|
key={file}
|
||||||
className="relative cursor-pointer p-3"
|
className="relative cursor-pointer p-3"
|
||||||
|
data-testid="artifact-file-card"
|
||||||
onClick={() => handleClick(file)}
|
onClick={() => handleClick(file)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pr-2 pl-1">
|
<CardHeader className="pr-2 pl-1">
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ export function useThreadChat() {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams<{ thread_id?: string }>();
|
const params = useParams<{ thread_id?: string }>();
|
||||||
|
const isNewRoute = params?.thread_id === "new";
|
||||||
// 兜底:当 params 还未就绪时,从 pathname 解析 thread_id。
|
// 兜底:当 params 还未就绪时,从 pathname 解析 thread_id。
|
||||||
const threadIdFromPathname = (() => {
|
const threadIdFromPathname = (() => {
|
||||||
const parts = pathname.split("?")[0]?.split("/") ?? [];
|
const parts = pathname.split("?")[0]?.split("/") ?? [];
|
||||||
|
|
@ -52,6 +53,8 @@ export function useThreadChat() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const queryThreadIdFromParams = readQueryThreadId();
|
const queryThreadIdFromParams = readQueryThreadId();
|
||||||
|
const hasRequiredThreadId = Boolean(queryThreadIdFromParams);
|
||||||
|
const invalidNewRoute = isNewRoute && !hasRequiredThreadId;
|
||||||
// console.log("[useThreadChat] query.thread_id", queryThreadIdFromParams);
|
// console.log("[useThreadChat] query.thread_id", queryThreadIdFromParams);
|
||||||
// 归一化:当值为 "new" 时,替换为 query 中的 thread_id(如果存在)。
|
// 归一化:当值为 "new" 时,替换为 query 中的 thread_id(如果存在)。
|
||||||
const normalizeThreadId = (value?: string | null) => {
|
const normalizeThreadId = (value?: string | null) => {
|
||||||
|
|
@ -65,7 +68,10 @@ export function useThreadChat() {
|
||||||
// 是否显示欢迎界面:当 xclaw_used=false 或 isnew=true 时显示。
|
// 是否显示欢迎界面:当 xclaw_used=false 或 isnew=true 时显示。
|
||||||
const showWelcomeStyle= searchParams.get("xclaw_used") === "false" || isNewRequested;
|
const showWelcomeStyle= searchParams.get("xclaw_used") === "false" || isNewRequested;
|
||||||
const effectiveThreadIdFromPath =
|
const effectiveThreadIdFromPath =
|
||||||
normalizeThreadId(threadIdFromPath) ?? readStoredThreadId();
|
invalidNewRoute
|
||||||
|
? undefined
|
||||||
|
: normalizeThreadId(threadIdFromPath) ??
|
||||||
|
(isNewRoute ? undefined : readStoredThreadId());
|
||||||
// console.log("[useThreadChat] effectiveThreadIdFromPath", effectiveThreadIdFromPath);
|
// console.log("[useThreadChat] effectiveThreadIdFromPath", effectiveThreadIdFromPath);
|
||||||
|
|
||||||
const [threadId, setThreadId] = useState(() => {
|
const [threadId, setThreadId] = useState(() => {
|
||||||
|
|
@ -84,8 +90,17 @@ export function useThreadChat() {
|
||||||
searchParams.get("isnew")?.trim().toLowerCase() === "true",
|
searchParams.get("isnew")?.trim().toLowerCase() === "true",
|
||||||
);
|
);
|
||||||
// Prefer path thread id, fall back to query thread_id when path is /new.
|
// Prefer path thread id, fall back to query thread_id when path is /new.
|
||||||
setThreadId(normalizeThreadId(threadIdFromPath));
|
setThreadId(
|
||||||
}, [pathname, router, searchParams, threadIdFromPath]);
|
invalidNewRoute ? undefined : normalizeThreadId(threadIdFromPath),
|
||||||
|
);
|
||||||
|
}, [invalidNewRoute, pathname, router, searchParams, threadIdFromPath]);
|
||||||
const isMock = searchParams.get("mock") === "true";
|
const isMock = searchParams.get("mock") === "true";
|
||||||
return { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle };
|
return {
|
||||||
|
threadId,
|
||||||
|
isNewThread,
|
||||||
|
setIsNewThread,
|
||||||
|
isMock,
|
||||||
|
showWelcomeStyle,
|
||||||
|
invalidNewRoute,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -508,6 +508,17 @@ function SuggestionList({
|
||||||
}) {
|
}) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { textInput } = usePromptInputController();
|
const { textInput } = usePromptInputController();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const suggestions =
|
||||||
|
searchParams.get("mode") === "skill"
|
||||||
|
? t.inputBox.suggestions
|
||||||
|
: t.inputBox.suggestionsCreate;
|
||||||
|
const promptSuggestions = suggestions.filter(
|
||||||
|
(
|
||||||
|
suggestion,
|
||||||
|
): suggestion is Exclude<(typeof suggestions)[number], { type: "separator" }> =>
|
||||||
|
!("type" in suggestion),
|
||||||
|
);
|
||||||
|
|
||||||
const handleSuggestionClick = useCallback(
|
const handleSuggestionClick = useCallback(
|
||||||
(suggestion: { prompt: string; skill_id?: string }) => {
|
(suggestion: { prompt: string; skill_id?: string }) => {
|
||||||
|
|
@ -536,8 +547,8 @@ function SuggestionList({
|
||||||
[textInput, sendSelectSkill],
|
[textInput, sendSelectSkill],
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Suggestions className="min-h-16 w-fit items-start">
|
<Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions">
|
||||||
{t.inputBox.suggestions.map((suggestion) => (
|
{promptSuggestions.map((suggestion) => (
|
||||||
<Suggestion
|
<Suggestion
|
||||||
key={suggestion.suggestion}
|
key={suggestion.suggestion}
|
||||||
icon={suggestion.icon}
|
icon={suggestion.icon}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
THREAD_WITH_ARTIFACTS,
|
||||||
|
THREAD_WITH_HTML_ARTIFACT,
|
||||||
|
THREAD_WITH_IMAGE_ARTIFACT,
|
||||||
|
openChat,
|
||||||
|
reuseThreadChatEntry,
|
||||||
|
skipIfMissingThread,
|
||||||
|
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: true });
|
||||||
|
|
||||||
|
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: true });
|
||||||
|
|
||||||
|
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: true });
|
||||||
|
|
||||||
|
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: true });
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,155 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
newChatEntry,
|
||||||
|
openChat,
|
||||||
|
reuseThreadChatEntry,
|
||||||
|
sendMessage,
|
||||||
|
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!));
|
||||||
|
|
||||||
|
await page.getByText(/Webpage|网页/).click();
|
||||||
|
|
||||||
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
await expect(textarea).toHaveValue(/Create a webpage about \[topic\]|生成一个关于\[主题\]的网页/);
|
||||||
|
await expect
|
||||||
|
.poll(async () => {
|
||||||
|
return await textarea.evaluate((element) => {
|
||||||
|
const target = element as HTMLTextAreaElement;
|
||||||
|
const start = target.value.indexOf("[");
|
||||||
|
const end = target.value.indexOf("]") + 1;
|
||||||
|
return {
|
||||||
|
matches:
|
||||||
|
target.selectionStart === start && target.selectionEnd === end,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.toEqual({ matches: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,134 @@
|
||||||
|
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: true });
|
||||||
|
|
||||||
|
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: true });
|
||||||
|
|
||||||
|
const normalizeText = (text: string) => text.replace(/\s+/g, " ").trim();
|
||||||
|
const beforeUsers = (await page.locator(".is-user").allTextContents())
|
||||||
|
.map(normalizeText)
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
expect(beforeUsers.length).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
await page.reload();
|
||||||
|
await expect(page.locator("textarea[name='message']")).toBeVisible();
|
||||||
|
await waitForMessageListReady(page, { requireMessages: true });
|
||||||
|
|
||||||
|
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: true });
|
||||||
|
|
||||||
|
await expect(page.getByRole("button", { name: /To-dos/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,124 @@
|
||||||
|
import { expect, type Page, type TestInfo } from "@playwright/test";
|
||||||
|
|
||||||
|
const rawPrimaryThreadId = process.env.FRONTEND_E2E_THREAD_ID?.trim();
|
||||||
|
|
||||||
|
function envThread(name: string) {
|
||||||
|
return process.env[name]?.trim() || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PRIMARY_THREAD_ID = rawPrimaryThreadId || undefined;
|
||||||
|
export const THREAD_FOR_WELCOME = PRIMARY_THREAD_ID;
|
||||||
|
export const THREAD_WITH_HISTORY = PRIMARY_THREAD_ID;
|
||||||
|
export const THREAD_WITH_MARKDOWN = envThread("FRONTEND_E2E_MARKDOWN_THREAD_ID");
|
||||||
|
export const THREAD_WITH_TODOS = envThread("FRONTEND_E2E_TODOS_THREAD_ID");
|
||||||
|
export const THREAD_WITH_ARTIFACTS = envThread("FRONTEND_E2E_ARTIFACTS_THREAD_ID");
|
||||||
|
export const THREAD_WITH_IMAGE_ARTIFACT = envThread(
|
||||||
|
"FRONTEND_E2E_IMAGE_ARTIFACT_THREAD_ID",
|
||||||
|
);
|
||||||
|
export const THREAD_WITH_HTML_ARTIFACT = envThread(
|
||||||
|
"FRONTEND_E2E_HTML_ARTIFACT_THREAD_ID",
|
||||||
|
);
|
||||||
|
|
||||||
|
export function skipIfMissingThread(
|
||||||
|
testInfo: TestInfo,
|
||||||
|
threadId: string | undefined,
|
||||||
|
label: string,
|
||||||
|
) {
|
||||||
|
testInfo.skip(!threadId, `未配置 ${label}。`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildChatUrl({
|
||||||
|
pathThreadId,
|
||||||
|
isNew,
|
||||||
|
xclawUsed,
|
||||||
|
threadId,
|
||||||
|
}: {
|
||||||
|
pathThreadId?: string;
|
||||||
|
isNew: boolean;
|
||||||
|
xclawUsed: boolean;
|
||||||
|
threadId?: string;
|
||||||
|
}) {
|
||||||
|
const resolvedThreadId = threadId ?? pathThreadId;
|
||||||
|
if (!pathThreadId && !resolvedThreadId) {
|
||||||
|
throw new Error("threadId is required for /workspace/chats/new routes.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("isnew", String(isNew));
|
||||||
|
query.set("xclaw_used", String(xclawUsed));
|
||||||
|
if (resolvedThreadId) {
|
||||||
|
query.set("thread_id", resolvedThreadId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = pathThreadId
|
||||||
|
? `/workspace/chats/${pathThreadId}`
|
||||||
|
: "/workspace/chats/new";
|
||||||
|
return `${basePath}?${query.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidNewChatUrl() {
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set("isnew", "true");
|
||||||
|
query.set("xclaw_used", "false");
|
||||||
|
return `/workspace/chats/new?${query.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newChatEntry(threadId: string) {
|
||||||
|
return buildChatUrl({
|
||||||
|
isNew: true,
|
||||||
|
xclawUsed: false,
|
||||||
|
threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reuseThreadWelcomeEntry(threadId: string) {
|
||||||
|
return buildChatUrl({
|
||||||
|
isNew: false,
|
||||||
|
xclawUsed: false,
|
||||||
|
threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reuseThreadChatEntry(threadId: string) {
|
||||||
|
return buildChatUrl({
|
||||||
|
pathThreadId: threadId,
|
||||||
|
isNew: false,
|
||||||
|
xclawUsed: true,
|
||||||
|
threadId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function openChat(
|
||||||
|
page: Page,
|
||||||
|
url: string,
|
||||||
|
options?: { expectInput?: boolean },
|
||||||
|
) {
|
||||||
|
await page.goto(url);
|
||||||
|
if (options?.expectInput ?? true) {
|
||||||
|
await expect(page.locator("textarea[name='message']")).toBeVisible();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sendMessage(page: Page, text: string) {
|
||||||
|
const textarea = page.locator("textarea[name='message']");
|
||||||
|
const submit = page.locator("button[aria-label='Submit']");
|
||||||
|
await textarea.click();
|
||||||
|
await textarea.fill(text);
|
||||||
|
await submit.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitForMessageListReady(
|
||||||
|
page: Page,
|
||||||
|
options?: { requireMessages?: boolean; minMessages?: number },
|
||||||
|
) {
|
||||||
|
const { requireMessages = false, minMessages = 1 } = options ?? {};
|
||||||
|
await expect(page.getByRole("main").first()).toBeVisible();
|
||||||
|
if (requireMessages) {
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () => await page.locator(".is-user, .is-assistant").count(),
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
)
|
||||||
|
.toBeGreaterThan(minMessages - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
import {
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
invalidNewChatUrl,
|
||||||
|
newChatEntry,
|
||||||
|
openChat,
|
||||||
|
reuseThreadChatEntry,
|
||||||
|
reuseThreadWelcomeEntry,
|
||||||
|
skipIfMissingThread,
|
||||||
|
waitForMessageListReady,
|
||||||
|
} from "./support/chat-helpers";
|
||||||
|
|
||||||
|
test.describe("聊天工作台 / 路由与欢迎态", () => {
|
||||||
|
test("DF-ROUTE-001 /new 带 thread_id 时展示欢迎态与建议词", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
|
await openChat(page, newChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
|
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
|
||||||
|
await expect(page.getByText(/Webpage|网页/)).toBeVisible();
|
||||||
|
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DF-ROUTE-002 /new 复用模式保留欢迎态且不直接渲染历史", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
|
await openChat(page, reuseThreadWelcomeEntry(THREAD_FOR_WELCOME!));
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/workspace/chats/new\\?.*thread_id=${THREAD_FOR_WELCOME!}`),
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
|
||||||
|
await expect(page.locator(".is-user, .is-assistant")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DF-ROUTE-003 /new 缺少 thread_id 时显示无效态并阻止交互", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto(invalidNewChatUrl());
|
||||||
|
|
||||||
|
await expect(page.getByTestId("missing-thread-id-state")).toBeVisible();
|
||||||
|
await expect(page.locator("textarea[name='message']")).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DF-ROUTE-004 线程页直接展示历史消息与标题栏", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
await waitForMessageListReady(page, { requireMessages: true });
|
||||||
|
|
||||||
|
await expect(page.locator(".is-user, .is-assistant").first()).toBeVisible();
|
||||||
|
await expect(page.locator("header button").first()).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DF-ROUTE-005 退出确认取消后保持当前线程页面", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
await waitForMessageListReady(page, { requireMessages: true });
|
||||||
|
|
||||||
|
await page.locator("header button").first().click();
|
||||||
|
await expect(page.getByText("退出后,当前会话结束并销毁")).toBeVisible();
|
||||||
|
await page.getByRole("button", { name: "取消" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(`/workspace/chats/${THREAD_FOR_WELCOME!}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("DF-ROUTE-006 退出确认确认后返回带 thread_id 的 /new", async ({
|
||||||
|
page,
|
||||||
|
}, testInfo) => {
|
||||||
|
skipIfMissingThread(
|
||||||
|
testInfo,
|
||||||
|
THREAD_FOR_WELCOME,
|
||||||
|
"FRONTEND_E2E_THREAD_ID",
|
||||||
|
);
|
||||||
|
await openChat(page, reuseThreadChatEntry(THREAD_FOR_WELCOME!));
|
||||||
|
await waitForMessageListReady(page, { requireMessages: true });
|
||||||
|
|
||||||
|
await page.locator("header button").first().click();
|
||||||
|
await page.getByRole("button", { name: "确定" }).click();
|
||||||
|
|
||||||
|
await expect(page).toHaveURL(
|
||||||
|
new RegExp(
|
||||||
|
`/workspace/chats/new\\?.*isnew=false.*xclaw_used=false.*thread_id=${THREAD_FOR_WELCOME!}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await expect(page.getByTestId("welcome-suggestions")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue