import fs from "node:fs/promises"; import { expect, test } from "@playwright/test"; import { v4 as uuid } from "uuid"; import { expandComposer, newChatEntry, openChat, } from "./support/chat-helpers"; const LANGGRAPH_LOG_PATH = "/home/mt/Projects/deerflow2/logs/langgraph.log"; const INPUT_TOOLS_TOUR_SEEN_KEY = "workspace.input_tools_tour_seen.v1"; const MEMORY_ERROR_PATTERNS = [ "Thread memory update failed", "json.decoder.JSONDecodeError", "JSONDecodeError", ]; async function readLogTail(startOffset: number) { const handle = await fs.open(LANGGRAPH_LOG_PATH, "r"); try { const stats = await handle.stat(); const length = Math.max(0, stats.size - startOffset); if (length === 0) { return ""; } const buffer = Buffer.alloc(length); await handle.read(buffer, 0, length, startOffset); return buffer.toString("utf8"); } finally { await handle.close(); } } function e2eLog(message: string, extra?: unknown) { if (extra === undefined) { console.log(`[DF-MEM-001] ${message}`); return; } console.log(`[DF-MEM-001] ${message}`, extra); } async function completeInputToolsTour(page: Parameters[0]) { e2eLog("checking input tools tour"); const tourRoot = page.locator(".workspace-input-tools-tour"); const nextButton = page .locator(".workspace-input-tools-tour .ant-tour-next-btn") .getByText(/下一步|完成/); if (!(await tourRoot.isVisible().catch(() => false))) { e2eLog("input tools tour not visible, skipping"); return; } for (let step = 0; step < 4; step += 1) { e2eLog(`input tools tour step ${step + 1}`); await expect(nextButton.first()).toBeVisible(); await nextButton.first().click(); if (!(await tourRoot.isVisible().catch(() => false))) { e2eLog("input tools tour completed"); return; } } e2eLog("input tools tour still visible after max steps"); } async function waitForResolvedThreadId( page: Parameters[0], threadId: string, ) { e2eLog("waiting for resolved thread id", threadId); await expect .poll( () => page.evaluate( (storageKey) => window.sessionStorage.getItem(storageKey), "workspace.thread_id", ), { timeout: 30_000, }, ) .toBe(threadId); e2eLog("resolved thread id is ready", threadId); } test.describe("线程记忆 / 前端加载与日志校验", () => { test.setTimeout(120_000); test("DF-MEM-001 发送消息后可从线程记忆面板加载 summary,且新增日志无记忆报错", async ({ page, }) => { const threadId = uuid(); const logStats = await fs.stat(LANGGRAPH_LOG_PATH); const message = `请记住:我常用 TypeScript、React 和 Playwright,偏好中文且直接回答重点。本次 e2e 线程标识 ${threadId.slice(0, 8)}。`; e2eLog("test started", { threadId, initialLogSize: logStats.size }); await page.addInitScript( ({ key, currentThreadId }: { key: string; currentThreadId: string }) => { window.localStorage.setItem( key, JSON.stringify({ seen: true, threadIds: [currentThreadId], }), ); }, { key: INPUT_TOOLS_TOUR_SEEN_KEY, currentThreadId: threadId, }, ); e2eLog("seeded localStorage for input tools tour"); await openChat(page, newChatEntry(threadId)); e2eLog("opened chat page", await page.url()); await completeInputToolsTour(page); await waitForResolvedThreadId(page, threadId); e2eLog("composer page state ready"); const observedRequests: string[] = []; page.on("request", (request) => { const url = request.url(); if (url.includes("/stream") || url.includes("/threads")) { observedRequests.push(`${request.method()} ${url}`); } }); e2eLog("registered network observer"); const textarea = page.locator("textarea[name='message']"); const submit = page.locator("button[aria-label='Submit']"); await textarea.click(); await textarea.pressSequentially(message); e2eLog("filled textarea", { messageLength: message.length, textareaValueLength: (await textarea.inputValue()).length, }); await expect(textarea).toHaveValue(message); e2eLog("textarea value confirmed"); e2eLog("submit button state before click", { visible: await submit.isVisible(), enabled: await submit.isEnabled(), }); await submit.evaluate((button) => { (button as HTMLButtonElement).click(); }); e2eLog("submit clicked via evaluate"); await expect .poll( async () => ({ url: page.url(), userCount: await page.locator(".is-user").count(), assistantCount: await page.locator(".is-assistant").count(), textareaValue: await textarea.inputValue(), observedRequests: observedRequests.slice(-10), }), { timeout: 30_000, intervals: [500, 1_000, 2_000], }, ) .toMatchObject({ url: expect.stringMatching(new RegExp(`/workspace/chats/${threadId}\\?is_chatting=true`)), }); e2eLog("post-submit state reached", { currentUrl: await page.url(), observedRequests: observedRequests.slice(-10), }); await expect(textarea).toHaveValue(""); e2eLog("textarea cleared after submit"); await expect(page).toHaveURL( new RegExp(`/workspace/chats/${threadId}\\?is_chatting=true`), { timeout: 30_000 }, ); e2eLog("navigated to active thread page", await page.url()); await expect .poll(async () => await page.locator(".is-user").count(), { timeout: 30_000, }) .toBeGreaterThan(0); e2eLog("user message rendered", await page.locator(".is-user").count()); await expect(page.locator(".is-user").last()).toContainText( "TypeScript", { timeout: 30_000 }, ); e2eLog("user message content contains TypeScript"); await expect .poll(async () => await page.locator(".is-assistant").count(), { timeout: 30_000, }) .toBeGreaterThan(0); e2eLog("assistant message rendered", await page.locator(".is-assistant").count()); await expandComposer(page); e2eLog("composer expanded for memory button"); const memoryButton = page.getByTestId("thread-memory-trigger"); e2eLog("memory button visibility precheck", { count: await memoryButton.count(), }); await expect(memoryButton).toBeVisible(); await memoryButton.click(); e2eLog("memory button clicked"); const loadButton = page.getByTestId("thread-memory-load"); await expect(loadButton).toBeVisible(); e2eLog("memory load button visible"); let latestSummary = ""; await expect .poll( async () => { await loadButton.click(); latestSummary = await page .getByTestId("thread-memory-summary") .inputValue(); e2eLog("memory summary polled", { length: latestSummary.length, preview: latestSummary.slice(0, 80), }); return latestSummary; }, { timeout: 75_000, intervals: [1_000, 2_000, 3_000, 5_000], }, ) .not.toEqual(""); e2eLog("memory summary loaded", { length: latestSummary.length, preview: latestSummary.slice(0, 120), }); const logTail = await readLogTail(logStats.size); e2eLog("new langgraph log tail length", logTail.length); for (const pattern of MEMORY_ERROR_PATTERNS) { e2eLog(`checking log pattern absence: ${pattern}`); expect(logTail).not.toContain(pattern); } e2eLog("test finished successfully"); }); });