78d1c88ca1
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m44s
CI/CD Pipeline / Unit Tests (push) Successful in 1m44s
CI/CD Pipeline / Frontend Lint (push) Successful in 2m28s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m8s
CI/CD Pipeline / Build Staging API Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Worker Image (push) Has been cancelled
CI/CD Pipeline / Build Staging Web Image (push) Has been cancelled
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Has been cancelled
CI/CD Pipeline / Staging E2E Tests (push) Has been cancelled
CI/CD Pipeline / Staging API Integration Tests (push) Has been cancelled
638 lines
18 KiB
TypeScript
638 lines
18 KiB
TypeScript
/**
|
||
* 模板库页面 E2E 测试
|
||
*
|
||
* 覆盖:模板列表加载、分类切换、模板详情、收藏/取消收藏、
|
||
* 使用模板入口、搜索功能、我的模板tab、未登录重定向
|
||
*
|
||
* 每个测试独立,先注册登录获取 auth token。
|
||
*/
|
||
import {
|
||
expect,
|
||
test,
|
||
type APIRequestContext,
|
||
type Page,
|
||
} from "@playwright/test";
|
||
|
||
const PASSWORD = "Test123456!";
|
||
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: 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,
|
||
};
|
||
}
|
||
|
||
/** 设置页面认证状态(localStorage) */
|
||
async function setupAuth(
|
||
page: Page,
|
||
token: string,
|
||
user: { id: string; email: string; username: string; display_name: 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.display_name,
|
||
is_email_verified: true,
|
||
email_verified: true,
|
||
},
|
||
},
|
||
);
|
||
}
|
||
|
||
test.describe("模板库页面 - 未登录重定向", () => {
|
||
test("未登录访问重定向到登录页", async ({ page }) => {
|
||
await page.goto("/app/templates");
|
||
await expect(page).toHaveURL(/\/login/);
|
||
});
|
||
});
|
||
|
||
test.describe("模板库页面 - 页面加载", () => {
|
||
test.describe.configure({ timeout: 120_000 });
|
||
|
||
test("模板库页面加载成功", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||
request,
|
||
"tpl-load",
|
||
);
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-load",
|
||
});
|
||
|
||
await page.goto("/app/templates");
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
});
|
||
|
||
test("模板库头部和搜索栏存在", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||
request,
|
||
"tpl-head",
|
||
);
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-head",
|
||
});
|
||
|
||
await page.goto("/app/templates");
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// 验证搜索框
|
||
const searchInput = page.locator(".xx-templates-search-input");
|
||
await expect(searchInput).toBeVisible({ timeout: 10_000 });
|
||
});
|
||
|
||
test("分类切换按钮存在", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||
request,
|
||
"tpl-cat",
|
||
);
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-cat",
|
||
});
|
||
|
||
await page.goto("/app/templates");
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// 验证分类按钮存在
|
||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||
await expect(categoryBtns.first()).toBeVisible({ timeout: 10_000 });
|
||
const count = await categoryBtns.count();
|
||
expect(count).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
test.describe("模板库 - 模板展示", () => {
|
||
test.describe.configure({ timeout: 120_000 });
|
||
|
||
test("模板卡片展示", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username, headers } =
|
||
await createAuthedUser(request, "tpl-cards");
|
||
const suffix = Date.now().toString(36);
|
||
|
||
// 创建一个模板
|
||
await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: `E2E 模板展示 ${suffix}`,
|
||
mode: "pip",
|
||
estimated_duration: 30,
|
||
description: "测试模板展示",
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 5,
|
||
duration_max: 15,
|
||
material_type: "video",
|
||
},
|
||
],
|
||
tags: ["e2e", "展示"],
|
||
category: "种草",
|
||
},
|
||
});
|
||
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-cards",
|
||
});
|
||
|
||
await page.goto("/app/templates");
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// 等待模板卡片出现
|
||
const cards = page.locator(".xx-template-card");
|
||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||
const count = await cards.count();
|
||
expect(count).toBeGreaterThan(0);
|
||
});
|
||
|
||
test("模板卡片包含名称和类型", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username, headers } =
|
||
await createAuthedUser(request, "tpl-info");
|
||
const suffix = Date.now().toString(36);
|
||
|
||
await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: `模板信息测试 ${suffix}`,
|
||
mode: "voice_over",
|
||
estimated_duration: 60,
|
||
description: "测试信息展示",
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 10,
|
||
duration_max: 30,
|
||
material_type: "video",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-info",
|
||
});
|
||
|
||
await page.goto("/app/templates");
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
const firstCard = page.locator(".xx-template-card").first();
|
||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||
// 验证信息区域存在
|
||
const info = firstCard.locator(".xx-template-info");
|
||
await expect(info).toBeVisible();
|
||
}
|
||
});
|
||
|
||
test("模板预览弹窗功能", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username, headers } =
|
||
await createAuthedUser(request, "tpl-preview");
|
||
const suffix = Date.now().toString(36);
|
||
|
||
await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: `预览测试模板 ${suffix}`,
|
||
mode: "pip",
|
||
estimated_duration: 30,
|
||
description: "预览测试描述",
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 5,
|
||
duration_max: 15,
|
||
material_type: "video",
|
||
description: "片段一",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-preview",
|
||
});
|
||
|
||
await page.goto("/app/templates");
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// 点击第一个模板卡片打开预览
|
||
const firstCard = page.locator(".xx-template-card").first();
|
||
if (await firstCard.isVisible({ timeout: 15_000 })) {
|
||
await firstCard.click();
|
||
// 预览弹窗应该出现
|
||
const modal = page.locator(".xx-template-modal");
|
||
if (await modal.isVisible({ timeout: 5_000 })) {
|
||
await expect(modal).toBeVisible();
|
||
// 验证预览内容存在
|
||
await expect(
|
||
modal.locator(".xx-template-modal-title-row"),
|
||
).toBeVisible();
|
||
}
|
||
}
|
||
});
|
||
});
|
||
|
||
test.describe("模板库 - 分类切换", () => {
|
||
test.describe.configure({ timeout: 120_000 });
|
||
|
||
test("切换分类筛选", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||
request,
|
||
"tpl-switch",
|
||
);
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-switch",
|
||
});
|
||
|
||
await page.goto("/app/templates");
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
const categoryBtns = page.locator(".xx-templates-cat-btn");
|
||
const firstBtn = categoryBtns.first();
|
||
|
||
if (await firstBtn.isVisible({ timeout: 10_000 })) {
|
||
await firstBtn.click();
|
||
// 验证按钮被选中
|
||
await expect(firstBtn).toHaveClass(/active/);
|
||
}
|
||
});
|
||
});
|
||
|
||
test.describe("模板库 - 搜索", () => {
|
||
test.describe.configure({ timeout: 120_000 });
|
||
|
||
test("搜索框可输入并筛选", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username, headers } =
|
||
await createAuthedUser(request, "tpl-search");
|
||
const suffix = Date.now().toString(36);
|
||
const templateName = `E2E 搜索测试模板 ${suffix}`;
|
||
|
||
await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: templateName,
|
||
mode: "pip",
|
||
estimated_duration: 30,
|
||
description: "搜索测试专用模板",
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 5,
|
||
duration_max: 15,
|
||
material_type: "video",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-search",
|
||
});
|
||
|
||
await page.goto("/app/templates");
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
const searchInput = page.locator(".xx-templates-search-input");
|
||
if (await searchInput.isVisible({ timeout: 10_000 })) {
|
||
await searchInput.fill(suffix);
|
||
// 验证页面正常响应
|
||
await expect(page.locator(".xx-templates-page")).toBeVisible();
|
||
}
|
||
});
|
||
});
|
||
|
||
test.describe("模板库 - API 操作", () => {
|
||
test.describe.configure({ timeout: 120_000 });
|
||
|
||
test("获取模板列表 - 正向", async ({ request }) => {
|
||
const { headers } = await createAuthedUser(request, "tpl-api-list");
|
||
const suffix = Date.now().toString(36);
|
||
|
||
await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: `API 列表测试 ${suffix}`,
|
||
mode: "pip",
|
||
estimated_duration: 30,
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 5,
|
||
duration_max: 15,
|
||
material_type: "video",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
const response = await request.get(`${apiBase}/templates`, { headers });
|
||
expect(
|
||
response.ok(),
|
||
`获取模板列表应返回 2xx,实际: ${response.status()}`,
|
||
).toBeTruthy();
|
||
|
||
const data = await response.json();
|
||
const items = data.items || data.templates || [];
|
||
expect(Array.isArray(items), "模板列表应为数组").toBeTruthy();
|
||
expect(items.length).toBeGreaterThan(0);
|
||
});
|
||
|
||
test("收藏/取消收藏模板 - 正向", async ({ request }) => {
|
||
const { headers } = await createAuthedUser(request, "tpl-fav");
|
||
const suffix = Date.now().toString(36);
|
||
|
||
// 创建模板
|
||
const createResp = await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: `收藏测试 ${suffix}`,
|
||
mode: "pip",
|
||
estimated_duration: 30,
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 5,
|
||
duration_max: 15,
|
||
material_type: "video",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
expect(createResp.ok()).toBeTruthy();
|
||
const created = await createResp.json();
|
||
const templateId = created.id;
|
||
|
||
// 收藏
|
||
const favResp = await request.post(
|
||
`${apiBase}/templates/${templateId}/favorite`,
|
||
{ headers },
|
||
);
|
||
// 收藏可能成功或接口不存在
|
||
expect(favResp.status() < 500, "收藏请求应返回 2xx 或 4xx").toBeTruthy();
|
||
|
||
// 取消收藏
|
||
const unfavResp = await request.delete(
|
||
`${apiBase}/templates/${templateId}/favorite`,
|
||
{ headers },
|
||
);
|
||
expect(
|
||
unfavResp.status() < 500,
|
||
"取消收藏请求应返回 2xx 或 4xx",
|
||
).toBeTruthy();
|
||
});
|
||
|
||
test("获取模板详情 - 正向", async ({ request }) => {
|
||
const { headers } = await createAuthedUser(request, "tpl-api-detail");
|
||
const suffix = Date.now().toString(36);
|
||
|
||
const createResp = await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: `详情测试 ${suffix}`,
|
||
mode: "voice_over",
|
||
estimated_duration: 60,
|
||
description: "详情测试描述",
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 10,
|
||
duration_max: 30,
|
||
material_type: "video",
|
||
description: "测试片段",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
expect(createResp.ok()).toBeTruthy();
|
||
const created = await createResp.json();
|
||
|
||
const detailResp = await request.get(`${apiBase}/templates/${created.id}`, {
|
||
headers,
|
||
});
|
||
expect(detailResp.ok(), "获取详情应成功").toBeTruthy();
|
||
const detail = await detailResp.json();
|
||
expect(detail.id).toBe(created.id);
|
||
expect(detail.name).toBe(`详情测试 ${suffix}`);
|
||
});
|
||
|
||
test("使用模板接口 - 正向", async ({ request }) => {
|
||
const { headers } = await createAuthedUser(request, "tpl-use");
|
||
const suffix = Date.now().toString(36);
|
||
|
||
const createResp = await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: `使用测试 ${suffix}`,
|
||
mode: "pip",
|
||
estimated_duration: 30,
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 5,
|
||
duration_max: 15,
|
||
material_type: "video",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
expect(createResp.ok()).toBeTruthy();
|
||
const created = await createResp.json();
|
||
|
||
// 使用模板(生成)
|
||
const genResp = await request.post(
|
||
`${apiBase}/templates/${created.id}/generate`,
|
||
{ headers, data: {} },
|
||
);
|
||
// 生成可能成功或返回业务错误
|
||
expect(genResp.status() < 500, "使用模板应返回 2xx 或 4xx").toBeTruthy();
|
||
});
|
||
|
||
test("未登录获取模板列表 - 反向", async ({ request }) => {
|
||
const response = await request.get(`${apiBase}/templates`);
|
||
expect([401, 403]).toContain(response.status());
|
||
});
|
||
});
|
||
|
||
test.describe("模板库 - 我的模板 Tab", () => {
|
||
test.describe.configure({ timeout: 120_000 });
|
||
|
||
test("我的模板页面可访问", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username } = await createAuthedUser(
|
||
request,
|
||
"tpl-my",
|
||
);
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-my",
|
||
});
|
||
|
||
await page.goto("/app/my-templates");
|
||
await expect(page.locator(".mt-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
});
|
||
|
||
test("我的模板页面展示已创建的模板", async ({ page, request }) => {
|
||
await routeBrowserApiToTestApi(page);
|
||
const { accessToken, userId, email, username, headers } =
|
||
await createAuthedUser(request, "tpl-my-data");
|
||
const suffix = Date.now().toString(36);
|
||
|
||
await request.post(`${apiBase}/templates`, {
|
||
headers,
|
||
data: {
|
||
name: `我的模板测试 ${suffix}`,
|
||
mode: "pip",
|
||
estimated_duration: 30,
|
||
description: "我的模板展示测试",
|
||
segments: [
|
||
{
|
||
segment_order: 1,
|
||
duration_min: 5,
|
||
duration_max: 15,
|
||
material_type: "video",
|
||
},
|
||
],
|
||
},
|
||
});
|
||
|
||
await setupAuth(page, accessToken, {
|
||
id: userId,
|
||
email,
|
||
username,
|
||
display_name: "E2E tpl-my-data",
|
||
});
|
||
|
||
await page.goto("/app/my-templates");
|
||
await expect(page.locator(".mt-page")).toBeVisible({
|
||
timeout: 20_000,
|
||
});
|
||
|
||
// 验证卡片容器存在
|
||
const cards = page.locator(".mt-card");
|
||
await expect(cards.first()).toBeVisible({ timeout: 15_000 });
|
||
});
|
||
});
|