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