diff --git a/frontend/tests/e2e/artifact-generation-preview-download.spec.ts b/frontend/tests/e2e/artifact-generation-preview-download.spec.ts new file mode 100644 index 00000000..949b10ad --- /dev/null +++ b/frontend/tests/e2e/artifact-generation-preview-download.spec.ts @@ -0,0 +1,222 @@ +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 { + 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 { + 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 { + 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.locator(".pptx-preview-wrap").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 { + 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 { + 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); +}