deerflow2/frontend/tests/e2e/artifact-generation-preview...

236 lines
7.1 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 {
expect,
test,
type Locator,
type Page,
type TestInfo,
} from "@playwright/test";
import { v4 as uuid } from "uuid";
import {
newChatEntry,
openChat,
sendMessage,
waitForMessageListReady,
} from "./support/chat-helpers";
const FILE_CASES = [
{ kind: "html", label: "html", regex: /\.html?/i },
{
kind: "image",
label: "image",
regex: /\.(png|jpe?g|gif|webp|bmp|svg|ico|avif|tiff?)/i,
},
{ kind: "md", label: "md", regex: /\.md/i },
{ kind: "docx", label: "docx", regex: /\.docx?/i },
{ kind: "pptx", label: "pptx", regex: /\.pptx?/i },
{ kind: "xlsx", label: "xlsx", regex: /\.xlsx?/i },
] as const;
test.use({
video: "on",
screenshot: "on",
});
test.describe("聊天工作台 / 智能体产物生成预览与下载", () => {
test("DF-ART-GEN-001 生成并逐个点击 html/image/md/docx/pptx/xlsx 卡片截图", async ({
page,
}, testInfo) => {
const startedAt = Date.now();
test.setTimeout(12 * 60 * 1000);
const threadId = uuid();
logStatus("开始测试", `threadId=${threadId}`);
await openChat(page, newChatEntry(threadId));
await waitForMessageListReady(page, { requireMessages: false });
logStatus("发送生成指令");
await sendMessage(page, buildGeneratePrompt());
await waitForArtifactsReady(page, FILE_CASES, startedAt);
await openArtifactPanel(page);
logStatus("Artifacts 列表已就绪,开始逐类校验");
await capture(page, testInfo, "artifact-list-ready");
for (const file of FILE_CASES) {
logStatus("校验文件类型", file.label);
const card = artifactCardByPattern(page, file.regex);
await expect(card).toBeVisible();
await card.click();
logStatus("点击并截图", file.label);
await waitAfterCardClick(page, file.kind);
await capture(page, testInfo, `card-clicked-${file.label}`);
logStatus("类型处理完成", file.label);
}
logStatus("测试完成");
});
});
function buildGeneratePrompt(): string {
return [
"请一次性创建以下 6 个文件到 /mnt/user-data/outputs并在完成后调用 present_files",
"1) e2e-artifacts-page.html包含标题 DF_E2E_HTML 和一段正文。",
"2) e2e-artifacts-image.png生成一张包含文字 DF_E2E_IMAGE 的图片。",
"3) e2e-artifacts-notes.md标题为 DF_E2E_MD并引用上面的图片。",
"4) e2e-artifacts-report.docx包含标题 DF_E2E_DOCX 和一段文字。",
"5) e2e-artifacts-slides.pptx至少 2 页,包含 DF_E2E_PPTX。",
"6) e2e-artifacts-table.xlsx至少 2 列 3 行,并包含 DF_E2E_XLSX。",
"注意:所有文件都要真实写入输出目录,不要只在回复里描述。",
].join("\n");
}
async function openArtifactPanel(page: Page): Promise<void> {
const button = page.getByTestId("artifacts-open-button");
await expect(button).toBeVisible({ timeout: 120_000 });
await button.click();
await expect(page.getByTestId("artifact-file-list").first()).toBeVisible();
}
async function waitForArtifactsReady(
page: Page,
requiredCases: ReadonlyArray<(typeof FILE_CASES)[number]>,
startedAt: number,
): Promise<void> {
let pollRound = 0;
await expect
.poll(
async () => {
pollRound += 1;
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
const list = page.getByTestId("artifact-file-list").first();
// 1) 优先直接检查已展示的 artifact-file-list
if (!(await list.isVisible().catch(() => false))) {
// 2) 列表不存在时再尝试通过按钮打开
const openButton = page.getByTestId("artifacts-open-button").first();
if (!(await openButton.isVisible().catch(() => false))) {
logStatus(
"等待 artifacts 入口或列表出现",
`轮次=${pollRound}, 已耗时=${elapsedSeconds}s`,
);
return false;
}
await openButton.click();
await expect(
page.getByTestId("artifact-file-list").first(),
).toBeVisible({
timeout: 5_000,
});
}
const allFileNames = await getArtifactFileNames(page);
const found = requiredCases
.filter((fileCase) =>
allFileNames.some((name) => fileCase.regex.test(name)),
)
.map((fileCase) => fileCase.label);
const missing = requiredCases
.filter(
(fileCase) =>
!allFileNames.some((name) => fileCase.regex.test(name)),
)
.map((fileCase) => fileCase.label);
logStatus(
"等待文件类型齐全",
`轮次=${pollRound}, 已耗时=${elapsedSeconds}s, 已找到=[${found.join(", ")}], 缺失=[${missing.join(", ")}]`,
);
return missing.length === 0;
},
{
timeout: 8 * 60 * 1000,
intervals: [1000, 2000, 3000, 5000],
},
)
.toBeTruthy();
}
function artifactCardByPattern(page: Page, pattern: RegExp): Locator {
return page
.locator("[data-testid='artifact-file-card']")
.filter({
has: page.locator("[data-slot='card-title'] div[title]").filter({
hasText: pattern,
}),
})
.first();
}
async function waitAfterCardClick(page: Page, kind: string): Promise<void> {
if (kind === "docx") {
await expect(page.locator(".docx-preview-wrap").first()).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "xlsx") {
await expect(page.locator("#artifact-xlsx-preview").first()).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "pptx") {
await expect(
page.getByText("请下载ppt文件以获得最佳效果").first(),
).toBeVisible({
timeout: 60_000,
});
return;
}
if (kind === "md") {
await page.waitForTimeout(1200);
return;
}
await expect(page.getByTitle(/Artifact preview(?::.*)?$/i)).toBeVisible({
timeout: 30_000,
});
}
async function capture(
page: Page,
testInfo: TestInfo,
name: string,
): Promise<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path, fullPage: true });
await testInfo.attach(name, {
path,
contentType: "image/png",
});
}
function logStatus(step: string, detail?: string): void {
const timestamp = new Date().toISOString();
if (detail) {
console.log(`[E2E][${timestamp}] ${step} | ${detail}`);
return;
}
console.log(`[E2E][${timestamp}] ${step}`);
}
async function getArtifactFileNames(page: Page): Promise<string[]> {
const titleNodes = page.locator(
"[data-testid='artifact-file-card'] [data-slot='card-title'] div[title]",
);
const titleCount = await titleNodes.count();
if (titleCount > 0) {
const names: string[] = [];
for (let i = 0; i < titleCount; i += 1) {
const value = (await titleNodes.nth(i).getAttribute("title"))?.trim();
if (value) {
names.push(value);
}
}
return names;
}
// fallback: if title attr is absent, use first text line of each card
const cardTexts = await page
.getByTestId("artifact-file-card")
.allTextContents();
return cardTexts
.map((text) => text.split("\n")[0]?.trim() ?? "")
.filter(Boolean);
}