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 { 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 { 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 { 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 { return login(request, ADMIN_CREDENTIALS); } async function getNewsModelId(request: APIRequestContext, token: string): Promise { 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: `

E2E 测试正文内容 ${suffix}

`, 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 { 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 { 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 { 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 { // 先用 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>/)?.[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); }); });