- 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 配置收敛。
442 lines
19 KiB
TypeScript
442 lines
19 KiB
TypeScript
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);
|
||
});
|
||
}); |