deerflow2/frontend/tests/e2e/thread-memory-log.spec.ts
MT-Mint 6c4f88d4c8 test(e2e): 添加 E2E 测试基础设施和线程记忆测试
- 配置 Playwright:baseURL 改为 localhost:2026,视频仅在 CI 保留
- 更新 .gitignore 排除 Playwright 报告/缓存
- 新增线程记忆 E2E 测试:验证发送消息后可加载 summary 且无日志报错
- thread-memory-panel 添加 data-testid 属性便于定位
2026-06-11 17:51:15 +08:00

244 lines
7.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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