Files
novalon-website/e2e/mobile-user-journeys.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

442 lines
19 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 } from './fixtures';
/**
* 移动端用户旅程 E2E 测试
*
* 覆盖范围(UJ-01 ~ UJ-10 移动端变体):
* - UJ-01 Mobile: 潜在客户从首页到联系表单的完整旅程
* - UJ-02 Mobile: 行业客户浏览解决方案到产品的旅程
* - UJ-04 Mobile: 新闻读者浏览旅程(列表 → 详情 → 返回)
* - UJ-05 Mobile: 案例浏览者筛选旅程(列表 → 筛选 → 详情)
* - UJ-10 Mobile: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)
*
* 所有测试使用 iPhone 14 视口(isMobile: true, hasTouch: true),
* 通过 `chromium-mobile` 项目自动注入。
*/
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);
}
}
/**
* 通过移动端汉堡菜单导航到指定页面
* 移动端导航菜单中所有项均为 `<a>` 链接(无下拉展开),直接点击对应标签跳转。
*/
async function navigateViaMobileMenu(page: Page, targetPath: string) {
// 检查是否在移动端视口(移动端菜单按钮可见)
const menuButton = page.locator('[data-testid="mobile-menu-button"]').first();
const isMobileViewport = await menuButton.isVisible().catch(() => false);
if (!isMobileViewport) {
// 桌面端视口 — 直接导航到目标路径
console.log('Desktop viewport detected, navigating directly to:', targetPath);
await page.goto(targetPath, { waitUntil: 'domcontentloaded', timeout: 30000 });
return;
}
// 移动端:通过汉堡菜单导航
await menuButton.click();
await page.waitForTimeout(500);
const mobileNav = page.locator('[data-testid="mobile-navigation"]').first();
await expect(mobileNav).toBeVisible({ timeout: 5000 });
// 路径到导航标签的映射
const pathToLabel: Record<string, string> = {
'/products': '产品',
'/solutions': '解决方案',
'/services': '服务',
'/cases': '案例',
'/news': '新闻动态',
'/about': '关于我们',
'/contact': '联系我们',
};
const label = pathToLabel[targetPath];
if (!label) {
// 未知路径,直接导航
await page.goto(targetPath, { waitUntil: 'domcontentloaded', timeout: 30000 });
return;
}
// 移动端菜单中所有项都是 `<a>` 链接,直接点击
const navLink = mobileNav.locator(`a:has-text("${label}")`).first();
await expect(navLink).toBeVisible({ timeout: 5000 });
await navLink.click();
}
// ==================== UJ-01 Mobile: 潜在客户从首页到联系表单的完整旅程 ====================
test.describe('UJ-01 Mobile: 潜在客户从首页到联系表单的完整旅程', () => {
test('@mobile @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);
// === Step 2: 通过移动端菜单导航到产品页面 ===
await navigateViaMobileMenu(page, '/products');
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 3: 点击产品进入详情页 ===
const firstProductLink = productLinks.first();
await firstProductLink.click();
await page.waitForURL(/\/products\/.+/, { timeout: 10000 });
await expect(page).toHaveURL(/\/products\/.+/);
await page.waitForTimeout(2000);
// === Step 4: 验证产品详情页内容 ===
const detailTitle = page.locator('h1').first();
await expect(detailTitle).toBeVisible({ timeout: 10000 });
// 验证 L4 CTA 区域存在(移动端寻找 CTA 按钮/咨询入口)
const ctaSection = page.locator('main').first();
await expect(ctaSection).toBeVisible();
const ctaSectionText = await ctaSection.textContent();
const hasCTA = ctaSectionText!.includes('咨询') ||
ctaSectionText!.includes('联系') ||
ctaSectionText!.includes('了解更多') ||
ctaSectionText!.includes('立即') ||
ctaSectionText!.includes('contact');
expect(hasCTA).toBeTruthy();
// === Step 5: 导航到联系页面 ===
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
// === Step 6: 填写联系表单 ===
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('mobile@example.com');
await page.locator('[data-testid="subject-input"]').fill('产品咨询 - 移动端');
await page.locator('[data-testid="message-input"]').fill('您好,我在手机上浏览了贵司产品,希望进一步了解。');
// === Step 7: 拦截 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 8: 验证成功消息 ===
await page.waitForTimeout(2000);
console.log('UJ-01 Mobile 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 Mobile: 行业客户浏览解决方案到产品的旅程 ====================
test.describe('UJ-02 Mobile: 行业客户浏览解决方案到产品的旅程', () => {
test('@mobile @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: 导航到解决方案页面 ===
await navigateViaMobileMenu(page, '/solutions');
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();
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 solutionMain = page.locator('main').first();
await expect(solutionMain).toBeVisible();
const solutionMainText = await solutionMain.textContent();
expect(solutionMainText!.length).toBeGreaterThan(200);
// === Step 5: 导航到产品页面 ===
await page.goto('/products', { waitUntil: 'domcontentloaded', timeout: 30000 });
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();
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 });
// 验证存在 CTA 内容(移动端寻找 CTA 按钮/咨询入口)
const productMain = page.locator('main').first();
await expect(productMain).toBeVisible();
const productMainText = await productMain.textContent();
const hasCTA = productMainText!.includes('咨询') ||
productMainText!.includes('联系') ||
productMainText!.includes('了解更多') ||
productMainText!.includes('立即') ||
productMainText!.includes('contact');
expect(hasCTA).toBeTruthy();
});
});
// ==================== UJ-04 Mobile: 新闻读者浏览旅程 ====================
test.describe('UJ-04 Mobile: 新闻读者浏览旅程(列表 → 详情 → 返回)', () => {
test('@mobile @journey @regression 读者从新闻列表浏览到详情页并返回', async ({ page }) => {
// N-27 修复:同桌面版 UJ-04(user-journey.spec.ts)——原 `if (articleCount > 0)`
// 包住整段旅程,新闻消失时静默通过。种子恰有 2 篇已发布新闻,改为全程硬断言。
// === 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 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 page.waitForTimeout(1000);
await expect(page).toHaveURL(/\/news\/?$/);
});
});
// ==================== UJ-05 Mobile: 案例浏览者筛选旅程 ====================
test.describe('UJ-05 Mobile: 案例浏览者筛选旅程(列表 → 筛选 → 详情)', () => {
test('@mobile @journey @regression 客户访问案例页,零案例种子下经共创空态转化到联系页', async ({ page }) => {
// N-27 修复:同桌面版 UJ-05。种子刻意 0 案例(prisma/seed.ts:628-631),
// 原 `if (caseLinkCount > 0)` + else 兜底 ⇒ 案例区为空也绿。
// 现断言当前真实契约:空态卡存在、筛选器缺席,并走完空态转化出口到联系页。
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 });
await expect(casesTitle).toContainText('以成果为目标');
// 零案例契约:行业筛选按钮组与案例卡片必须不存在
await expect(page.getByRole('group', { name: '按行业筛选' })).toHaveCount(0);
await expect(page.locator('a[href*="/cases/"]')).toHaveCount(0);
// 空态转化卡:预约咨询 CTA 恒定渲染(cases-content-v3.tsx:334-336)
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 Mobile: 深度搜索者旅程 ====================
test.describe('UJ-10 Mobile: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)', () => {
test('@mobile @journey @regression 用户通过分类筛选浏览多篇文章并发现相关内容', async ({ page }) => {
// N-27 修复:同桌面版 UJ-10(user-journey.spec.ts)——原 Step 2/3/4/5/6 全部被
// `if (isVisible)`/`if (count > 0)` 包住。分类按钮组与 2 篇种子新闻(各属一类)
// 均为恒定契约 ⇒ 全程硬断言。
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 });
// 验证分类筛选按钮存在且可点(news-content-v3.tsx:159-167 无条件渲染)
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(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);
// === 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('数字化转型');
await expect(page.locator('a[href*="/news/"]')).toHaveCount(1);
});
});