/** * 素材库页面完整 E2E 测试 * * 覆盖:页面加载、创建素材库、切换素材库、搜索/筛选、素材详情、 * 删除素材、批量删除、空状态 * 注意:test_asset.spec.ts 已覆盖 API 级别的素材库 CRUD,本文件聚焦 UI 交互 */ import { expect, test, type APIRequestContext } from "@playwright/test"; const PASSWORD = "SmokePass123!"; const apiBase = process.env.E2E_API_BASE || "/api/v1"; const apiOrigin = apiBase.endsWith("/api/v1") ? apiBase.slice(0, -"/api/v1".length) : ""; const routeBrowserApiToTestApi = async ( page: import("@playwright/test").Page, ) => { if (!apiOrigin) return; await page.route("**/api/v1/**", async (route) => { const sourceUrl = new URL(route.request().url()); const response = await route.fetch({ url: `${apiOrigin}${sourceUrl.pathname}${sourceUrl.search}`, }); await route.fulfill({ response }); }); }; function uniqueEmail(prefix: string): string { return `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 8)}@example.com`; } function uniqueUsername(prefix: string): string { return `${prefix}_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`; } /** 登录操作,遇到 429 限流自动等待重试 */ async function loginWithRetry( request: APIRequestContext, email: string, password: string, maxRetries = 2, ) { for (let i = 0; i <= maxRetries; i++) { const response = await request.post(`${apiBase}/auth/login`, { data: { email, password }, }); if (response.status() !== 429) return response; console.log(`[login] 触发限流,等待 65s 后重试 (${i + 1}/${maxRetries})`); await new Promise((r) => setTimeout(r, 65000)); } return request.post(`${apiBase}/auth/login`, { data: { email, password }, }); } /** 注册并登录,返回 { headers, email, username, userId } */ async function createAuthedUser(request: APIRequestContext, label: string) { const email = uniqueEmail(label); const username = uniqueUsername(label); const reg = await request.post(`${apiBase}/auth/register`, { data: { email, password: PASSWORD, username, display_name: `E2E ${label}` }, }); expect(reg.ok(), `注册应成功: ${await reg.text()}`).toBeTruthy(); const regData = await reg.json(); const login = await loginWithRetry(request, email, PASSWORD); expect(login.ok(), `登录应成功: ${await login.text()}`).toBeTruthy(); const loginData = await login.json(); return { headers: { Authorization: `Bearer ${loginData.access_token}` }, email, username, userId: regData.user_id, accessToken: loginData.access_token, }; } /** 创建项目 */ async function createProject( request: APIRequestContext, headers: Record, suffix: string, ): Promise { const resp = await request.post(`${apiBase}/projects`, { headers, data: { name: `Assets Test Proj ${suffix}`, description: "E2E assets test", }, }); expect(resp.ok(), `创建项目应成功: ${await resp.text()}`).toBeTruthy(); const data = await resp.json(); return data.id; } /** 创建素材库 */ async function createLibrary( request: APIRequestContext, headers: Record, projectId: string, name: string, kind: "video" | "image" = "video", ): Promise { const resp = await request.post(`${apiBase}/asset-libraries`, { headers, data: { project_id: projectId, name, kind }, }); expect(resp.ok(), `创建素材库应成功: ${await resp.text()}`).toBeTruthy(); const data = await resp.json(); return data.id; } /** 创建素材记录 */ async function createAsset( request: APIRequestContext, headers: Record, projectId: string, libraryId: string, userId: string, name: string, status: string = "ready", ): Promise { const resp = await request.post(`${apiBase}/assets`, { headers, data: { project_id: projectId, library_id: libraryId, name, storage_key: `uploads/e2e/${Date.now()}/${name}`, mime_type: "video/mp4", file_size: 1024000, status, uploaded_by_user_id: userId, metadata: { duration: 15.5, resolution: "1080p" }, }, }); expect(resp.ok(), `创建素材应成功: ${await resp.text()}`).toBeTruthy(); const data = await resp.json(); return data.id; } /** 在浏览器中设置登录态 */ async function setupAuthInBrowser( page: import("@playwright/test").Page, token: string, user: { id: string; email: string; username: string }, ) { await page.addInitScript( ({ token, user }) => { localStorage.setItem("access_token", token); localStorage.setItem( "auth-storage", JSON.stringify({ state: { user, isAuthenticated: true }, version: 0, }), ); }, { token, user: { id: user.id, user_id: user.id, email: user.email, username: user.username, display_name: user.username, is_email_verified: true, email_verified: true, }, }, ); } test.describe("素材库页面 - 完整交互测试", () => { test.describe.configure({ timeout: 180_000 }); // ─── 页面加载 ────────────────────────────────────── test("素材库列表页面加载", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-load"); const projectId = await createProject( request, headers, Date.now().toString(), ); await createLibrary(request, headers, projectId, "默认视频库", "video"); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); // 页面布局容器 await expect(page.locator(".xx-assets-page")).toBeVisible({ timeout: 20_000, }); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); // 左侧素材库列表 await expect(page.locator(".xx-asset-library-list")).toBeVisible(); // 右侧内容区(上传区 + 筛选 + 素材网格) await expect(page.locator(".xx-assets-content")).toBeVisible(); await expect(page.locator(".xx-asset-upload-zone")).toBeVisible(); await expect(page.locator(".xx-assets-filters")).toBeVisible(); // 无错误提示 await expect(page.getByText(/加载失败|素材库加载失败/)).toHaveCount(0, { timeout: 5_000, }); }); // ─── 创建素材库 ──────────────────────────────────── test("创建新素材库 - 通过 UI", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-create"); const projectId = await createProject( request, headers, Date.now().toString(), ); await createLibrary(request, headers, projectId, "初始库", "video"); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); // 点击新建素材库 await page.locator(".xx-asset-library-add").click(); // 弹窗出现 const modal = page .locator(".ant-modal-content") .filter({ hasText: "新建素材库" }); await expect(modal).toBeVisible(); // 填写表单 const newLibName = `E2E 新建库 ${Date.now()}`; await modal.getByPlaceholder("请输入素材库名称").fill(newLibName); // 类型选择默认是 video,保持即可 // 监听创建请求 const createPromise = page.waitForResponse( (resp) => resp.url().includes("/asset-libraries") && resp.request().method() === "POST", { timeout: 10_000 }, ); // 点击创建 await modal.getByRole("button", { name: "创建" }).click(); const resp = await createPromise; expect(resp.ok(), `创建素材库应成功: ${resp.status()}`).toBeTruthy(); // 新素材库应出现在列表中 await expect( page.locator(".xx-asset-library-item").filter({ hasText: newLibName }), ).toBeVisible({ timeout: 10_000 }); }); // ─── 切换素材库 ──────────────────────────────────── test("切换不同素材库", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-switch"); const projectId = await createProject( request, headers, Date.now().toString(), ); const videoLibName = "视频素材库 A"; const imageLibName = "图片素材库 B"; const videoLibId = await createLibrary( request, headers, projectId, videoLibName, "video", ); // 在视频库里创建一个素材 await createAsset( request, headers, projectId, videoLibId, userId, "demo_video.mp4", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); // 点击视频库,应显示素材 const videoLibItem = page .locator(".xx-asset-library-item") .filter({ hasText: videoLibName }); await videoLibItem.click({ force: true }); await expect(videoLibItem).toHaveClass(/active/); // 验证视频素材出现 await expect(page.getByText("demo_video.mp4")).toBeVisible({ timeout: 10_000, }); // 点击图片库,应切换且不显示视频 const imageLibItem = page .locator(".xx-asset-library-item") .filter({ hasText: imageLibName }); await imageLibItem.click({ force: true }); await expect(imageLibItem).toHaveClass(/active/); // 空状态或图片库内容 await expect(page.getByText("demo_video.mp4")).toHaveCount(0, { timeout: 5_000, }); }); // ─── 素材搜索 ────────────────────────────────────── test("素材搜索功能", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-search"); const projectId = await createProject( request, headers, Date.now().toString(), ); const libraryId = await createLibrary( request, headers, projectId, "搜索测试库", "video", ); // 创建两个不同名称的素材 await createAsset( request, headers, projectId, libraryId, userId, "apple_clip.mp4", ); await createAsset( request, headers, projectId, libraryId, userId, "banana_clip.mp4", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); // 确保在测试库中 const libItem = page .locator(".xx-asset-library-item") .filter({ hasText: "搜索测试库" }); await libItem.click({ force: true }); // 两个素材都应可见 await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 10_000, }); await expect(page.getByText("banana_clip.mp4")).toBeVisible(); // 搜索 apple,只显示 apple await page.getByPlaceholder("搜索素材名称...").fill("apple"); await expect(page.getByText("apple_clip.mp4")).toBeVisible(); await expect(page.getByText("banana_clip.mp4")).toHaveCount(0); // 清空搜索,两个都显示 await page.getByPlaceholder("搜索素材名称...").fill(""); await expect(page.getByText("apple_clip.mp4")).toBeVisible({ timeout: 5_000, }); await expect(page.getByText("banana_clip.mp4")).toBeVisible(); }); // ─── 筛选类型 ────────────────────────────────────── test("素材类型筛选", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-filter"); const projectId = await createProject( request, headers, Date.now().toString(), ); const libraryId = await createLibrary( request, headers, projectId, "筛选测试库", "video", ); // 创建视频素材 await createAsset( request, headers, projectId, libraryId, userId, "video_clip.mp4", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); const libItem = page .locator(".xx-asset-library-item") .filter({ hasText: "筛选测试库" }); await libItem.click({ force: true }); // 素材应可见 await expect(page.getByText("video_clip.mp4")).toBeVisible({ timeout: 10_000, }); // 筛选类型下拉存在 const filterSelect = page.locator(".xx-assets-filters-left select").first(); await expect(filterSelect).toBeVisible(); }); // ─── 素材详情/播放 ──────────────────────────────── test("素材详情查看 - 播放弹窗", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-detail"); const projectId = await createProject( request, headers, Date.now().toString(), ); const libraryId = await createLibrary( request, headers, projectId, "详情测试库", "video", ); await createAsset( request, headers, projectId, libraryId, userId, "play_test.mp4", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); const libItem = page .locator(".xx-asset-library-item") .filter({ hasText: "详情测试库" }); await libItem.click({ force: true }); // 找到素材卡片并点击播放按钮 const assetCard = page .locator(".xx-asset-card") .filter({ hasText: "play_test.mp4" }); await expect(assetCard).toBeVisible({ timeout: 10_000 }); // 点击播放按钮 await assetCard.locator(".xx-asset-play").click({ force: true }); // 播放弹窗出现 const modal = page .locator(".ant-modal-content") .filter({ hasText: "播放" }); await expect(modal).toBeVisible(); // 关闭弹窗 await modal.locator(".ant-modal-close").click(); await expect(modal).not.toBeVisible({ timeout: 5_000 }); }); // ─── 删除素材 ────────────────────────────────────── test("删除素材 - 带确认对话框", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-delete"); const projectId = await createProject( request, headers, Date.now().toString(), ); const libraryId = await createLibrary( request, headers, projectId, "删除测试库", "video", ); await createAsset( request, headers, projectId, libraryId, userId, "to_delete.mp4", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); const libItem = page .locator(".xx-asset-library-item") .filter({ hasText: "删除测试库" }); await libItem.click({ force: true }); const assetCard = page .locator(".xx-asset-card") .filter({ hasText: "to_delete.mp4" }); await expect(assetCard).toBeVisible({ timeout: 10_000 }); // 悬停显示删除按钮 await assetCard.hover(); // 点击删除 const deleteBtn = assetCard.locator(".xx-asset-delete"); await expect(deleteBtn).toBeVisible(); await deleteBtn.click({ force: true }); // 确认对话框出现 const confirmModal = page .locator(".ant-popover") .filter({ hasText: "确认删除" }); await expect(confirmModal).toBeVisible(); // 监听删除请求 const deletePromise = page.waitForResponse( (resp) => resp.url().includes("/assets/") && resp.request().method() === "DELETE", { timeout: 10_000 }, ); // 点击确认删除 await confirmModal.getByRole("button", { name: "删除" }).click(); const resp = await deletePromise; expect(resp.ok(), `删除素材应成功: ${resp.status()}`).toBeTruthy(); // 素材应从列表中消失 await expect(page.getByText("to_delete.mp4")).toHaveCount(0, { timeout: 10_000, }); }); // ─── 批量删除素材 ────────────────────────────────── test("批量删除素材", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-batch"); const projectId = await createProject( request, headers, Date.now().toString(), ); const libraryId = await createLibrary( request, headers, projectId, "批量删除库", "video", ); // 创建多个素材 await createAsset( request, headers, projectId, libraryId, userId, "batch_1.mp4", ); await createAsset( request, headers, projectId, libraryId, userId, "batch_2.mp4", ); await createAsset( request, headers, projectId, libraryId, userId, "batch_3.mp4", ); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); const libItem = page .locator(".xx-asset-library-item") .filter({ hasText: "批量删除库" }); await libItem.click({ force: true }); // 所有素材应可见 await expect(page.getByText("batch_1.mp4")).toBeVisible({ timeout: 10_000, }); await expect(page.getByText("batch_2.mp4")).toBeVisible(); await expect(page.getByText("batch_3.mp4")).toBeVisible(); // 点击全选 const selectAllBtn = page.getByRole("button", { name: "全选" }); await expect(selectAllBtn).toBeVisible(); await selectAllBtn.click(); // 批量操作栏出现 const batchBar = page.locator(".xx-assets-batch-bar"); await expect(batchBar).toBeVisible(); await expect(batchBar.getByText(/已选 3 项/)).toBeVisible(); // 点击批量删除 const batchDeleteBtn = batchBar.getByRole("button", { name: "批量删除" }); await expect(batchDeleteBtn).toBeVisible(); await batchDeleteBtn.click(); // 确认对话框 const confirmPop = page .locator(".ant-popover") .filter({ hasText: "确定删除" }); await expect(confirmPop).toBeVisible(); // 确认删除 await confirmPop.getByRole("button", { name: "删除" }).click(); // 验证素材已删除(通过 API 确认) await expect .poll( async () => { const resp = await request.get(`${apiBase}/assets`, { headers, params: { library_id: libraryId }, }); if (!resp.ok()) return "error"; const data = await resp.json(); const items = data.items || []; return items.length; }, { timeout: 15_000, intervals: [1_000, 2_000, 3_000] }, ) .toBe(0); }); // ─── 空状态 ──────────────────────────────────────── test("空素材库展示空状态", async ({ page, request }) => { await routeBrowserApiToTestApi(page); const { headers, userId, accessToken, email, username } = await createAuthedUser(request, "assets-empty"); const projectId = await createProject( request, headers, Date.now().toString(), ); await createLibrary(request, headers, projectId, "空素材库", "video"); await setupAuthInBrowser(page, accessToken, { id: userId, email, username, }); await page.goto("/app/assets"); await expect(page.locator(".xx-assets-layout")).toBeVisible({ timeout: 20_000, }); const libItem = page .locator(".xx-asset-library-item") .filter({ hasText: "空素材库" }); await libItem.click({ force: true }); // 空状态应显示 await expect(page.locator(".xx-assets-empty")).toBeVisible({ timeout: 10_000, }); await expect(page.getByText("暂无素材,请上传或切换素材库")).toBeVisible(); }); // ─── 未登录访问 ──────────────────────────────────── test("未登录访问素材库 - 重定向到登录页", async ({ page }) => { await page.goto("/app/assets"); await expect(page).toHaveURL(/\/login/); }); });