- 配置 Playwright:baseURL 改为 localhost:2026,视频仅在 CI 保留 - 更新 .gitignore 排除 Playwright 报告/缓存 - 新增线程记忆 E2E 测试:验证发送消息后可加载 summary 且无日志报错 - thread-memory-panel 添加 data-testid 属性便于定位
244 lines
7.6 KiB
TypeScript
244 lines
7.6 KiB
TypeScript
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");
|
||
});
|
||
});
|