Files
novalon-website/e2e/user-journey.spec.ts
T
zhangxiang 3e93317988 fix(e2e): resolve admin user journey authentication and stabilize flaky tests
- Fix loginAdminAndSetCookie to set both cookie (middleware) and localStorage (auth-context)
- Add page navigation before localStorage evaluate to avoid SecurityError
- Update Playwright webServer to npm run dev for API route support
- Fix UJ-10 CSS selector parsing error (text= regex mixed with CSS)
- Fix cases-filter flaky test (getByRole('radio') → locator('button[role="radio"]'))
- Update test-strategy-plan.md: mark UJ-03/06/07 as  completed
- Update README.md with admin fix progress record
2026-08-03 08:36:50 +08:00

996 lines
40 KiB
TypeScript
Raw 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.
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
/**
* UJ: 用户旅程 E2E 测试
*
* 覆盖范围:
* UJ-01: 潜在客户从首页到联系表单的完整旅程
* UJ-02: 行业客户浏览解决方案到产品的旅程
* UJ-03: 内容管理员完整旅程(登录 → 仪表盘 → 内容管理 → 编辑 → 前台可见)
* UJ-04: 新闻读者浏览旅程(列表 → 详情 → 返回)
* UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情)
* UJ-06: 多角色管理员权限旅程(超级管理员 → 角色 → 权限 → 用户)
* UJ-07: 媒体管理员上传旅程
* UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)
*/
test.setTimeout(90000);
// ==================== 辅助函数 ====================
/**
* 关闭 Cookie 同意横幅(如果存在)
*/
async function closeCookieBanner(page: Page) {
const acceptAllButton = page.locator('button:has-text("接受所有")').first();
if (await acceptAllButton.count() > 0 && await acceptAllButton.isVisible()) {
await acceptAllButton.click();
await page.waitForTimeout(500);
}
}
/**
* 从桌面主导航的产品下拉菜单中导航到指定链接
*/
async function navigateFromProductDropdown(page: Page, linkText: string) {
const desktopNav = page.locator('[data-testid="desktop-navigation"]').first();
const productDropdown = desktopNav.locator('button:has-text("产品")').first();
await expect(productDropdown).toBeVisible({ timeout: 5000 });
await productDropdown.hover();
await page.waitForTimeout(300);
const targetLink = desktopNav.locator(`a:has-text("${linkText}")`).first();
await expect(targetLink).toBeVisible({ timeout: 5000 });
// 鼠标悬停在链接上,防止下拉菜单因 mouseleave 关闭
await targetLink.hover();
await page.waitForTimeout(200);
await targetLink.click();
}
// ==================== UJ-01: 潜在客户从首页到联系表单的完整旅程 ====================
test.describe('UJ-01: 潜在客户从首页到联系表单的完整旅程', () => {
test('@journey @regression 访客从首页浏览产品并提交联系表单', async ({ page }) => {
// === Step 1: 访问首页,验证 Hero 区域 ===
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
await closeCookieBanner(page);
// 验证 Hero 区域可见
const heroTitle = page.locator('h1').first();
await expect(heroTitle).toBeVisible({ timeout: 15000 });
const titleText = await heroTitle.textContent();
expect(titleText).toBeTruthy();
expect(titleText!.length).toBeGreaterThan(0);
// 验证 Hero 描述段落可见
const heroDescription = page.locator('main p').first();
await expect(heroDescription).toBeVisible();
// === Step 2: 通过导航下拉菜单进入产品页面 ===
await navigateFromProductDropdown(page, '查看全部产品');
// === Step 3: 验证产品页面加载成功 ===
await page.waitForURL(/\/products/, { timeout: 10000 });
await expect(page).toHaveURL(/\/products/);
await page.waitForTimeout(2000);
// 验证产品页面有标题
const productsTitle = page.locator('h1, h2').first();
await expect(productsTitle).toBeVisible();
const productsTitleText = await productsTitle.textContent();
expect(productsTitleText).toContain('产品');
// 验证产品卡片存在
const productLinks = page.locator('a[href*="/products/"]:not([href$="/products"])');
const linkCount = await productLinks.count();
expect(linkCount).toBeGreaterThan(0);
// === Step 4: 点击产品进入详情页 ===
const firstProductLink = productLinks.first();
await firstProductLink.click();
await page.waitForURL(/\/products\/.+/, { timeout: 10000 });
await expect(page).toHaveURL(/\/products\/.+/);
await page.waitForTimeout(2000);
// === Step 5: 验证产品详情页的四层叙事结构 ===
// L1 Hero: 产品标题可见
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// L2 Value: 产品详情页应包含核心功能或产品价值区域
const mainContent = page.locator('main').first();
await expect(mainContent).toBeVisible();
const mainText = await mainContent.textContent();
expect(mainText!.length).toBeGreaterThan(200);
// 验证功能/价值相关内容存在(核心功能或产品价值标签)
const valueIndicators = ['核心功能', '产品价值', '实施流程'];
let hasValueSection = false;
for (const indicator of valueIndicators) {
if (mainText!.includes(indicator)) {
hasValueSection = true;
break;
}
}
expect(hasValueSection).toBe(true);
// L3 Trust: 验证信任相关内容(客户案例或资质认证)
const trustIndicators = ['客户案例', '资质认证', '可衡量的成果'];
let hasTrustSection = false;
for (const indicator of trustIndicators) {
if (mainText!.includes(indicator)) {
hasTrustSection = true;
break;
}
}
expect(hasTrustSection).toBe(true);
// L4 CTA: 验证 CTA 区域存在(包含"预约演示"或"开始使用"链接)
const ctaLink = page.locator('a[href="/contact"]').first();
await expect(ctaLink).toBeVisible();
// === Step 6: 导航到联系页面 ===
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
// === Step 7: 填写联系表单 ===
// 验证表单字段可见
const nameInput = page.locator('[data-testid="name-input"]').first();
await expect(nameInput).toBeVisible({ timeout: 5000 });
await page.locator('[data-testid="name-input"]').fill('潜在客户');
await page.locator('[data-testid="phone-input"]').fill('13912345678');
await page.locator('[data-testid="email-input"]').fill('prospect@example.com');
await page.locator('[data-testid="subject-input"]').fill('产品咨询 - ERP 管理系统');
await page.locator('[data-testid="message-input"]').fill('您好,我在浏览了贵司的 ERP 产品后非常感兴趣,希望进一步了解产品功能和实施细节,请安排专人联系。');
// === Step 8: 拦截 API 并提交表单 ===
let routeHit = false;
await page.route('/api/contact', async (route) => {
routeHit = true;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ success: true, message: '发送成功' }),
});
});
const submitButton = page.locator('[data-testid="submit-button"]').first();
await submitButton.scrollIntoViewIfNeeded();
await page.waitForTimeout(300);
// 隐藏可能覆盖底部元素的固定返回顶部按钮
await page.evaluate(() => {
const backToTop = document.querySelector('[aria-label="返回顶部"]') as HTMLElement | null;
if (backToTop) backToTop.style.display = 'none';
});
// 使用原生 DOM click() 触发提交
await page.evaluate(() => {
const btn = document.querySelector('[data-testid="submit-button"]') as HTMLButtonElement | null;
btn?.click();
});
// === Step 9: 验证成功消息 ===
await page.waitForTimeout(2000);
console.log('UJ-01 route hit:', routeHit);
// 验证成功提示存在
const bodyText = await page.locator('body').textContent();
const hasSuccessIndicator =
bodyText!.includes('消息已发送') ||
bodyText!.includes('感谢') ||
bodyText!.includes('发送成功') ||
bodyText!.includes('提交成功');
expect(hasSuccessIndicator).toBe(true);
});
});
// ==================== UJ-02: 行业客户浏览解决方案到产品的旅程 ====================
test.describe('UJ-02: 行业客户浏览解决方案到产品的旅程', () => {
test('@journey @regression 客户从解决方案浏览到产品详情', async ({ page }) => {
// === Step 1: 访问首页 ===
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
// 验证首页加载成功
const heroTitle = page.locator('h1').first();
await expect(heroTitle).toBeVisible({ timeout: 10000 });
// === Step 2: 导航到解决方案页面 ===
// 通过桌面导航的解决方案下拉菜单
const desktopNav = page.locator('[data-testid="desktop-navigation"]').first();
const solutionsDropdown = desktopNav.locator('button:has-text("解决方案")').first();
await expect(solutionsDropdown).toBeVisible({ timeout: 5000 });
await solutionsDropdown.hover();
await page.waitForTimeout(300);
const viewAllSolutions = desktopNav.locator('a:has-text("查看全部解决方案")').first();
await expect(viewAllSolutions).toBeVisible({ timeout: 5000 });
await viewAllSolutions.hover();
await page.waitForTimeout(200);
await viewAllSolutions.click();
await page.waitForURL(/\/solutions/, { timeout: 10000 });
await expect(page).toHaveURL(/\/solutions/);
await page.waitForTimeout(2000);
// 验证解决方案页面加载成功
const solutionsTitle = page.locator('h1, h2').first();
await expect(solutionsTitle).toBeVisible();
// === Step 3: 点击一个解决方案 ===
const solutionLinks = page.locator('a[href*="/solutions/"]:not([href$="/solutions"])');
const solutionLinkCount = await solutionLinks.count();
expect(solutionLinkCount).toBeGreaterThan(0);
const firstSolutionLink = solutionLinks.first();
const solutionHref = await firstSolutionLink.getAttribute('href');
console.log('UJ-02 clicking solution link:', solutionHref);
await firstSolutionLink.click();
await page.waitForURL(/\/solutions\/.+/, { timeout: 10000 });
await expect(page).toHaveURL(/\/solutions\/.+/);
await page.waitForTimeout(2000);
// === Step 4: 验证解决方案详情页 ===
const solutionDetailTitle = page.locator('h1').first();
await expect(solutionDetailTitle).toBeVisible({ timeout: 10000 });
const solutionDetailText = await solutionDetailTitle.textContent();
expect(solutionDetailText).toBeTruthy();
expect(solutionDetailText!.length).toBeGreaterThan(0);
// 验证解决方案详情页包含核心内容区域
const solutionMain = page.locator('main').first();
await expect(solutionMain).toBeVisible();
const solutionMainText = await solutionMain.textContent();
expect(solutionMainText!.length).toBeGreaterThan(200);
// 验证包含行业痛点/解决方案/价值主张区域
const solutionSections = ['行业痛点', '解决方案', 'Value Proposition'];
let hasSolutionSection = false;
for (const section of solutionSections) {
if (solutionMainText!.includes(section)) {
hasSolutionSection = true;
break;
}
}
expect(hasSolutionSection).toBe(true);
// === Step 5: 通过"查看产品"链接进入产品页面 ===
// 解决方案详情页的 Hero 区有"查看产品"链接
const viewProductsLink = page.locator('a[href="/products"]').first();
await expect(viewProductsLink).toBeVisible({ timeout: 5000 });
await viewProductsLink.click();
await page.waitForURL(/\/products/, { timeout: 10000 });
await expect(page).toHaveURL(/\/products/);
await page.waitForTimeout(2000);
// 验证产品页面加载成功
const productsPageTitle = page.locator('h1, h2').first();
await expect(productsPageTitle).toBeVisible();
// === Step 6: 点击产品进入详情页 ===
const productDetailLinks = page.locator('a[href*="/products/"]:not([href$="/products"])');
const productDetailLinkCount = await productDetailLinks.count();
expect(productDetailLinkCount).toBeGreaterThan(0);
const firstProductDetailLink = productDetailLinks.first();
const productHref = await firstProductDetailLink.getAttribute('href');
console.log('UJ-02 clicking product link:', productHref);
await firstProductDetailLink.click();
await page.waitForURL(/\/products\/.+/, { timeout: 10000 });
await expect(page).toHaveURL(/\/products\/.+/);
await page.waitForTimeout(2000);
// 验证产品详情页
const productDetailTitle = page.locator('h1').first();
await expect(productDetailTitle).toBeVisible({ timeout: 10000 });
const productDetailTitleText = await productDetailTitle.textContent();
expect(productDetailTitleText).toBeTruthy();
expect(productDetailTitleText!.length).toBeGreaterThan(0);
// 验证产品详情页包含完整内容
const productDetailMain = page.locator('main').first();
const productDetailText = await productDetailMain.textContent();
expect(productDetailText!.length).toBeGreaterThan(200);
// 验证存在 CTA 链接
const contactLink = page.locator('a[href="/contact"]').first();
await expect(contactLink).toBeVisible();
});
});
// ==================== UJ-04: 新闻读者浏览旅程 ====================
test.describe('UJ-04: 新闻读者浏览旅程(列表 → 详情 → 返回)', () => {
test('@journey @regression 读者从新闻列表浏览到详情页并返回', async ({ page }) => {
// === Step 1: 访问新闻列表 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
// 验证新闻列表页加载成功
const newsTitle = page.locator('h1').first();
await expect(newsTitle).toBeVisible({ timeout: 10000 });
const newsTitleText = await newsTitle.textContent();
expect(newsTitleText).toBeTruthy();
// 验证新闻列表存在文章条目
const newsListItems = page.locator('a[href*="/news/"]').first();
await expect(newsListItems).toBeVisible({ timeout: 5000 });
// 获取新闻列表中的文章链接数量
const articleLinks = page.locator('a[href*="/news/"]');
const articleCount = await articleLinks.count();
// 可能有些链接是导航/面包屑类,应该有至少1个文章链接
console.log('UJ-04 article links found:', articleCount);
if (articleCount > 0) {
// === Step 2: 点击第一篇新闻进入详情页 ===
const firstArticle = articleLinks.first();
await firstArticle.click();
// 等待导航到新闻详情页
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// === Step 3: 验证新闻详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// 验证新闻详情页有发布时间或内容区域
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(50);
// === Step 4: 返回新闻列表 ===
// 通过面包屑导航返回
const breadcrumbNewsLink = page.locator('nav a[href="/news"], [aria-label*="新闻"] a, a:has-text("新闻")').first();
if (await breadcrumbNewsLink.count() > 0 && await breadcrumbNewsLink.isVisible()) {
await breadcrumbNewsLink.click();
await page.waitForURL(/\/news\/?$/, { timeout: 10000 });
await page.waitForTimeout(1000);
} else {
// 直接导航回新闻列表
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1000);
}
// 验证已返回新闻列表
await expect(page).toHaveURL(/\/news\/?$/);
} else {
// 没有文章链接时,验证页面内容存在
console.log('UJ-04: No article links found, verifying page content');
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
});
});
// ==================== UJ-05: 案例浏览者筛选旅程 ====================
test.describe('UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情)', () => {
test('@journey @regression 客户从案例列表筛选并查看详情', async ({ page }) => {
// === Step 1: 访问案例列表 ===
await page.goto('/cases', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
// 验证案例列表页加载成功
const casesTitle = page.locator('h1').first();
await expect(casesTitle).toBeVisible({ timeout: 10000 });
const casesTitleText = await casesTitle.textContent();
expect(casesTitleText).toBeTruthy();
console.log('UJ-05 cases page title:', casesTitleText);
// === Step 2: 尝试使用行业筛选(如果有筛选功能) ===
const industryFilters = page.locator(
'button:has-text("全部"), button:has-text("金融"), button:has-text("制造"), ' +
'button:has-text("医疗"), button:has-text("零售"), button:has-text("教育"), ' +
'button:has-text("科技"), [data-testid*="filter"], [data-testid*="industry"]'
);
if (await industryFilters.count() > 0) {
// 点击第一个可见的筛选按钮(非"全部")
const filterButtons = page.locator(
'button:has-text("金融"), button:has-text("制造"), ' +
'button:has-text("医疗"), button:has-text("零售"), ' +
'button:has-text("科技")'
);
const filterCount = await filterButtons.count();
if (filterCount > 0) {
const firstFilter = filterButtons.first();
const filterText = await firstFilter.textContent();
console.log('UJ-05 clicking filter:', filterText);
await firstFilter.click();
await page.waitForTimeout(1500);
}
}
// === Step 3: 点击案例进入详情页 ===
const caseLinks = page.locator('a[href*="/cases/"]');
const caseLinkCount = await caseLinks.count();
console.log('UJ-05 case links found:', caseLinkCount);
if (caseLinkCount > 0) {
const firstCase = caseLinks.first();
await firstCase.click();
await page.waitForURL(/\/cases\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// === Step 4: 验证案例详情页 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
// 验证案例详情页包含内容
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 验证包含案例相关标识
const hasCaseIndicator = detailText!.includes('案例') ||
detailText!.includes('成果') ||
detailText!.includes('挑战') ||
detailText!.includes('解决方案');
expect(hasCaseIndicator).toBe(true);
} else {
// 没有案例链接时,验证页面内容存在
console.log('UJ-05: No case links found, verifying page content');
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
});
});
// ==================== UJ-10: 深度搜索者旅程 ====================
test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)', () => {
test('@journey @regression 用户通过分类筛选浏览多篇文章并发现相关内容', async ({ page }) => {
// === Step 1: 访问新闻列表 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
// 验证新闻列表页加载成功
const newsTitle = page.locator('h1').first();
await expect(newsTitle).toBeVisible({ timeout: 10000 });
const newsTitleText = await newsTitle.textContent();
expect(newsTitleText).toBeTruthy();
console.log('UJ-10 news title:', newsTitleText);
// 验证分类筛选按钮存在
const categoryButtons = page.locator('button:has-text("公司新闻"), button:has-text("研发动态")');
const categoryCount = await categoryButtons.count();
console.log('UJ-10 category buttons found:', categoryCount);
expect(categoryCount).toBeGreaterThan(0);
// === Step 2: 点击"公司新闻"分类筛选 ===
const companyNewsBtn = page.locator('button:has-text("公司新闻")').first();
if (await companyNewsBtn.isVisible()) {
await companyNewsBtn.click();
await page.waitForTimeout(1000);
// 验证筛选后文章列表更新
const filteredArticles = page.locator('a[href*="/news/"]');
const filteredCount = await filteredArticles.count();
console.log('UJ-10 company news articles:', filteredCount);
}
// === Step 3: 阅读第一篇新闻文章 ===
const firstArticle = page.locator('a[href*="/news/"]').first();
if (await firstArticle.count() > 0 && await firstArticle.isVisible()) {
const firstArticleHref = await firstArticle.getAttribute('href');
console.log('UJ-10 reading first article:', firstArticleHref);
await firstArticle.click();
// 等待导航到新闻详情页
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// 验证新闻详情页加载成功
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
const detailTitleText = await detailTitle.textContent();
expect(detailTitleText).toBeTruthy();
expect(detailTitleText!.length).toBeGreaterThan(0);
console.log('UJ-10 first article title:', detailTitleText);
// 验证详情页有正文内容
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 软验证:页面包含发布日期(非关键断言,不阻止测试通过)
const dateIndicator = page.locator('time, [datetime], [aria-label*="date"]').first();
const hasDateElement = await dateIndicator.count() > 0;
if (hasDateElement) {
await expect(dateIndicator).toBeVisible();
}
// 验证内容发现:相关新闻或推荐阅读区域
const relatedSection = page.locator(
'text=/相关新闻|推荐阅读|相关文章|RELATED|more news/i'
).first();
if (await relatedSection.count() > 0) {
await expect(relatedSection).toBeVisible();
console.log('UJ-10 related news section found');
}
}
// === Step 4: 返回新闻列表,切换分类浏览 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 切换到"研发动态"分类
const devNewsBtn = page.locator('button:has-text("研发动态")').first();
if (await devNewsBtn.isVisible()) {
await devNewsBtn.click();
await page.waitForTimeout(1000);
}
// === Step 5: 阅读第二篇新闻文章 ===
const secondArticle = page.locator('a[href*="/news/"]').first();
if (await secondArticle.count() > 0 && await secondArticle.isVisible()) {
const secondArticleHref = await secondArticle.getAttribute('href');
console.log('UJ-10 reading second article:', secondArticleHref);
await secondArticle.click();
await page.waitForURL(/\/news\/.+/, { timeout: 10000 });
await page.waitForTimeout(2000);
// 验证第二篇新闻详情页
const secondDetailTitle = page.locator('h1').first();
await expect(secondDetailTitle).toBeVisible({ timeout: 10000 });
const secondDetailTitleText = await secondDetailTitle.textContent();
expect(secondDetailTitleText).toBeTruthy();
expect(secondDetailTitleText!.length).toBeGreaterThan(0);
console.log('UJ-10 second article title:', secondDetailTitleText);
// 验证详情页有正文内容
const secondDetailContent = page.locator('main').first();
await expect(secondDetailContent).toBeVisible();
const secondDetailText = await secondDetailContent.textContent();
expect(secondDetailText!.length).toBeGreaterThan(100);
}
// === Step 6: 返回全部新闻列表,验证内容发现能力 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 点击"全部"恢复显示所有新闻
const allBtn = page.locator('button:has-text("全部")').first();
if (await allBtn.isVisible()) {
await allBtn.click();
await page.waitForTimeout(1000);
}
// 验证所有新闻文章显示
const allArticles = page.locator('a[href*="/news/"]');
const allCount = await allArticles.count();
console.log('UJ-10 all articles count:', allCount);
expect(allCount).toBeGreaterThanOrEqual(1);
// 验证搜索功能(内容发现工具)
const searchInput = page.locator('input[placeholder*="搜索"]').first();
if (await searchInput.isVisible()) {
await searchInput.fill('数字化');
await page.waitForTimeout(1000);
const searchResults = page.locator('a[href*="/news/"]');
const searchResultCount = await searchResults.count();
console.log('UJ-10 search results:', searchResultCount);
// 搜索功能正常工作,返回结果
expect(searchResultCount).toBeGreaterThanOrEqual(0);
}
// 最终验证:页面加载正常,内容可发现
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
console.log('UJ-10 completed successfully');
});
});
// ==================== 辅助函数:Admin 登录 ====================
const ADMIN_CREDENTIALS = { username: 'admin', password: 'admin123' };
const BASE_URL = process.env.E2E_BASE_URL || 'http://localhost:3000';
/**
* 通过 API 登录 admin 并设置浏览器 cookie 和 localStorage
* 使后续页面导航通过中间件认证,且前端 auth-context 能读取到用户状态。
*/
async function loginAdminAndSetCookie(page: Page, request: APIRequestContext): Promise<string> {
const loginResponse = await request.post(`${BASE_URL}/api/auth/login`, {
data: ADMIN_CREDENTIALS,
headers: { 'Content-Type': 'application/json' },
});
expect(loginResponse.ok()).toBeTruthy();
const body = await loginResponse.json();
expect(body.accessToken).toBeTruthy();
const token = body.accessToken as string;
// 设置中间件 cookie
await page.context().addCookies([
{
name: 'novalon_token',
value: token,
domain: 'localhost',
path: '/',
httpOnly: true,
sameSite: 'Lax',
},
]);
// 先导航到 admin 登录页建立同源上下文,再设置 localStorage
await page.goto(`${BASE_URL}/admin/login`, { waitUntil: 'domcontentloaded', timeout: 30000 });
// 设置前端 localStorageauth-context 读取此值判断登录状态)
await page.evaluate(
({ token, user }) => {
localStorage.setItem('novalon_admin_token', token);
localStorage.setItem('novalon_admin_user', JSON.stringify(user));
},
{ token, user: body.user }
);
return token;
}
// ==================== UJ-03: 内容管理员完整旅程 ====================
test.describe('UJ-03: 内容管理员完整旅程(登录 → 仪表盘 → 内容管理 → 编辑 → 前台可见)', () => {
test('@journey @regression @admin 管理员登录后浏览仪表盘、内容列表、编辑页面,并验证前台可见', async ({
page,
request,
}) => {
test.setTimeout(120000);
// === Step 1: 通过 API 登录并设置 cookie ===
const token = await loginAdminAndSetCookie(page, request);
expect(token).toBeTruthy();
console.log('UJ-03 admin login successful');
// === Step 2: 访问 Admin 仪表盘 ===
await page.goto('/admin', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证仪表盘加载成功(未重定向到登录页)
const currentUrl = page.url();
expect(currentUrl).not.toContain('/admin/login');
console.log('UJ-03 dashboard URL:', currentUrl);
// 验证仪表盘标题或内容存在
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
const hasDashboardContent =
bodyText!.includes('仪表盘') ||
bodyText!.includes('Dashboard') ||
bodyText!.includes('admin') ||
bodyText!.includes('管理');
expect(hasDashboardContent).toBe(true);
// === Step 3: 导航到新闻内容管理 ===
const newsContentLink = page.locator('a[href*="/admin/content/news"]').first();
if (await newsContentLink.isVisible()) {
await newsContentLink.click();
await page.waitForURL(/\/admin\/content\/news/, { timeout: 10000 });
await page.waitForTimeout(2000);
} else {
// 直接导航
await page.goto('/admin/content/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
}
// 验证内容列表页加载成功
const contentUrl = page.url();
expect(contentUrl).toContain('/admin/content/news');
console.log('UJ-03 content list URL:', contentUrl);
// 验证页面有内容
const contentBodyText = await page.locator('body').textContent();
expect(contentBodyText!.length).toBeGreaterThan(0);
// === Step 4: 通过 API 创建一篇测试新闻 ===
const suffix = `uj03-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const title = `UJ-03 测试新闻 ${suffix}`;
const slug = `uj03-test-news-${suffix}`;
// 获取模型 ID
const modelsResponse = await request.get(`${BASE_URL}/api/admin/models`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(modelsResponse.ok()).toBeTruthy();
const models = await modelsResponse.json();
const newsModel = models.find((m: { code: string }) => m.code === 'news');
expect(newsModel).toBeTruthy();
// 创建新闻
const createResponse = await request.post(`${BASE_URL}/api/admin/items`, {
data: {
modelId: newsModel.id,
modelCode: 'news',
title,
slug,
status: 'draft',
data: {
id: slug,
excerpt: `UJ-03 测试摘要 ${suffix}`,
date: new Date().toISOString().split('T')[0],
category: '公司新闻',
content: `<p>UJ-03 测试正文内容 ${suffix}</p>`,
featured: false,
},
},
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
expect(createResponse.ok()).toBeTruthy();
const createdItem = await createResponse.json();
const itemId = createdItem.id;
console.log('UJ-03 created item:', itemId, slug);
// === Step 5: 导航到编辑页面 ===
await page.goto(`/admin/content/news/${itemId}`, {
waitUntil: 'domcontentloaded',
timeout: 30000,
});
await page.waitForTimeout(2000);
// 验证编辑页面加载成功
const editUrl = page.url();
expect(editUrl).toContain(itemId);
const editBodyText = await page.locator('body').textContent();
expect(editBodyText!.length).toBeGreaterThan(0);
console.log('UJ-03 editor page loaded');
// === Step 6: 发布内容(API ===
// 提交审核
const submitResponse = await request.post(
`${BASE_URL}/api/admin/items/${itemId}/workflow`,
{
data: { action: 'submit' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
);
expect(submitResponse.ok()).toBeTruthy();
// 审核通过
const approveResponse = await request.post(
`${BASE_URL}/api/admin/items/${itemId}/workflow`,
{
data: { action: 'approve' },
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}
);
expect(approveResponse.ok()).toBeTruthy();
// 刷新 ISR 缓存
const revalidateSecret = process.env.CMS_REVALIDATE_SECRET || 'e2e-test-secret';
await request.post(`${BASE_URL}/api/cms/revalidate`, {
data: {
event: 'content.published',
modelCode: 'news',
slug,
secret: revalidateSecret,
},
headers: { 'Content-Type': 'application/json' },
});
console.log('UJ-03 item published and revalidated');
// === Step 7: 验证前台可见 ===
await page.goto(`/news/${slug}`, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证新闻详情页加载成功且包含标题
await expect
.poll(
async () => {
const h1Text =
(await page.locator('h1').first().textContent().catch(() => '')) || '';
return h1Text.includes(title);
},
{ timeout: 20000, intervals: [1000, 2000, 2000] }
)
.toBe(true);
// 验证正文内容
await expect(page.locator('body')).toContainText(`UJ-03 测试正文内容 ${suffix}`);
console.log('UJ-03 frontend verification passed');
// === Step 8: 清理测试数据 ===
await request.delete(`${BASE_URL}/api/admin/items?id=${itemId}`, {
headers: { Authorization: `Bearer ${token}` },
});
console.log('UJ-03 cleanup completed');
});
});
// ==================== UJ-06: 多角色管理员权限旅程 ====================
test.describe('UJ-06: 多角色管理员权限旅程(超级管理员 → 角色 → 权限 → 用户)', () => {
test('@journey @regression @admin 管理员访问角色管理和用户管理页面', async ({ page, request }) => {
test.setTimeout(90000);
// === Step 1: 登录并设置 cookie ===
const token = await loginAdminAndSetCookie(page, request);
expect(token).toBeTruthy();
console.log('UJ-06 admin login successful');
// === Step 2: 访问角色管理页面 ===
await page.goto('/admin/roles', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证未重定向到登录页
const rolesUrl = page.url();
expect(rolesUrl).not.toContain('/admin/login');
console.log('UJ-06 roles URL:', rolesUrl);
// 验证角色页面加载成功
const rolesBodyText = await page.locator('body').textContent();
expect(rolesBodyText!.length).toBeGreaterThan(0);
// 验证角色列表存在
const hasRoleContent =
rolesBodyText!.includes('角色') ||
rolesBodyText!.includes('super_admin') ||
rolesBodyText!.includes('权限');
expect(hasRoleContent).toBe(true);
// === Step 3: 通过 API 验证角色数据 ===
const rolesResponse = await request.get(`${BASE_URL}/api/admin/roles`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(rolesResponse.ok()).toBeTruthy();
const rolesData = await rolesResponse.json();
const roles = rolesData.roles || rolesData;
expect(Array.isArray(roles)).toBe(true);
expect(roles.length).toBeGreaterThanOrEqual(5); // super_admin, content_admin, content_editor, reviewer, readonly
console.log('UJ-06 roles found:', roles.length);
// 验证关键角色存在
const roleCodes = roles.map((r: { code: string }) => r.code);
expect(roleCodes).toContain('super_admin');
expect(roleCodes).toContain('content_admin');
expect(roleCodes).toContain('content_editor');
expect(roleCodes).toContain('reviewer');
expect(roleCodes).toContain('readonly');
// === Step 4: 访问用户管理页面 ===
await page.goto('/admin/users', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证未重定向到登录页
const usersUrl = page.url();
expect(usersUrl).not.toContain('/admin/login');
console.log('UJ-06 users URL:', usersUrl);
// 验证用户页面加载成功
const usersBodyText = await page.locator('body').textContent();
expect(usersBodyText!.length).toBeGreaterThan(0);
// 验证用户列表存在
const hasUserContent =
usersBodyText!.includes('用户') ||
usersBodyText!.includes('admin') ||
usersBodyText!.includes('角色');
expect(hasUserContent).toBe(true);
// === Step 5: 通过 API 验证用户数据 ===
const usersResponse = await request.get(`${BASE_URL}/api/admin/users`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(usersResponse.ok()).toBeTruthy();
const usersData = await usersResponse.json();
const users = usersData.users || usersData.data || usersData;
expect(Array.isArray(users)).toBe(true);
console.log('UJ-06 users found:', users.length);
});
});
// ==================== UJ-07: 媒体管理员上传旅程 ====================
test.describe('UJ-07: 媒体管理员上传旅程', () => {
test('@journey @regression @admin 管理员访问媒体管理页面并上传文件', async ({ page, request }) => {
test.setTimeout(90000);
// === Step 1: 登录并设置 cookie ===
const token = await loginAdminAndSetCookie(page, request);
expect(token).toBeTruthy();
console.log('UJ-07 admin login successful');
// === Step 2: 访问媒体管理页面 ===
await page.goto('/admin/media', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// 验证未重定向到登录页
const mediaUrl = page.url();
expect(mediaUrl).not.toContain('/admin/login');
console.log('UJ-07 media URL:', mediaUrl);
// 验证媒体页面加载成功
const mediaBodyText = await page.locator('body').textContent();
expect(mediaBodyText!.length).toBeGreaterThan(0);
// 验证媒体管理相关元素存在
const hasMediaContent =
mediaBodyText!.includes('媒体') ||
mediaBodyText!.includes('上传') ||
mediaBodyText!.includes('素材') ||
mediaBodyText!.includes('media');
expect(hasMediaContent).toBe(true);
// === Step 3: 通过 API 获取媒体列表 ===
const mediaResponse = await request.get(`${BASE_URL}/api/admin/media`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(mediaResponse.ok()).toBeTruthy();
const mediaData = await mediaResponse.json();
const mediaItems = mediaData.items || [];
console.log('UJ-07 existing media items:', mediaItems.length);
// === Step 4: 上传测试文件(通过 API POST /api/admin/media 带 FormData ===
const testFileName = `uj07-test-${Date.now()}.txt`;
const testFileContent = 'UJ-07 test file content';
const uploadResponse = await request.post(`${BASE_URL}/api/admin/media`, {
multipart: {
file: {
name: testFileName,
mimeType: 'text/plain',
buffer: Buffer.from(testFileContent, 'utf-8'),
},
},
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(uploadResponse.ok()).toBeTruthy();
const uploadedFile = await uploadResponse.json();
// 单文件上传返回资产对象本身
expect(uploadedFile.id).toBeTruthy();
console.log('UJ-07 uploaded file:', uploadedFile.id, uploadedFile.name);
// === Step 5: 验证上传文件在媒体列表中可见 ===
const mediaAfterUploadResponse = await request.get(`${BASE_URL}/api/admin/media`, {
headers: { Authorization: `Bearer ${token}` },
});
expect(mediaAfterUploadResponse.ok()).toBeTruthy();
const mediaAfterUpload = await mediaAfterUploadResponse.json();
const mediaAfterItems = mediaAfterUpload.items || [];
const foundUploaded = mediaAfterItems.some(
(item: { name?: string; url?: string }) =>
item.name === testFileName || (item.url && item.url.includes(testFileName))
);
expect(foundUploaded).toBe(true);
console.log('UJ-07 uploaded file verified in media list');
// === Step 6: 清理上传文件 ===
if (uploadedFile.id) {
await request.delete(`${BASE_URL}/api/admin/media?id=${uploadedFile.id}`, {
headers: { Authorization: `Bearer ${token}` },
});
console.log('UJ-07 cleanup completed');
}
});
});