- 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
996 lines
40 KiB
TypeScript
996 lines
40 KiB
TypeScript
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 });
|
||
// 设置前端 localStorage(auth-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');
|
||
}
|
||
});
|
||
}); |