test(02-01): 增加线程与 skills 合同回归测试

- 新增 node:test 覆盖线程路由与 bootstrap 合同归一

- 更新 e2e 路由辅助与用例,移除 isnew 依赖
This commit is contained in:
肖应宇 2026-04-07 12:54:01 +08:00
parent 034e35c880
commit c01ac7b8de
5 changed files with 322 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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