Files
novalon-website/e2e/user-journey.spec.ts
T
zhangxiang 6bb7c557ee test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注
  skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets
  等复用桩。
- 集成层:config/test/jest.integration.config.js + tests-integration/ 真库
  一次性 SQLite 用例,teardown 守卫开发库指纹。
- 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、
  check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse
  配置收敛。
2026-09-28 10:48:08 +08:00

920 lines
38 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 './fixtures';
/**
* UJ: 用户旅程 E2E 测试
*
* 覆盖范围:
* UJ-01: 潜在客户从首页到联系表单的完整旅程
* UJ-02: 行业客户浏览解决方案到产品的旅程
* UJ-03: 内容管理员完整旅程(登录 → 仪表盘 → 内容管理 → 编辑 → 前台可见)
* UJ-04: 新闻读者浏览旅程(列表 → 详情 → 返回)
* UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情)
* UJ-06: 多角色管理员权限旅程(超级管理员 → 角色 → 权限 → 用户)
* UJ-07: 媒体管理员上传旅程
* UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)
*/
test.setTimeout(90000);
// 本套件为桌面旅程(desktop-navigation 在 md 断点以下 display:none),
// 移动端等价旅程由 mobile-user-journeys.spec.ts 覆盖。
test.use({ viewport: { width: 1440, height: 900 } });
// ==================== 辅助函数 ====================
/**
* 关闭 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();
// N-27 修复:种子含 2 篇已发布新闻(company-founded、
// digital-transformation-solution),列表卡片链接 a[href*="/news/"] 恒存在。
// 原实现用 `if (articleCount > 0)` 包住整段旅程 + else 兜底 bodyText —— 新闻
// 列表消失时本例仍绿。改为硬断言,任何一步元素消失即红。
const firstArticle = page.locator('a[href*="/news/"]').first();
await expect(firstArticle).toBeVisible({ timeout: 5000 });
// === Step 2: 点击第一篇新闻进入详情页 ===
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
firstArticle.click(),
]);
await page.waitForTimeout(1000);
// === 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: 通过面包屑返回新闻列表 ===
// news-detail-content-v3.tsx:68-71 恒渲染 breadcrumb nav 中的「新闻动态」链接
const breadcrumbNewsLink = page.locator('nav[aria-label="breadcrumb"] a[href="/news"]');
await expect(breadcrumbNewsLink).toBeVisible();
await Promise.all([
page.waitForURL(/\/news\/?$/, { timeout: 15000, waitUntil: 'commit' }),
breadcrumbNewsLink.click(),
]);
// 验证已返回新闻列表
await expect(page).toHaveURL(/\/news\/?$/);
});
});
// ==================== UJ-05: 案例浏览者筛选旅程 ====================
test.describe('UJ-05: 案例浏览者筛选旅程(列表 → 筛选 → 详情)', () => {
test('@journey @regression 客户访问案例页,零案例种子下经共创空态转化到联系页', async ({ page }) => {
// N-27 修复:原实现用 `if (caseLinkCount > 0)` 包住详情断言 + else 兜底
// bodyText ⇒ 案例区永远空白也绿。种子刻意 0 条案例(prisma/seed.ts:628-631
// 「首批客户共创阶段,案例种子为空」),cases-content-v3.tsx:234-343 的契约是
// 「hasAnyCases=false ⇒ 渲染共创空态卡 + 行业筛选器整段缺席」。
// 「列表→筛选→详情」的真断言需要 ≥1 条已发布案例,接通前不可验证(见整改报告);
// 本例改为断言当下真实的转化旅程:案例页 → 空态出口「预约咨询」→ 联系页。
await page.goto('/cases', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
await closeCookieBanner(page);
// 页面骨架正常:hero h1 恒定渲染(cases-content-v3.tsx:142-163)
const casesTitle = page.locator('h1').first();
await expect(casesTitle).toBeVisible({ timeout: 10000 });
await expect(casesTitle).toContainText('以成果为目标');
// 零案例契约:筛选按钮组与案例卡片必须不存在(种子加入案例后此处变红,
// 提示把「列表→筛选→详情」断言接回来,而非静默跳过)
await expect(page.getByRole('group', { name: '按行业筛选' })).toHaveCount(0);
await expect(page.locator('a[href*="/cases/"]')).toHaveCount(0);
// 空态转化卡:预约咨询 CTA 存在且 href 契约正确
const emptyPrimaryCta = page.locator('[data-testid="cases-empty-primary-cta"]');
await expect(emptyPrimaryCta).toBeVisible();
await expect(emptyPrimaryCta).toHaveAttribute('href', '/contact');
// === 点击转化出口,验证抵达联系页 ===
await Promise.all([
page.waitForURL((url) => new URL(url).pathname === '/contact', { timeout: 15000, waitUntil: 'commit' }),
emptyPrimaryCta.click(),
]);
await expect(page).toHaveURL((url) => url.pathname === '/contact');
const contactTitle = page.locator('h1').first();
await expect(contactTitle).toBeVisible({ timeout: 10000 });
const contactText = await page.locator('main').first().textContent();
expect(contactText!.length).toBeGreaterThan(50);
});
});
// ==================== UJ-10: 深度搜索者旅程 ====================
test.describe('UJ-10: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)', () => {
test('@journey @regression 用户通过分类筛选浏览多篇文章并发现相关内容', async ({ page }) => {
// N-27 修复:原实现用 `if (isVisible)`/`if (count > 0)` 包住 Step 2/3/4/5/6
// 几乎所有断言 ⇒ 分类按钮或文章消失时本例只剩两条哨兵断言仍绿。
// 种子契约(prisma/dev.db):恰有 2 篇已发布新闻,各属一类——
// company-founded(公司新闻)、digital-transformation-solution(研发动态);
// 分类按钮组恒渲染(news-content-v3.tsx:159-167)⇒ 全部改为硬断言。
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();
// 验证分类筛选按钮存在(两个分类 chip 必须可见)
const companyNewsBtn = page.locator('button', { hasText: '公司新闻' }).first();
const devNewsBtn = page.locator('button', { hasText: '研发动态' }).first();
await expect(companyNewsBtn).toBeVisible();
await expect(devNewsBtn).toBeVisible();
// === Step 2: 点击「公司新闻」→ 列表应收窄到恰 1 篇 ===
await companyNewsBtn.click();
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
// === Step 3: 阅读第一篇新闻文章 ===
const firstArticle = page.locator('a[href*="/news/"]').first();
await expect(firstArticle).toContainText('正式成立');
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
firstArticle.click(),
]);
await page.waitForTimeout(1000);
// 验证新闻详情页加载成功(标题/分类徽标/正文都是恒定渲染)
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toContainText('四川睿新致远科技有限公司正式成立');
const detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
await expect(detailContent).toContainText('公司新闻');
// 相关内容区:两篇新闻分属不同分类,相关新闻按同分类筛选
//(news/[slug]/page.tsx:43-46)⇒ 当前种子下恒为空,section 必须缺席
await expect(page.getByRole('heading', { name: '相关新闻' })).toHaveCount(0);
// === Step 4: 返回新闻列表,切换「研发动态」分类 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const devNewsBtn2 = page.locator('button', { hasText: '研发动态' }).first();
await expect(devNewsBtn2).toBeVisible();
await devNewsBtn2.click();
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
// === Step 5: 阅读第二篇新闻文章 ===
const secondArticle = page.locator('a[href*="/news/"]').first();
await Promise.all([
page.waitForURL(/\/news\/.+/, { timeout: 15000, waitUntil: 'commit' }),
secondArticle.click(),
]);
await page.waitForTimeout(1000);
const secondDetailTitle = page.locator('h1').first();
await expect(secondDetailTitle).toContainText('数字化转型');
const secondDetailContent = page.locator('main').first();
await expect(secondDetailContent).toBeVisible();
const secondDetailText = await secondDetailContent.textContent();
expect(secondDetailText!.length).toBeGreaterThan(100);
await expect(secondDetailContent).toContainText('研发动态');
// === Step 6: 返回全部新闻列表,验证内容发现能力 ===
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 点击「全部」恢复显示所有新闻
const allBtn = page.locator('button', { hasText: '全部' }).first();
await expect(allBtn).toBeVisible();
await allBtn.click();
// 全部种子新闻文章可见(恰 2 篇)
await expect(page.locator('a[href*="/news/"]')).toHaveCount(2);
// 验证搜索功能(内容发现工具):输入命中词应精确收窄到 1 篇
const searchInput = page.getByLabel('搜索新闻');
await expect(searchInput).toBeVisible();
await searchInput.fill('数字化转型');
const searchResults = page.locator('a[href*="/news/"]');
await expect(searchResults).toHaveCount(1);
await expect(searchResults.first()).toContainText('数字化转型');
});
});
// ==================== 辅助函数: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: 验收 A-4 契约 —— 非白名单扩展名即使谎报 MIME 也必须拒绝 ===
// 原实现在此处上传 .txt 并断言 200:那是 A-4 修复前「任意文件可落盘」的行为,
// 断言的正是漏洞本身,故改为断言拒绝 + 白名单内真实图片成功。
const rejectedResponse = await request.post(`${BASE_URL}/api/admin/media`, {
multipart: {
file: {
name: `uj07-rejected-${Date.now()}.txt`,
mimeType: 'image/png',
buffer: Buffer.from('UJ-07 disallowed extension probe', 'utf-8'),
},
},
headers: {
Authorization: `Bearer ${token}`,
},
});
expect(rejectedResponse.status()).toBe(400);
console.log('UJ-07 .txt upload rejected as expected');
// === Step 4b: 白名单内的真实图片上传 ===
// 96 字节 8×8 PNG,由 sharp 生成并回读校验(meta.format === 'png'),非手造字节
const pngBuffer = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEklEQVQYlWM4Imf1Hx9mGBkKAGZzhsHMOkj6AAAAAElFTkSuQmCC',
'base64'
);
const testFileName = `uj07-test-${Date.now()}.png`;
const uploadResponse = await request.post(`${BASE_URL}/api/admin/media`, {
multipart: {
file: {
name: testFileName,
mimeType: 'image/png',
buffer: pngBuffer,
},
},
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');
}
});
});