From 0a58c62c79f61bcc4bce9b18a9db3cf21e3f4c6c Mon Sep 17 00:00:00 2001 From: MT-Mint <798521692@qq.com> Date: Tue, 7 Apr 2026 12:54:01 +0800 Subject: [PATCH] =?UTF-8?q?test(02-01):=20=E5=A2=9E=E5=8A=A0=E7=BA=BF?= =?UTF-8?q?=E7=A8=8B=E4=B8=8E=20skills=20=E5=90=88=E5=90=8C=E5=9B=9E?= =?UTF-8?q?=E5=BD=92=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 node:test 覆盖线程路由与 bootstrap 合同归一 - 更新 e2e 路由辅助与用例,移除 isnew 依赖 --- frontend/src/core/skills/api.test.ts | 34 +++++ frontend/src/core/threads/hooks.test.ts | 30 +++++ frontend/tests/e2e/support/chat-helpers.ts | 117 ++++++++++++++++++ frontend/tests/e2e/thread-routing.spec.ts | 29 +++++ .../tests/e2e/welcome-and-routing.spec.ts | 112 +++++++++++++++++ 5 files changed, 322 insertions(+) create mode 100644 frontend/src/core/skills/api.test.ts create mode 100644 frontend/src/core/threads/hooks.test.ts create mode 100644 frontend/tests/e2e/support/chat-helpers.ts create mode 100644 frontend/tests/e2e/thread-routing.spec.ts create mode 100644 frontend/tests/e2e/welcome-and-routing.spec.ts diff --git a/frontend/src/core/skills/api.test.ts b/frontend/src/core/skills/api.test.ts new file mode 100644 index 00000000..0aecc289 --- /dev/null +++ b/frontend/src/core/skills/api.test.ts @@ -0,0 +1,34 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { normalizeBootstrapRemoteSkillRequest } = await import( + new URL("./normalize-bootstrap.ts", import.meta.url).href +); + +void test("keeps content_ids as primary contract", () => { + const normalized = normalizeBootstrapRemoteSkillRequest({ + thread_id: "t1", + content_ids: [11, 22], + }); + + assert.deepEqual(normalized.content_ids, [11, 22]); +}); + +void test("maps legacy content_id to content_ids for compatibility", () => { + const normalized = normalizeBootstrapRemoteSkillRequest({ + thread_id: "t1", + content_id: 7, + }); + + assert.deepEqual(normalized.content_ids, [7]); +}); + +void test("throws when neither content_ids nor content_id is provided", () => { + assert.throws( + () => + normalizeBootstrapRemoteSkillRequest({ + thread_id: "t1", + }), + /content_ids is required/, + ); +}); diff --git a/frontend/src/core/threads/hooks.test.ts b/frontend/src/core/threads/hooks.test.ts new file mode 100644 index 00000000..7967f87a --- /dev/null +++ b/frontend/src/core/threads/hooks.test.ts @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const { resolveThreadQueryIntent } = await import( + new URL("./utils.ts", import.meta.url).href +); + +void test("uses /chats/new route as the only new-session signal", () => { + const intent = resolveThreadQueryIntent({ + pathThreadId: "new", + queryThreadId: "thread-from-query", + isNewRoute: true, + }); + + assert.equal(intent.isNewThread, true); + assert.equal(intent.showWelcomeStyle, true); + assert.equal(intent.threadId, "thread-from-query"); +}); + +void test("prefers path thread id over query thread id when not on /new", () => { + const intent = resolveThreadQueryIntent({ + pathThreadId: "thread-from-path", + queryThreadId: "thread-from-query", + isNewRoute: false, + }); + + assert.equal(intent.isNewThread, false); + assert.equal(intent.threadId, "thread-from-path"); + assert.equal(intent.invalidNewRoute, false); +}); diff --git a/frontend/tests/e2e/support/chat-helpers.ts b/frontend/tests/e2e/support/chat-helpers.ts new file mode 100644 index 00000000..6065508a --- /dev/null +++ b/frontend/tests/e2e/support/chat-helpers.ts @@ -0,0 +1,117 @@ +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, + xclawUsed, + threadId, +}: { + pathThreadId?: string; + 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("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("xclaw_used", "false"); + return `/workspace/chats/new?${query.toString()}`; +} + +export function newChatEntry(threadId: string) { + return buildChatUrl({ + xclawUsed: false, + threadId, + }); +} + +export function reuseThreadWelcomeEntry(threadId: string) { + return buildChatUrl({ + xclawUsed: false, + threadId, + }); +} + +export function reuseThreadChatEntry(threadId: string) { + return buildChatUrl({ + pathThreadId: threadId, + 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); + } +} diff --git a/frontend/tests/e2e/thread-routing.spec.ts b/frontend/tests/e2e/thread-routing.spec.ts new file mode 100644 index 00000000..09c86aec --- /dev/null +++ b/frontend/tests/e2e/thread-routing.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test"; + +import { + THREAD_FOR_WELCOME, + newChatEntry, + openChat, + reuseThreadChatEntry, + skipIfMissingThread, + waitForMessageListReady, +} from "./support/chat-helpers"; + +test.describe("线程路由(无 isnew)", () => { + test("/new 始终走欢迎态,发送后进入具体 thread 路由", 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(); + }); + + test("/chats/:thread_id 直接复用并渲染历史", 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).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 new file mode 100644 index 00000000..11265d65 --- /dev/null +++ b/frontend/tests/e2e/welcome-and-routing.spec.ts @@ -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("welcome-suggestions")).toBeVisible(); + await expect(page.locator("textarea[name='message']")).toBeVisible(); + }); + + 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\\?.*xclaw_used=false.*thread_id=${THREAD_FOR_WELCOME!}`, + ), + ); + await expect(page.getByTestId("welcome-suggestions")).toBeVisible(); + }); +});