Files
xiaoxia 0fcb77b991
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Frontend Lint (push) Successful in 1m6s
CI Build & Deploy Pipeline / Build Staging Web Image (push) Successful in 1m49s
CI Build & Deploy Pipeline / Build Production API Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Web Image (push) Has been skipped
CI Build & Deploy Pipeline / Build Production Worker Image (push) Has been skipped
CI Build & Deploy Pipeline / Deploy Production (push) Has been skipped
CI Build & Deploy Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 3m1s
CI/CD Pipeline / Unit Tests (push) Successful in 3m20s
CI/CD Pipeline / Integration Tests (push) Successful in 1m29s
CI Build & Deploy Pipeline / Build Staging API Image (push) Successful in 7m4s
CI Build & Deploy Pipeline / Build Staging Worker Image (push) Successful in 7m54s
CI Build & Deploy Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 45s
CI Build & Deploy Pipeline / Staging E2E Tests (push) Failing after 2m50s
CI Build & Deploy Pipeline / Staging API Integration Tests (push) Successful in 3m28s
ci: Prettier纳入两层防御体系 (#520)
Co-authored-by: xiaoxia <dev@xiaoxiajianji.com>
Co-committed-by: xiaoxia <dev@xiaoxiajianji.com>
2026-07-18 18:08:19 +08:00

717 lines
22 KiB
TypeScript
Raw Permalink 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.
/**
* 作品库页面 E2E 测试
*
* 覆盖:作品库列表加载、状态展示、作品详情、视频播放、下载按钮、
* 删除作品、空状态、筛选
*
* 说明:产品创建依赖生成流程,测试通过 Mock API 返回产品数据来验证 UI 行为。
* 真实的生成流程测试见 core-generation.spec.ts。
*/
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, accessToken } */
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,
}
}
/** Mock 产品数据 */
function mockProducts(count: number, statuses: string[] = ["completed"]) {
const products = []
for (let i = 0; i < count; i++) {
const status = statuses[i % statuses.length]
products.push({
id: `mock-prod-${Date.now()}-${i}`,
title: `测试作品 ${i + 1}`,
status,
duration_seconds: 30 + i * 10,
resolution: "1080x1920",
file_size: (5 + i) * 1024 * 1024,
duplicate_rate: i * 5,
video_url: status === "completed" ? "https://example.com/video.mp4" : undefined,
thumbnail_url: undefined,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
})
}
return products
}
/** 在浏览器中设置登录态 */
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,
},
},
)
}
/** Mock 产品列表 API */
async function mockProductsApi(page: import("@playwright/test").Page, products: unknown[]) {
await page.route("**/api/v1/products", (route) => {
const method = route.request().method()
const url = route.request().url()
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
// 列表
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }),
})
return
}
// 单个产品详情
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/)
if (method === "GET" && detailMatch) {
const productId = detailMatch[1]
const product = (products as Array<{ id: string }>).find((p) => p.id === productId)
if (product) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(product),
})
} else {
route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ detail: "Not found" }),
})
}
return
}
// 删除
if (method === "DELETE" && detailMatch) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "deleted" }),
})
return
}
// 下载链接
if (method === "GET" && url.includes("/download-url")) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
url: "https://example.com/download.mp4",
expires_at: new Date().toISOString(),
}),
})
return
}
route.continue()
})
}
test.describe("作品库页面", () => {
test.describe.configure({ timeout: 180_000 })
// ─── 页面加载 ──────────────────────────────────────
test("作品库列表页面加载", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-load",
)
const products = mockProducts(3, ["completed", "processing", "failed"])
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
// 页面容器
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
})
// 页面标题
await expect(page.getByRole("heading", { name: "成片库" })).toBeVisible()
// 筛选栏
await expect(page.locator(".xx-products-filters")).toBeVisible()
// 卡片网格
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 10_000,
})
// 作品卡片存在
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
})
})
// ─── 状态展示 ──────────────────────────────────────
test("作品状态展示 - 已完成/处理中/失败", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-status",
)
const products = [
{ ...mockProducts(1, ["completed"])[0], title: "已完成作品" },
{
...mockProducts(1, ["processing"])[0],
title: "处理中作品",
id: `mock-prod-${Date.now()}-p`,
},
{
...mockProducts(1, ["failed"])[0],
title: "失败作品",
id: `mock-prod-${Date.now()}-f`,
},
]
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
})
// 等待卡片加载
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
})
// 验证各状态标签存在
const completedCard = page.locator(".xx-product-card").filter({ hasText: "已完成作品" })
await expect(completedCard.locator(".xx-product-status.completed")).toHaveText("已完成")
const processingCard = page.locator(".xx-product-card").filter({ hasText: "处理中作品" })
await expect(processingCard.locator(".xx-product-status.processing")).toHaveText("处理中")
const failedCard = page.locator(".xx-product-card").filter({ hasText: "失败作品" })
await expect(failedCard.locator(".xx-product-status.failed")).toHaveText("失败")
})
// ─── 作品详情页 ────────────────────────────────────
test("作品详情页打开", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-detail",
)
const products = mockProducts(1, ["completed"])
products[0].title = "详情页测试作品"
const productId = products[0].id
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
// 直接访问详情页
await page.goto(`/app/products/${productId}`)
// 验证 URL
await expect(page).toHaveURL(/\/app\/products\//)
// 页面应正常渲染(无错误)
await expect(page.getByText(/加载失败|404|Not Found/)).toHaveCount(0, {
timeout: 5_000,
})
})
// ─── 视频播放 ──────────────────────────────────────
test("视频播放器存在(播放弹窗)", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-play",
)
const products = mockProducts(1, ["completed"])
products[0].title = "播放测试作品"
products[0].video_url = "https://example.com/test-video.mp4"
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
})
// 点击作品卡片打开播放
const productCard = page.locator(".xx-product-card").filter({ hasText: "播放测试作品" })
await expect(productCard).toBeVisible()
// 点击播放按钮
await productCard.locator(".xx-product-play").click({ force: true })
// 播放弹窗出现 - 验证有视频元素或播放器容器
// (通过 Mock 的 video_urlvideo 元素应能渲染)
const videoEl = page.locator("video")
const videoVisible = await videoEl
.first()
.isVisible({ timeout: 5000 })
.catch(() => false)
// 或弹窗容器可见
const modalVisible = await page
.locator(".ant-modal-content")
.filter({ hasText: "播放测试作品" })
.isVisible()
.catch(() => false)
expect(videoVisible || modalVisible).toBeTruthy()
})
// ─── 下载按钮 ──────────────────────────────────────
test("下载按钮存在", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-download",
)
const products = mockProducts(1, ["completed"])
products[0].title = "下载测试作品"
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
})
const productCard = page.locator(".xx-product-card").filter({ hasText: "下载测试作品" })
await expect(productCard).toBeVisible()
// 下载按钮存在且可用(已完成状态)
const downloadBtn = productCard.getByRole("button", { name: "下载" })
await expect(downloadBtn).toBeVisible()
await expect(downloadBtn).not.toBeDisabled()
})
test("处理中作品下载按钮禁用", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-disabled",
)
const products = mockProducts(1, ["processing"])
products[0].title = "处理中下载测试"
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
})
const productCard = page.locator(".xx-product-card").filter({ hasText: "处理中下载测试" })
await expect(productCard).toBeVisible()
// 处理中的作品下载按钮应禁用
const downloadBtn = productCard.getByRole("button", { name: "下载" })
await expect(downloadBtn).toBeVisible()
const isDisabled = await downloadBtn.isDisabled()
const hasDisabled = await downloadBtn.evaluate(
(el) => el.hasAttribute("disabled") || el.classList.contains("disabled"),
)
expect(isDisabled || hasDisabled).toBeTruthy()
})
// ─── 删除作品 ──────────────────────────────────────
test("删除作品 - API 调用正确", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-delete",
)
const products = mockProducts(1, ["completed"])
products[0].title = "待删除作品"
let deleteCalled = false
let deletedId = ""
await page.route("**/api/v1/products", (route) => {
const method = route.request().method()
const url = route.request().url()
if (method === "GET" && url.match(/\/api\/v1\/products$/)) {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ items: products, total: products.length }),
})
return
}
const detailMatch = url.match(/\/api\/v1\/products\/([^/?]+)/)
if (method === "DELETE" && detailMatch) {
deleteCalled = true
deletedId = detailMatch[1]
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ message: "deleted" }),
})
return
}
if (method === "GET" && detailMatch) {
const productId = detailMatch[1]
const product = products.find((p) => p.id === productId)
route.fulfill({
status: product ? 200 : 404,
contentType: "application/json",
body: JSON.stringify(product || { detail: "Not found" }),
})
return
}
route.continue()
})
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
})
const productCard = page.locator(".xx-product-card").filter({ hasText: "待删除作品" })
await expect(productCard).toBeVisible()
// 验证 DELETE API 存在于 products API 中
// 我们通过检查实际 API 来确认删除功能可用
// (mock 只是为了测试 UI 行为)
expect(deleteCalled).toBe(false) // 初始状态未调用
expect(deletedId).toBe("")
})
test("删除作品 API 端点存在", async ({ request }) => {
const { headers } = await createAuthedUser(request, "products-del-api")
// 测试删除不存在的产品,验证 API 端点存在
const resp = await request.delete(`${apiBase}/products/nonexistent-test-id`, {
headers,
})
// 应返回 404 或 403,不应是 405 (Method Not Allowed) 或 404 (路由不存在)
// 404 表示资源不存在但端点存在
expect(resp.status(), "删除 API 端点应存在").not.toBe(405)
expect([200, 204, 403, 404]).toContain(resp.status())
})
// ─── 空状态 ────────────────────────────────────────
test("空状态展示", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-empty",
)
// Mock 空列表
await mockProductsApi(page, [])
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-page")).toBeVisible({
timeout: 20_000,
})
// 空状态应显示
await expect(page.locator(".xx-products-empty")).toBeVisible({
timeout: 10_000,
})
await expect(page.getByText(/暂无成片|没有成片/)).toBeVisible()
})
// ─── 搜索筛选 ──────────────────────────────────────
test("作品搜索功能", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-search",
)
const products = [
{
...mockProducts(1, ["completed"])[0],
title: "苹果宣传视频",
id: `mock-prod-${Date.now()}-apple`,
},
{
...mockProducts(1, ["completed"])[0],
title: "香蕉推广视频",
id: `mock-prod-${Date.now()}-banana`,
},
]
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
})
// 两个作品都可见
await expect(page.getByText("苹果宣传视频")).toBeVisible({
timeout: 5_000,
})
await expect(page.getByText("香蕉推广视频")).toBeVisible()
// 搜索"苹果"
await page.getByPlaceholder("搜索成片名称...").fill("苹果")
await expect(page.getByText("苹果宣传视频")).toBeVisible()
await expect(page.getByText("香蕉推广视频")).toHaveCount(0)
// 清空搜索
await page.getByPlaceholder("搜索成片名称...").fill("")
await expect(page.getByText("香蕉推广视频")).toBeVisible({
timeout: 5_000,
})
})
test("作品状态筛选", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-filter-status",
)
const products = [
{
...mockProducts(1, ["completed"])[0],
title: "已完成筛选",
id: `mock-prod-${Date.now()}-done`,
},
{
...mockProducts(1, ["processing"])[0],
title: "处理中筛选",
id: `mock-prod-${Date.now()}-proc`,
},
]
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
})
// 两个都可见
await expect(page.getByText("已完成筛选")).toBeVisible({ timeout: 5_000 })
await expect(page.getByText("处理中筛选")).toBeVisible()
// 状态筛选下拉存在
const selects = page.locator(".xx-products-filters-left select")
const count = await selects.count()
if (count >= 2) {
// 第2个 select 是状态筛选
await selects.nth(1).selectOption({ label: "已完成" })
await expect(page.getByText("已完成筛选")).toBeVisible()
await expect(page.getByText("处理中筛选")).toHaveCount(0)
}
})
// ─── 批量操作 ──────────────────────────────────────
test("批量选择和批量操作栏", async ({ page, request }) => {
await routeBrowserApiToTestApi(page)
const { userId, accessToken, email, username } = await createAuthedUser(
request,
"products-batch",
)
const products = mockProducts(3, ["completed"])
products[0].title = "批量测试 1"
products[1].title = "批量测试 2"
products[2].title = "批量测试 3"
await mockProductsApi(page, products)
await setupAuthInBrowser(page, accessToken, {
id: userId,
email,
username,
})
await page.goto("/app/products")
await expect(page.locator(".xx-products-grid")).toBeVisible({
timeout: 20_000,
})
// 三张卡片
await expect(page.locator(".xx-product-card")).toHaveCount(3, {
timeout: 10_000,
})
// 点击第一张卡片的复选框
const firstCard = page.locator(".xx-product-card").first()
const checkbox = firstCard.locator(".xx-product-card-checkbox")
await expect(checkbox).toBeVisible()
await checkbox.click()
// 批量操作栏应出现
const batchBar = page.locator(".xx-products-batch-bar")
await expect(batchBar).toBeVisible({ timeout: 5_000 })
await expect(batchBar.getByText(/已选择 1 项/)).toBeVisible()
// 批量按钮存在
await expect(batchBar.getByRole("button", { name: "批量下载" })).toBeVisible()
await expect(batchBar.getByRole("button", { name: "批量删除" })).toBeVisible()
// 取消选择
await batchBar.getByRole("button", { name: "取消选择" }).click()
await expect(batchBar).not.toBeVisible({ timeout: 3_000 })
})
// ─── 未登录访问 ────────────────────────────────────
test("未登录访问作品库 - 重定向到登录页", async ({ page }) => {
await page.goto("/app/products")
await expect(page).toHaveURL(/\/login/)
})
})