348 lines
9.2 KiB
TypeScript
348 lines
9.2 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 setTheme(page: Page, theme: "light" | "dark") {
|
|
await page.evaluate((nextTheme) => {
|
|
const root = document.documentElement;
|
|
root.classList.remove("light", "dark");
|
|
root.classList.add(nextTheme);
|
|
root.style.colorScheme = nextTheme;
|
|
}, theme);
|
|
}
|
|
|
|
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;
|
|
}
|