deerflow2/frontend/tests/e2e/support/chat-helpers.ts

339 lines
8.9 KiB
TypeScript

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 const THREAD_WITH_REFERENCE_FIXTURE =
THREAD_WITH_ARTIFACTS ?? PRIMARY_THREAD_ID;
export const THREAD_WITH_STALE_REFERENCE =
THREAD_WITH_ARTIFACTS ?? PRIMARY_THREAD_ID;
export function skipIfMissingThread(
testInfo: TestInfo,
threadId: string | undefined,
label: string,
) {
testInfo.skip(!threadId, `未配置 ${label}`);
}
export function buildChatUrl({
pathThreadId,
isChatting,
threadId,
}: {
pathThreadId?: string;
isChatting: 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("is_chatting", String(isChatting));
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("is_chatting", "false");
return `/workspace/chats/new?${query.toString()}`;
}
export function newChatEntry(threadId: string) {
return buildChatUrl({
isChatting: false,
threadId,
});
}
export function reuseThreadWelcomeEntry(threadId: string) {
return buildChatUrl({
isChatting: false,
threadId,
});
}
export function reuseThreadChatEntry(threadId: string) {
return buildChatUrl({
pathThreadId: threadId,
isChatting: 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 expandComposer(page: Page) {
const expander = page.locator("div.absolute.inset-0.z-1.cursor-text");
if ((await expander.count()) > 0) {
await expander.first().click();
}
}
export async function openReferencePicker(page: Page, value = "@") {
const textarea = page.locator("textarea[name='message']");
const panel = page.getByTestId("mention-candidate-panel").first();
await textarea.focus();
for (let attempt = 0; attempt < 3; attempt += 1) {
await textarea.type(value);
const visible = await panel.isVisible().catch(() => false);
if (visible) {
await page.waitForTimeout(100);
return { textarea, panel };
}
// Reset the transient token and retry when dropdown opening lags in fast loops.
await textarea.press("Backspace");
await page.waitForTimeout(100);
}
await expect(panel).toBeVisible();
return { textarea, panel };
}
export async function collectMentionCandidateKeys(page: Page) {
const items = page.getByTestId("mention-candidate-item");
const count = await items.count();
const keys: string[] = [];
for (let index = 0; index < count; index += 1) {
const key = await items.nth(index).getAttribute("data-candidate-key");
if (key) {
keys.push(key);
}
}
return keys;
}
export function mentionCandidateByKey(page: Page, key: string) {
return page.locator(
`[data-testid="mention-candidate-item"][data-candidate-key=${JSON.stringify(key)}]`,
);
}
export function toastByText(page: Page, text: string) {
return page.locator("[data-sonner-toast]").filter({ hasText: text }).first();
}
export async function rewriteFirstReferenceAsArtifact(
page: Page,
artifactPath: string,
) {
const textarea = page.locator("textarea[name='message']");
const rewritten = await textarea.evaluate((element, nextArtifactPath) => {
const fiberKey = Object.keys(element).find((key) =>
key.startsWith("__reactFiber$"),
);
if (!fiberKey) {
return false;
}
let fiber = ((element as unknown as Record<string, unknown>)[fiberKey]) as
| {
return?: unknown;
memoizedState?: unknown;
elementType?: { name?: string };
}
| undefined;
while (fiber) {
if (fiber.elementType?.name === "InputBox") {
break;
}
fiber = fiber.return as typeof fiber;
}
let hook = fiber?.memoizedState as
| {
memoizedState?: unknown;
next?: unknown;
queue?: { dispatch?: (value: unknown) => void };
}
| undefined;
while (hook) {
const state = hook.memoizedState;
const dispatch = hook.queue?.dispatch;
if (
Array.isArray(state) &&
state.some(
(item) =>
item &&
typeof item === "object" &&
"ref_kind" in item &&
(item as { ref_kind?: string }).ref_kind === "mention",
) &&
dispatch
) {
dispatch(
state.map((item, index) =>
index === 0 && item && typeof item === "object"
? {
...item,
path: nextArtifactPath,
ref_source: "artifact",
}
: item,
),
);
return true;
}
hook = hook.next as typeof hook;
}
return false;
}, artifactPath);
expect(rewritten).toBe(true);
}
export async function stubReferenceFixtures(
page: Page,
options: {
threadId: string;
artifactPaths?: string[];
uploadFiles?: Array<{
filename: string;
size?: number;
virtual_path: string;
path?: string;
artifact_url?: string;
}>;
},
) {
const { threadId, artifactPaths = [], uploadFiles = [] } = options;
await page.route(
new RegExp(`/api/langgraph/threads/${threadId}/state(?:/[^?]+)?(\\?.*)?$`),
async (route) => {
const response = await route.fetch();
const json = await response.json();
await route.fulfill({
response,
json: {
...json,
values: {
...json.values,
artifacts: artifactPaths,
},
},
});
},
);
await page.route(
new RegExp(`/api/langgraph/threads/${threadId}/history(\\?.*)?$`),
async (route) => {
const response = await route.fetch();
const json = await response.json();
const history = Array.isArray(json) ? json : [];
await route.fulfill({
response,
json: history.map((entry) => ({
...entry,
values: {
...entry.values,
artifacts: artifactPaths,
},
})),
});
},
);
await page.route(
new RegExp(`/api/threads/${threadId}/uploads/list(\\?.*)?$`),
async (route) => {
const response = await route.fetch();
const json = await response.json().catch(() => ({ files: [], count: 0 }));
const files = uploadFiles.map((file) => ({
filename: file.filename,
size: file.size ?? 128,
path: file.path ?? file.virtual_path,
virtual_path: file.virtual_path,
artifact_url:
file.artifact_url ??
`/api/threads/${threadId}/artifacts${file.virtual_path}`,
}));
await route.fulfill({
response,
json: {
...json,
files,
count: files.length,
},
});
},
);
}
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);
}
}
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;
}