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:
@@ -0,0 +1,216 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import AxeBuilder from '@axe-core/playwright';
|
||||
|
||||
/**
|
||||
* 移动端可访问性专项测试
|
||||
*
|
||||
* 覆盖范围:
|
||||
* 1. axe-core 合规扫描(WCAG 2.1 AA)— 移动视口
|
||||
* 2. 触摸目标尺寸 ≥ 44px(WCAG 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);
|
||||
});
|
||||
});
|
||||
@@ -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('触摸目标尺寸 ≥ 44px(WCAG 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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'] },
|
||||
|
||||
Reference in New Issue
Block a user