Compare commits
3 Commits
2f9e682445
...
1e981dc459
| Author | SHA1 | Date |
|---|---|---|
|
|
1e981dc459 | |
|
|
13eb9cff9b | |
|
|
84c4931eda |
|
|
@ -46,6 +46,8 @@ logs/
|
|||
sandbox_image_cache.tar
|
||||
|
||||
frontend/imports
|
||||
frontend/test-results
|
||||
frontend/.cache
|
||||
|
||||
# ignore the legacy `web` folder
|
||||
web/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@
|
|||
"format:write": "prettier --write .",
|
||||
"lint": "eslint . --ext .ts,.tsx --ignore-pattern imports/**",
|
||||
"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",
|
||||
"start": "next start",
|
||||
"typecheck": "tsc --noEmit"
|
||||
|
|
@ -93,6 +96,7 @@
|
|||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@playwright/test": "^1.48.0",
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@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,
|
||||
fullscreen,
|
||||
} = useArtifacts();
|
||||
const { threadId, isNewThread, setIsNewThread, isMock, showWelcomeStyle } = useThreadChat();
|
||||
const {
|
||||
threadId,
|
||||
isNewThread,
|
||||
setIsNewThread,
|
||||
isMock,
|
||||
showWelcomeStyle,
|
||||
invalidNewRoute,
|
||||
} = useThreadChat();
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
// History render rules:
|
||||
|
|
@ -140,7 +147,7 @@ export default function ChatPage() {
|
|||
}, [thread.values?.title]);
|
||||
|
||||
const [hasSubmitted, setHasSubmitted] = useState(false);
|
||||
const showInputBox = !(showWelcomeStyle && thread.isThreadLoading);
|
||||
const showInputBox = !invalidNewRoute && !(showWelcomeStyle && thread.isThreadLoading);
|
||||
const [historyCutoff, setHistoryCutoff] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -312,6 +319,7 @@ export default function ChatPage() {
|
|||
{artifacts?.length > 0 && !artifactsOpen && (
|
||||
<Tooltip content="点击可查看生成的文件结果">
|
||||
<Button
|
||||
data-testid="artifacts-open-button"
|
||||
className="text-[#150033] hover:text-[#150033]/80"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
|
|
@ -333,6 +341,26 @@ export default function ChatPage() {
|
|||
)}
|
||||
>
|
||||
<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
|
||||
className={cn(
|
||||
"size-full",
|
||||
|
|
@ -346,10 +374,10 @@ export default function ChatPage() {
|
|||
: thread.messages.slice(historyCutoff)
|
||||
}
|
||||
paddingBottom={todoListCollapsed ? 160 : 280}
|
||||
// !showWelcomeStyle || hasSubmitted
|
||||
showScrollToBottomButton={!showWelcomeStyle}
|
||||
scrollButtonClassName="bottom-[112px]"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
|
@ -381,6 +409,7 @@ export default function ChatPage() {
|
|||
<div className="relative flex size-full justify-center px-[20px]">
|
||||
<div className="absolute top-2 right-2 z-30">
|
||||
<Button
|
||||
data-testid="artifacts-panel-close"
|
||||
size="icon-sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
|
|
|
|||
|
|
@ -593,21 +593,29 @@ function rewriteArtifactImagePaths(content: string, threadId?: string) {
|
|||
return content;
|
||||
}
|
||||
|
||||
const toArtifactUrl = (rawPath: string) => {
|
||||
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
return resolveArtifactURL(normalizedPath, threadId);
|
||||
};
|
||||
|
||||
const markdownRewritten = content.replace(
|
||||
/!\[([^\]]*)\]\(\s*(\/?mnt\/user-data\/outputs\/[^)\s]+)\s*\)/g,
|
||||
(_full, alt, rawPath) => {
|
||||
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
const artifactUrl = resolveArtifactURL(normalizedPath, threadId);
|
||||
return ``;
|
||||
return `})`;
|
||||
},
|
||||
);
|
||||
|
||||
return markdownRewritten.replace(
|
||||
const shorthandMarkdownRewritten = markdownRewritten.replace(
|
||||
/!(?!\[)([^\n()()]+?)\s*[((]\s*(\/?mnt\/user-data\/outputs\/[^)\s)]+)\s*[))]/g,
|
||||
(_full, alt, rawPath) => {
|
||||
return `})`;
|
||||
},
|
||||
);
|
||||
|
||||
return shorthandMarkdownRewritten.replace(
|
||||
/(<img\b[^>]*\bsrc\s*=\s*)(["'])(\/?mnt\/user-data\/outputs\/[^"']+)\2/gi,
|
||||
(_full, prefix, quote, rawPath) => {
|
||||
const normalizedPath = rawPath.startsWith("/") ? rawPath : `/${rawPath}`;
|
||||
const artifactUrl = resolveArtifactURL(normalizedPath, threadId);
|
||||
return `${prefix}${quote}${artifactUrl}${quote}`;
|
||||
return `${prefix}${quote}${toArtifactUrl(rawPath)}${quote}`;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,11 +73,15 @@ export function ArtifactFileList({
|
|||
);
|
||||
|
||||
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) => (
|
||||
<Card
|
||||
key={file}
|
||||
className="relative cursor-pointer p-3"
|
||||
data-testid="artifact-file-card"
|
||||
onClick={() => handleClick(file)}
|
||||
>
|
||||
<CardHeader className="pr-2 pl-1">
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ export function useThreadChat() {
|
|||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const params = useParams<{ thread_id?: string }>();
|
||||
const isNewRoute = params?.thread_id === "new";
|
||||
// 兜底:当 params 还未就绪时,从 pathname 解析 thread_id。
|
||||
const threadIdFromPathname = (() => {
|
||||
const parts = pathname.split("?")[0]?.split("/") ?? [];
|
||||
|
|
@ -52,6 +53,8 @@ export function useThreadChat() {
|
|||
};
|
||||
|
||||
const queryThreadIdFromParams = readQueryThreadId();
|
||||
const hasRequiredThreadId = Boolean(queryThreadIdFromParams);
|
||||
const invalidNewRoute = isNewRoute && !hasRequiredThreadId;
|
||||
// console.log("[useThreadChat] query.thread_id", queryThreadIdFromParams);
|
||||
// 归一化:当值为 "new" 时,替换为 query 中的 thread_id(如果存在)。
|
||||
const normalizeThreadId = (value?: string | null) => {
|
||||
|
|
@ -65,7 +68,10 @@ export function useThreadChat() {
|
|||
// 是否显示欢迎界面:当 xclaw_used=false 或 isnew=true 时显示。
|
||||
const showWelcomeStyle= searchParams.get("xclaw_used") === "false" || isNewRequested;
|
||||
const effectiveThreadIdFromPath =
|
||||
normalizeThreadId(threadIdFromPath) ?? readStoredThreadId();
|
||||
invalidNewRoute
|
||||
? undefined
|
||||
: normalizeThreadId(threadIdFromPath) ??
|
||||
(isNewRoute ? undefined : readStoredThreadId());
|
||||
// console.log("[useThreadChat] effectiveThreadIdFromPath", effectiveThreadIdFromPath);
|
||||
|
||||
const [threadId, setThreadId] = useState(() => {
|
||||
|
|
@ -84,8 +90,17 @@ export function useThreadChat() {
|
|||
searchParams.get("isnew")?.trim().toLowerCase() === "true",
|
||||
);
|
||||
// Prefer path thread id, fall back to query thread_id when path is /new.
|
||||
setThreadId(normalizeThreadId(threadIdFromPath));
|
||||
}, [pathname, router, searchParams, threadIdFromPath]);
|
||||
setThreadId(
|
||||
invalidNewRoute ? undefined : normalizeThreadId(threadIdFromPath),
|
||||
);
|
||||
}, [invalidNewRoute, pathname, router, searchParams, threadIdFromPath]);
|
||||
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 { 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(
|
||||
(suggestion: { prompt: string; skill_id?: string }) => {
|
||||
|
|
@ -536,8 +547,8 @@ function SuggestionList({
|
|||
[textInput, sendSelectSkill],
|
||||
);
|
||||
return (
|
||||
<Suggestions className="min-h-16 w-fit items-start">
|
||||
{t.inputBox.suggestions.map((suggestion) => (
|
||||
<Suggestions className="min-h-16 w-fit items-start" data-testid="welcome-suggestions">
|
||||
{promptSuggestions.map((suggestion) => (
|
||||
<Suggestion
|
||||
key={suggestion.suggestion}
|
||||
icon={suggestion.icon}
|
||||
|
|
|
|||
|
|
@ -120,28 +120,34 @@ export const enUS: Translations = {
|
|||
prompt:
|
||||
"Write an academic paper about [topic], including abstract, introduction, body and references.",
|
||||
icon: PenLineIcon,
|
||||
skill_id: "paper-writing",
|
||||
skill_id: "1245",
|
||||
},
|
||||
{
|
||||
suggestion: "Report Generation",
|
||||
prompt:
|
||||
"Analyze [topic] in depth and generate a well-structured research report.",
|
||||
icon: MicroscopeIcon,
|
||||
skill_id: "report-generation",
|
||||
skill_id: "520",
|
||||
},
|
||||
{
|
||||
suggestion: "Copywriting",
|
||||
prompt:
|
||||
"Create a complete planning proposal and promotional copy for [project/event].",
|
||||
icon: ShapesIcon,
|
||||
skill_id: "planning-copywriting",
|
||||
skill_id: "409",
|
||||
},
|
||||
{
|
||||
suggestion: "Document Processing",
|
||||
prompt:
|
||||
"Process [document] with reading, summarizing, translating or format conversion.",
|
||||
icon: CompassIcon,
|
||||
skill_id: "document-processing",
|
||||
skill_id: "5",
|
||||
},
|
||||
{
|
||||
suggestion: "Market Research",
|
||||
prompt: "TestingTestingTestingTestingTesting",
|
||||
icon: ShapesIcon,
|
||||
skill_id: "1216",
|
||||
},
|
||||
],
|
||||
suggestionsCreate: [
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@ export const zhCN: Translations = {
|
|||
// Welcome
|
||||
welcome: {
|
||||
// TODO: 测试环境标识
|
||||
greeting: "轻办公 · XClaw Tag:v3.1.0 build: 实现真正的执行一次脚本就部署的流程",
|
||||
greeting: "轻办公 · XClaw Tag:v3.1.0 fix:适配md图片的更多情况",
|
||||
description:
|
||||
"欢迎使用 🦌 DeerFlow,一个完全开源的超级智能体。通过内置和自定义的 Skills,\nDeerFlow 可以帮你搜索网络、分析数据,还能为你生成幻灯片、\n图片、视频、播客及网页等,几乎可以做任何事情。",
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ export const zhCN: Translations = {
|
|||
prompt:
|
||||
"为[主题/产品]撰写吸引人的自媒体文案,包括标题、正文和话题标签。",
|
||||
icon: PenLineIcon,
|
||||
skill_id: "431",
|
||||
skill_id: "1245",
|
||||
},
|
||||
{
|
||||
suggestion: "需求文档",
|
||||
|
|
@ -141,7 +141,7 @@ export const zhCN: Translations = {
|
|||
suggestion: "市场调研",
|
||||
prompt: "针对[行业/产品]进行市场调研,分析市场规模、竞品和趋势。",
|
||||
icon: ShapesIcon,
|
||||
skill_id: "30",
|
||||
skill_id: "1216",
|
||||
},
|
||||
],
|
||||
suggestionsCreate: [
|
||||
|
|
|
|||
|
|
@ -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