test(mobile): add mobile E2E test suite (53 tests) and fix layout issues

Add comprehensive mobile testing coverage:
- Add chromium-mobile functional test project (iPhone 14, isMobile, hasTouch)
- Add mobile user journey tests (UJ-01/02/04/05/10 mobile variants)
- Add mobile performance baseline tests (FCP/LCP/load time)
- Add mobile accessibility tests (axe-core WCAG 2.1 AA, touch targets,
  form labels, alt text, contrast)
- Update README with progress and new npm scripts

Fix pre-existing issues:
- Fix footer component layout and test assertions
- Fix admin content page sidebar navigation and breadcrumb
- Fix standalone products page metadata and layout
- Fix product detail/service value sections
- Fix contact form layout on mobile
- Fix navigation constants and products data
- Fix layout.tsx CMS config and theme handling
- Fix erp-upgrade content layout
This commit is contained in:
2026-08-03 18:32:29 +08:00
parent f4d8f0a8e9
commit f3fc969bef
20 changed files with 1095 additions and 93 deletions
+216
View File
@@ -0,0 +1,216 @@
import { test, expect } from '@playwright/test';
import AxeBuilder from '@axe-core/playwright';
/**
* 移动端可访问性专项测试
*
* 覆盖范围:
* 1. axe-core 合规扫描(WCAG 2.1 AA)— 移动视口
* 2. 触摸目标尺寸 ≥ 44pxWCAG 2.5.5
* 3. 焦点管理(移动端 Tab 导航)
* 4. 表单标签关联
* 5. 图片 Alt 文本
* 6. 颜色对比度检查
*
* 标记:@mobile @accessibility
*/
test.setTimeout(60000);
// ==================== 1. axe-core 全量扫描 ====================
test.describe('移动端可访问性 - axe-core 合规扫描', { tag: '@mobile @accessibility' }, () => {
const pages = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/contact', name: '联系我们' },
{ path: '/products', name: '产品中心' },
{ path: '/solutions', name: '解决方案' },
{ path: '/services', name: '服务' },
{ path: '/news', name: '新闻' },
{ path: '/cases', name: '案例' },
{ path: '/team', name: '团队' },
];
for (const { path, name } of pages) {
test(`${name} (${path}) — WCAG 2.1 AA 无严重违规`, async ({ page }) => {
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const accessibilityScanResults = await new AxeBuilder({ page })
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'])
.analyze();
const violations = accessibilityScanResults.violations;
// 输出违规详情用于分析
if (violations.length > 0) {
console.log(`\n=== ${name} 可访问性违规 (${violations.length} 项) ===`);
for (const v of violations) {
console.log(` [${v.impact}] ${v.id}: ${v.help}`);
console.log(` Nodes: ${v.nodes.length}, URL: ${v.helpUrl}`);
// 输出前 2 个节点的摘要
v.nodes.slice(0, 2).forEach((node, i) => {
const target = node.target?.join(', ') || 'unknown';
console.log(` Node ${i + 1}: ${target.slice(0, 100)}`);
});
}
}
// 允许少量 low/moderate 违规,但 critical/serious 应为 0
const criticalSerious = violations.filter(
v => v.impact === 'critical' || v.impact === 'serious'
);
expect(criticalSerious.length).toBe(0);
});
}
});
// ==================== 2. 触摸目标尺寸检查 ====================
test.describe('移动端可访问性 - 触摸目标尺寸', { tag: '@mobile @accessibility' }, () => {
test('首页交互元素触摸目标 ≥ 44px', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
await page.evaluate(() => window.scrollTo(0, 0));
await page.waitForTimeout(300);
// 检查可见区域内的交互元素
const smallTargets = await page.evaluate(() => {
const interactiveElements = document.querySelectorAll(
'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])'
);
const issues: Array<{ tag: string; text: string; width: number; height: number }> = [];
interactiveElements.forEach(el => {
const rect = el.getBoundingClientRect();
// 只检查可见元素
if (rect.width > 0 && rect.height > 0 && rect.width < 1000) {
if (rect.width < 44 || rect.height < 44) {
const text = el.textContent?.trim().slice(0, 30) || el.tagName;
// 排除内联文本链接(段落中的 a 标签)
const isInline = el.closest('p, span, li, h1, h2, h3, h4, h5, h6');
if (!isInline) {
issues.push({
tag: el.tagName,
text: text,
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
}
}
});
return issues;
});
if (smallTargets.length > 0) {
console.log(`触摸目标不足 44px 的元素 (${smallTargets.length} 个):`);
smallTargets.slice(0, 10).forEach(t => {
console.log(` <${t.tag}> "${t.text}" — ${t.width}x${t.height}px`);
});
}
// 允许少量例外(如小图标装饰按钮),但不应超过 5 个
expect(smallTargets.length).toBeLessThanOrEqual(5);
});
});
// ==================== 3. 焦点管理 ====================
test.describe('移动端可访问性 - 焦点管理', { tag: '@mobile @accessibility' }, () => {
test('焦点元素数量合理', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const focusableCount = await page.evaluate(() => {
return document.querySelectorAll(
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])'
).length;
});
console.log(`Mobile focusable elements: ${focusableCount}`);
expect(focusableCount).toBeGreaterThan(0);
});
});
// ==================== 4. 表单标签关联 ====================
test.describe('移动端可访问性 - 表单标签', { tag: '@mobile @accessibility' }, () => {
test('联系表单输入框有关联标签或 aria-label', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2500);
const formInputs = await page.evaluate(() => {
const inputs = document.querySelectorAll('input, textarea, select');
const results: Array<{ tag: string; id: string; hasLabel: boolean; ariaLabel: string | null }> = [];
inputs.forEach(input => {
const id = input.id;
const ariaLabel = input.getAttribute('aria-label');
let hasLabel = false;
if (id) {
const label = document.querySelector(`label[for="${id}"]`);
hasLabel = label !== null;
}
results.push({
tag: input.tagName,
id: id || '',
hasLabel,
ariaLabel,
});
});
return results;
});
for (const input of formInputs) {
const hasAccessibleName = input.hasLabel || input.ariaLabel;
expect(
hasAccessibleName,
`Input ${input.tag}${input.id ? '#' + input.id : ''} 缺少可访问名称`
).toBeTruthy();
}
});
});
// ==================== 5. 图片 Alt 文本 ====================
test.describe('移动端可访问性 - 图片 Alt 文本', { tag: '@mobile @accessibility' }, () => {
test('首页图片有 alt 属性', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const imagesWithoutAlt = await page.evaluate(() => {
const images = Array.from(document.querySelectorAll('img'));
return images.filter(img => !img.hasAttribute('alt') || img.getAttribute('alt') === null).length;
});
console.log(`Images without alt attribute: ${imagesWithoutAlt}`);
expect(imagesWithoutAlt).toBe(0);
});
});
// ==================== 6. 颜色对比度 ====================
test.describe('移动端可访问性 - 颜色对比度', { tag: '@mobile @accessibility' }, () => {
test('无白色文字在白色背景上的问题', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const contrastIssues = await page.evaluate(() => {
const issues: string[] = [];
const textElements = document.querySelectorAll('p, h1, h2, h3, h4, h5, h6, span, a, li, td, th');
textElements.forEach(el => {
const style = window.getComputedStyle(el);
const color = style.color;
const bgColor = style.backgroundColor;
if (color === 'rgb(255, 255, 255)' && bgColor === 'rgb(255, 255, 255)') {
issues.push(`${el.tagName}: 白色文字在白色背景上`);
}
});
return issues;
});
expect(contrastIssues.length, `发现颜色对比度问题:\n${contrastIssues.join('\n')}`).toBe(0);
});
});
+256
View File
@@ -0,0 +1,256 @@
import { test, expect } from '@playwright/test';
/**
* 移动端性能基线测试
*
* 目标:在 iPhone 14 视口(390x844)下测量关键性能指标,
* 建立移动端性能基线,与桌面端对比发现性能差距。
*
* 测试指标:
* 1. FCP (First Contentful Paint) < 2s
* 2. LCP (Largest Contentful Paint) < 2.5s
* 3. DOMContentLoaded < 3s
* 4. 完整加载时间 < 8s
* 5. 长任务计数
* 6. 资源加载分析
* 7. 关键页面加载时间对比
*
* 标记:@mobile @performance
*/
test.setTimeout(90000);
// ==================== 1. 核心性能指标 ====================
test.describe('移动端性能 - 核心加载指标', { tag: '@mobile @performance' }, () => {
test('首页 FCP < 2s', async ({ page }) => {
const fcp = await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 }).then(async () => {
await page.waitForTimeout(1500);
return page.evaluate(() => {
const entries = performance.getEntriesByType('paint');
const fcpEntry = entries.find(e => e.name === 'first-contentful-paint');
return fcpEntry ? fcpEntry.startTime : null;
});
});
if (fcp !== null) {
console.log(`Mobile FCP: ${fcp.toFixed(0)}ms`);
expect(fcp).toBeLessThan(2000);
}
});
test('首页 LCP < 2.5s', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const lcp = await page.evaluate(() => {
return new Promise<number | null>((resolve) => {
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
if (entries.length > 0) {
const lastEntry = entries[entries.length - 1];
resolve(lastEntry.startTime);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// 超时返回 null
setTimeout(() => resolve(null), 5000);
});
});
if (lcp !== null) {
console.log(`Mobile LCP: ${lcp.toFixed(0)}ms`);
expect(lcp).toBeLessThan(2500);
}
});
test('首页 DOMContentLoaded < 3s', async ({ page }) => {
const startTime = Date.now();
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
const loadTime = Date.now() - startTime;
console.log(`Mobile DOMContentLoaded: ${loadTime}ms`);
expect(loadTime).toBeLessThan(3000);
});
test('首页完整加载时间 < 8s', async ({ page }) => {
const startTime = Date.now();
await page.goto('/', { waitUntil: 'load', timeout: 30000 });
await page.waitForTimeout(1000);
const loadTime = Date.now() - startTime;
console.log(`Mobile full load: ${loadTime}ms`);
expect(loadTime).toBeLessThan(8000);
});
test('长任务计数 ≤ 3', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const longTaskCount = await page.evaluate(() => {
return new Promise<number>((resolve) => {
const observer = new PerformanceObserver((list) => {
resolve(list.getEntries().length);
});
observer.observe({ entryTypes: ['longtask'] });
setTimeout(() => resolve(0), 3000);
});
});
console.log(`Mobile long task count: ${longTaskCount}`);
expect(longTaskCount).toBeLessThanOrEqual(3);
});
});
// ==================== 2. 关键页面加载对比 ====================
test.describe('移动端性能 - 关键页面加载对比', { tag: '@mobile @performance' }, () => {
const pages = [
{ path: '/', name: '首页' },
{ path: '/about', name: '关于我们' },
{ path: '/contact', name: '联系我们' },
{ path: '/products', name: '产品中心' },
{ path: '/solutions', name: '解决方案' },
{ path: '/services', name: '服务' },
{ path: '/news', name: '新闻' },
{ path: '/cases', name: '案例' },
];
for (const { path, name } of pages) {
test(`${name} (${path}) 加载 < 5s`, async ({ page }) => {
const startTime = Date.now();
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const loadTime = Date.now() - startTime;
console.log(`Mobile ${name} load: ${loadTime}ms`);
expect(loadTime).toBeLessThan(5000);
});
}
});
// ==================== 3. 资源加载分析 ====================
test.describe('移动端性能 - 资源加载分析', { tag: '@mobile @performance' }, () => {
test('图片资源大小合理', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
const imageAnalysis = await page.evaluate(() => {
const images = Array.from(document.querySelectorAll('img'));
let largeImages = 0;
let oversizedCount = 0;
images.forEach(img => {
if (img.naturalWidth > 1920) {
largeImages++;
}
// 检查移动端是否加载了远大于视口的图片
if (img.naturalWidth > 800 && img.naturalWidth > 0) {
oversizedCount++;
}
});
return {
totalImages: images.length,
largeImageCount: largeImages,
oversizedForMobile: oversizedCount,
};
});
console.log('Mobile image analysis:', imageAnalysis);
expect(imageAnalysis.totalImages).toBeGreaterThan(0);
});
test('JS/CSS 资源加载时间合理', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
const resourceTiming = await page.evaluate(() => {
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
return entries
.filter(e => e.initiatorType === 'script' || e.initiatorType === 'stylesheet')
.map(e => ({
name: e.name.split('/').pop(),
type: e.initiatorType,
duration: Math.round(e.duration),
transferSize: e.transferSize,
}));
});
console.log('Mobile resource timing:', resourceTiming.slice(0, 10));
const criticalResources = resourceTiming.filter(r =>
r.name?.includes('main') ||
r.name?.includes('chunk') ||
r.name?.includes('app')
);
for (const resource of criticalResources) {
expect(resource.duration).toBeLessThan(3000);
}
});
});
// ==================== 4. 移动端渲染性能 ====================
test.describe('移动端性能 - 渲染性能', { tag: '@mobile @performance' }, () => {
test('页面无水平滚动条', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const hasHorizontalScroll = await page.evaluate(() => {
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
});
expect(hasHorizontalScroll).toBeFalsy();
});
test('滚动流畅无卡顿', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
// 模拟快速滚动
for (let i = 0; i < 5; i++) {
await page.evaluate(() => window.scrollBy(0, window.innerHeight));
await page.waitForTimeout(200);
}
// 滚动后页面不应崩溃
const bodyVisible = await page.locator('body').isVisible();
expect(bodyVisible).toBeTruthy();
});
test('触摸目标尺寸 ≥ 44pxWCAG 2.1 AA', async ({ page }) => {
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(1500);
const smallTargets = await page.evaluate(() => {
const interactiveElements = document.querySelectorAll(
'a, button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])'
);
const issues: Array<{ tag: string; text: string; width: number; height: number }> = [];
interactiveElements.forEach(el => {
const rect = el.getBoundingClientRect();
// 只检查可见且非零尺寸的元素
if (rect.width > 0 && rect.height > 0 && rect.width < 1000) {
if (rect.width < 44 || rect.height < 44) {
const text = el.textContent?.trim().slice(0, 30) || el.tagName;
// 排除内联文本链接(如段落中的 a 标签)
const isInline = el.closest('p, span, li, h1, h2, h3, h4, h5, h6');
if (!isInline) {
issues.push({
tag: el.tagName,
text: text,
width: Math.round(rect.width),
height: Math.round(rect.height),
});
}
}
}
});
return issues;
});
if (smallTargets.length > 0) {
console.log('Small touch targets found:', smallTargets.slice(0, 10));
}
// 允许少量例外(如小图标按钮),但不应超过 5 个
expect(smallTargets.length).toBeLessThanOrEqual(5);
});
});
+469
View File
@@ -0,0 +1,469 @@
import { test, expect, type Page } from '@playwright/test';
/**
* 移动端用户旅程 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();
await expect(menuButton).toBeVisible({ timeout: 5000 });
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 }) => {
// === 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 articleLinks = page.locator('a[href*="/news/"]');
const articleCount = await articleLinks.count();
console.log('UJ-04 Mobile 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"], a:has-text("新闻")').first();
if (await breadcrumbNewsLink.count() > 0 && await breadcrumbNewsLink.isVisible()) {
await breadcrumbNewsLink.click();
await page.waitForURL(/\/news\/?$/, { timeout: 10000 });
} else {
await page.goto('/news', { waitUntil: 'domcontentloaded', timeout: 30000 });
}
await page.waitForTimeout(1000);
await expect(page).toHaveURL(/\/news\/?$/);
} else {
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
});
});
// ==================== UJ-05 Mobile: 案例浏览者筛选旅程 ====================
test.describe('UJ-05 Mobile: 案例浏览者筛选旅程(列表 → 筛选 → 详情)', () => {
test('@mobile @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 });
console.log('UJ-05 Mobile cases page loaded');
// === Step 2: 尝试使用行业筛选 ===
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();
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 Mobile 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 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 {
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
}
});
});
// ==================== UJ-10 Mobile: 深度搜索者旅程 ====================
test.describe('UJ-10 Mobile: 深度搜索者旅程(分类浏览 → 逐篇阅读 → 内容发现)', () => {
test('@mobile @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 });
console.log('UJ-10 Mobile news page loaded');
// 验证分类筛选按钮存在
const categoryButtons = page.locator('button:has-text("公司新闻"), button:has-text("研发动态")');
const categoryCount = await categoryButtons.count();
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);
}
// === Step 3: 阅读第一篇新闻文章 ===
const firstArticle = page.locator('a[href*="/news/"]').first();
if (await firstArticle.count() > 0 && await firstArticle.isVisible()) {
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 detailContent = page.locator('main').first();
await expect(detailContent).toBeVisible();
const detailText = await detailContent.textContent();
expect(detailText!.length).toBeGreaterThan(100);
// 验证内容发现:相关新闻或推荐阅读区域
const relatedSection = page.locator(
'text=/相关新闻|推荐阅读|相关文章|RELATED|more news/i'
).first();
if (await relatedSection.count() > 0) {
console.log('UJ-10 Mobile 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()) {
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);
}
// === 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();
expect(allCount).toBeGreaterThanOrEqual(1);
console.log('UJ-10 Mobile all articles count:', allCount);
// 最终验证
const bodyText = await page.locator('body').textContent();
expect(bodyText!.length).toBeGreaterThan(0);
console.log('UJ-10 Mobile completed successfully');
});
});
+10
View File
@@ -90,6 +90,16 @@ export default defineConfig({
use: { ...devices['Desktop Chrome'] },
testIgnore: 'visual-regression.spec.ts',
},
{
name: 'chromium-mobile',
use: {
...devices['iPhone 14'],
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
},
testIgnore: 'visual-regression.spec.ts',
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },