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.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 { 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); }