Files
novalon-website/e2e/cms-workflow.spec.ts
T
zhangxiang 3e629bd9ee test(e2e): add GA4 tracking, mobile, user journey tests and update visual baselines
- Add GA4 event tracking E2E tests (4 cases: page view, form submit, button click, product page)
- Add mobile-specific E2E tests (16 cases: navigation, form, product browsing)
- Add user journey tests (UJ-01: homepage to contact, UJ-02: product discovery)
- Fix E2E test selectors and assertions for consistency
- Update visual regression baselines across all browsers (chromium/firefox/webkit)
- Update Playwright config for new test files
2026-07-31 20:24:57 +08:00

343 lines
12 KiB
TypeScript

import { test, expect, APIRequestContext } from '@playwright/test';
import { config as dotenvConfig } from 'dotenv';
import path from 'path';
// 加载项目根目录的 .env.local,使 E2E 测试与 dev/preview 服务器使用相同的 CMS_REVALIDATE_SECRET
// Playwright 配置已负责设置 process.env,此处仅做兜底加载,不再 override 以避免覆盖 webServer 环境变量
dotenvConfig({ path: path.resolve(__dirname, '../.env.local') });
// CMS 工作流依赖共享数据库与角色权限,浏览器差异可忽略,仅在 chromium 项目运行以避免跨 project 数据竞争
test.skip(({ browserName }) => browserName !== 'chromium', 'CMS workflow test runs only in chromium');
/**
* CMS 内容发布工作流 E2E 测试
*
* 覆盖完整流程:管理员登录 → 创建草稿 → 提交审核 → 审核通过 → 前台可见
* 以及多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见
*/
const ADMIN_CREDENTIALS = { username: 'admin', password: 'admin123' };
const EDITOR_CREDENTIALS = { username: 'e2e_editor', password: 'editor123' };
const REVIEWER_CREDENTIALS = { username: 'e2e_reviewer', password: 'reviewer123' };
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
const REVALIDATE_SECRET = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
// 跟踪当前测试创建的内容条目 ID,afterEach 只清理当前测试的数据,避免并行/跨 project 误删
const createdItemIds: string[] = [];
test.setTimeout(120000);
async function deleteItemsByIds(request: APIRequestContext, token: string, ids: string[]): Promise<void> {
if (ids.length === 0) return;
await Promise.all(
ids.map((id) =>
request.delete(`${BASE_URL}/api/admin/items?id=${id}`, {
headers: { Authorization: `Bearer ${token}` },
})
)
);
}
async function cleanupStaleE2ENews(request: APIRequestContext, token: string): Promise<void> {
const response = await request.get(`${BASE_URL}/api/admin/items`, {
headers: { Authorization: `Bearer ${token}` },
params: { modelCode: 'news', pageSize: '1000' },
});
if (!response.ok()) {
console.warn('Failed to fetch news items for cleanup:', await response.text());
return;
}
const body = await response.json();
const items = (body.items || body.data || []) as Array<{ id: string; title: string }>;
const staleItems = items.filter((item) => item.title?.startsWith('E2E 测试新闻'));
await deleteItemsByIds(
request,
token,
staleItems.map((item) => item.id)
);
}
test.beforeAll(async ({ request }) => {
// 清理历史残留 E2E 测试数据(仅在 chromium 项目执行,不会误删其他 project 数据)
const token = await loginAdmin(request);
await cleanupStaleE2ENews(request, token);
});
test.afterEach(async ({ request }) => {
const token = await loginAdmin(request);
// 优先按 ID 清理当前测试创建的条目,避免误删其他并行测试的数据
const ids = createdItemIds.splice(0);
await deleteItemsByIds(request, token, ids);
// 再兜底清理历史残留
await cleanupStaleE2ENews(request, token);
});
async function login(
request: APIRequestContext,
credentials: { username: string; password: string }
): Promise<string> {
const loginResponse = await request.post(`${BASE_URL}/api/auth/login`, {
data: credentials,
headers: { 'Content-Type': 'application/json' },
});
expect(loginResponse.ok()).toBeTruthy();
const loginBody = await loginResponse.json();
expect(loginBody.accessToken).toBeTruthy();
return loginBody.accessToken as string;
}
async function loginAdmin(request: APIRequestContext): Promise<string> {
return login(request, ADMIN_CREDENTIALS);
}
async function getNewsModelId(request: APIRequestContext, token: string): Promise<string> {
const response = await request.get(`${BASE_URL}/api/admin/models`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(response.ok()).toBeTruthy();
const models = await response.json();
const newsModel = models.find((m: { code: string }) => m.code === 'news');
expect(newsModel).toBeTruthy();
return newsModel.id;
}
async function createNewsDraft(
request: APIRequestContext,
token: string,
suffix: string,
modelId?: string
): Promise<{ id: string; slug: string; title: string }> {
const title = `E2E 测试新闻 ${suffix}`;
const slug = `e2e-test-news-${suffix}`;
const resolvedModelId = modelId || (await getNewsModelId(request, token));
const response = await request.post(`${BASE_URL}/api/admin/items`, {
data: {
modelId: resolvedModelId,
modelCode: 'news',
title,
slug,
status: 'draft',
data: {
id: slug,
excerpt: `这是 E2E 测试摘要 ${suffix}`,
date: new Date().toISOString().split('T')[0],
category: '公司新闻',
image: '/images/placeholder-news.jpg',
content: `<p>E2E 测试正文内容 ${suffix}</p>`,
featured: false,
},
},
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('draft');
createdItemIds.push(body.id);
return { id: body.id, slug, title };
}
async function submitForReview(
request: APIRequestContext,
token: string,
itemId: string
): Promise<void> {
const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, {
data: { action: 'submit' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('review');
}
async function approveItem(
request: APIRequestContext,
token: string,
itemId: string
): Promise<{ status: string; slug: string }> {
const response = await request.post(`${BASE_URL}/api/admin/items/${itemId}/workflow`, {
data: { action: 'approve' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.status).toBe('published');
return body;
}
async function revalidateNews(request: APIRequestContext, slug: string): Promise<void> {
const response = await request.post(`${BASE_URL}/api/cms/revalidate`, {
data: {
event: 'content.published',
modelCode: 'news',
slug,
secret: REVALIDATE_SECRET,
},
headers: { 'Content-Type': 'application/json' },
});
const responseBody = await response.text();
console.log('[DEBUG] revalidate status=%s body=%s', response.status(), responseBody);
expect(response.ok()).toBeTruthy();
const body = JSON.parse(responseBody);
expect(body.code).toBe(0);
expect(body.data.revalidatedPaths).toContain('/news');
expect(body.data.revalidatedPaths).toContain(`/news/${slug}`);
}
async function ensureRolePermissions(
request: APIRequestContext,
adminToken: string,
roleCode: string,
permissions: Array<{ modelCode: string; action: string }>
): Promise<void> {
const response = await request.put(`${BASE_URL}/api/admin/roles`, {
data: { roleCode, permissions },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${adminToken}`,
},
});
expect(response.ok()).toBeTruthy();
const body = await response.json();
expect(body.roleCode).toBe(roleCode);
expect(body.permissions).toHaveLength(permissions.length);
}
async function verifyNewsVisibleOnFrontend(
page: import('@playwright/test').Page,
request: APIRequestContext,
slug: string,
title: string,
suffix: string
): Promise<void> {
// 先用 API context 拉取本地页面,确认服务端渲染正常
const apiResponse = await request.get(`${BASE_URL}/news/${slug}`);
const apiHtml = await apiResponse.text();
console.log('[DEBUG] api status=%s title=%s', apiResponse.status(), apiHtml.match(/<title>([^<]+)<\/title>/)?.[1]);
// 直接访问详情页验证 ISR 刷新后的内容可见(比列表页更稳定,避免整页缓存波动)
await page.goto(`/news/${slug}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
const currentUrl = page.url();
const h1Text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
console.log('[DEBUG] page.url=%s h1=%s expected title=%s', currentUrl, h1Text, title);
await expect
.poll(
async () => {
const text = (await page.locator('h1').first().textContent().catch(() => '')) || '';
return text.includes(title);
},
{ timeout: 20000, intervals: [1000, 2000, 2000] }
)
.toBe(true);
await expect(page.locator('body')).toContainText(`E2E 测试正文内容 ${suffix}`);
}
test.describe('CMS 内容发布工作流', { tag: '@critical' }, () => {
// 同一 worker 内串行执行,避免共享 DB/角色权限在并行时相互影响
test.describe.configure({ mode: 'serial' });
test('admin 可完成 创建草稿 → 提交审核 → 发布 → 前台可见', async ({ request, page }) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const token = await loginAdmin(request);
const { id, slug, title } = await createNewsDraft(request, token, suffix);
console.log('[DEBUG] created item id=%s slug=%s title=%s', id, slug, title);
await submitForReview(request, token, id);
const afterApprove = await approveItem(request, token, id);
console.log('[DEBUG] after approve status=%s slug=%s', afterApprove.status, afterApprove.slug);
await revalidateNews(request, slug);
console.log('[DEBUG] revalidate done, navigating to /news/%s', slug);
await verifyNewsVisibleOnFrontend(page, request, slug, title, suffix);
});
test('非法状态流转会被拦截', async ({ request }) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const token = await loginAdmin(request);
const { id } = await createNewsDraft(request, token, suffix);
const response = await request.post(`${BASE_URL}/api/admin/items/${id}/workflow`, {
data: { action: 'approve' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(response.status()).toBe(400);
const body = await response.json();
expect(body.error).toContain('当前状态 draft 不允许审核通过');
});
test('多角色权限分离:管理员配置权限 → 编辑创建/提交 → 审核员发布 → 前台可见', async ({
request,
page,
}) => {
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
// 1. 管理员登录并获取模型 ID,然后配置角色权限
const adminToken = await loginAdmin(request);
const newsModelId = await getNewsModelId(request, adminToken);
await ensureRolePermissions(request, adminToken, 'content_editor', [
{ modelCode: 'news', action: 'create' },
{ modelCode: 'news', action: 'read' },
{ modelCode: 'news', action: 'update' },
]);
await ensureRolePermissions(request, adminToken, 'reviewer', [
{ modelCode: 'news', action: 'read' },
{ modelCode: 'news', action: 'publish' },
]);
// 2. 编辑创建草稿并提交审核(模型 ID 由管理员提供,编辑无需 content-model 读取权限)
const editorToken = await login(request, EDITOR_CREDENTIALS);
const { id, slug, title } = await createNewsDraft(request, editorToken, suffix, newsModelId);
await submitForReview(request, editorToken, id);
// 3. 编辑尝试直接审核通过,应被 403 拒绝
const editorApproveResponse = await request.post(
`${BASE_URL}/api/admin/items/${id}/workflow`,
{
data: { action: 'approve' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${editorToken}`,
},
}
);
expect(editorApproveResponse.status()).toBe(403);
// 4. 审核员审核通过
const reviewerToken = await login(request, REVIEWER_CREDENTIALS);
await approveItem(request, reviewerToken, id);
// 5. 刷新 ISR 缓存并验证前台可见
await revalidateNews(request, slug);
await verifyNewsVisibleOnFrontend(page, request, slug, title, suffix);
});
});