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:
@@ -231,7 +231,9 @@ novalon-website/
|
||||
| `npm run test:smoke` | 运行 E2E 冒烟测试(@smoke 标签) |
|
||||
| `npm run test:critical` | 运行 E2E 关键路径测试(@critical 标签) |
|
||||
| `npm run test:e2e:journey` | 运行用户旅程测试(@journey 标签,UJ-01/UJ-02) |
|
||||
| `npm run test:e2e:mobile` | 运行移动端 E2E 测试(@mobile 标签) |
|
||||
| `npm run test:e2e:mobile` | 运行移动端 E2E 测试(@mobile 标签,53 个测试) |
|
||||
| `npm run test:e2e:mobile:performance` | 运行移动端性能基线测试(FCP/LCP/加载时间) |
|
||||
| `npm run test:e2e:mobile:accessibility` | 运行移动端可访问性测试(axe-core WCAG 2.1 AA) |
|
||||
| `npm run test:mutation` | 运行变异测试(Stryker,评估测试质量,当前 36.98%) |
|
||||
| `npm run test:mutation:quick` | 快速变异测试(仅 utils.ts,91.18%) |
|
||||
| `npm run test:security` | 安全扫描(npm audit + 安全响应头检查) |
|
||||
@@ -289,7 +291,7 @@ novalon-website/
|
||||
| `@smoke` + `@critical` | 快速回归(<5min) | `npm run test:e2e:fast` |
|
||||
| `@regression` | 全量回归(<15min) | `npm run test:e2e:standard` |
|
||||
| `@journey` | 用户旅程(UJ-01/UJ-02) | `npm run test:e2e:journey` |
|
||||
| `@mobile` | 移动端专项测试 | `npm run test:e2e:mobile` |
|
||||
| `@mobile` | 移动端专项测试(53 个:16 基础 + 5 用户旅程 + 14 可访问性 + 18 性能) | `npm run test:e2e:mobile` |
|
||||
| `@visual` | 视觉回归(105 snapshots × 5 browsers) | `npm run test:visual:all` |
|
||||
|
||||
### 运行测试
|
||||
@@ -306,7 +308,11 @@ npm run test # 全量 E2E(631 passed)
|
||||
npm run test:e2e:fast # 快速回归(@smoke + @critical)
|
||||
npm run test:e2e:standard # 标准回归(@regression)
|
||||
npm run test:e2e:journey # 用户旅程(@journey)
|
||||
npm run test:e2e:mobile # 移动端(@mobile)
|
||||
|
||||
# 移动端测试
|
||||
npm run test:e2e:mobile # 全量移动端(53 个 @mobile 测试)
|
||||
npm run test:e2e:mobile:performance # 移动端性能基线
|
||||
npm run test:e2e:mobile:accessibility # 移动端可访问性
|
||||
|
||||
# 变异测试
|
||||
npm run test:mutation # 全量变异测试
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -36,6 +36,8 @@
|
||||
"test:e2e:standard": "cd e2e && npx playwright test --grep @regression",
|
||||
"test:e2e:journey": "cd e2e && npx playwright test --grep @journey",
|
||||
"test:e2e:mobile": "cd e2e && npx playwright test --grep @mobile",
|
||||
"test:e2e:mobile:performance": "cd e2e && npx playwright test --grep @mobile.*@performance",
|
||||
"test:e2e:mobile:accessibility": "cd e2e && npx playwright test --grep @mobile.*@accessibility",
|
||||
"test:mutation": "rm -rf .stryker-tmp && npx stryker run --inPlace",
|
||||
"test:mutation:quick": "rm -rf .stryker-tmp && npx stryker run --inPlace --mutate 'src/lib/utils.ts'",
|
||||
"test:all": "npm run type-check && npm run lint && npm run test:coverage && npm run test:e2e:fast && npm run test:security:headers",
|
||||
|
||||
@@ -7,6 +7,7 @@ export const STANDALONE_PRODUCTS: StandaloneProduct[] = [
|
||||
title: '睿视 NovaVis',
|
||||
description: '面向执法机关的新一代智能数据分析平台,完全离线运行,AI驱动分析,百万级数据秒级响应。',
|
||||
image: '/images/products/novavis.jpg',
|
||||
externalUrl: 'https://novavis.p.novalon.cn',
|
||||
category: '专业工具系列',
|
||||
categoryId: 'specialized',
|
||||
tags: ['资金追踪', '关系图谱', 'AI分析', '离线运行', '执法情报'],
|
||||
|
||||
@@ -288,7 +288,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
|
||||
<div className="w-12 h-12 border border-border-primary bg-white flex items-center justify-center shrink-0" aria-hidden="true">
|
||||
<Mail className="w-5 h-5 text-text-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-3.5">
|
||||
<p className="text-sm text-text-muted mb-1">{data.emailLabel || ''}</p>
|
||||
<a
|
||||
href={`mailto:${emailAddress}`}
|
||||
@@ -304,7 +304,7 @@ function ContactFormContent({ data }: { data: ContactData }) {
|
||||
<div className="w-12 h-12 border border-border-primary bg-white flex items-center justify-center shrink-0" aria-hidden="true">
|
||||
<MapPin className="w-5 h-5 text-text-secondary" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-3.5">
|
||||
<p className="text-sm text-text-muted mb-1">{data.addressLabel || ''}</p>
|
||||
<p className="text-ink" data-testid="address-text">
|
||||
{addressText}
|
||||
|
||||
@@ -266,7 +266,7 @@ export default function ErpUpgradeContentV2({ item }: ErpUpgradeContentV2Props)
|
||||
<div className="w-12 h-12 bg-brand/10 border border-brand/20 flex items-center justify-center shrink-0">
|
||||
<item.icon className="w-6 h-6 text-brand" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="mt-3.5">
|
||||
<h3 className="text-lg font-semibold mb-2 text-ink">{item.title}</h3>
|
||||
<p className="text-text-secondary text-sm leading-relaxed">{item.desc}</p>
|
||||
</div>
|
||||
|
||||
@@ -139,8 +139,8 @@ function FeaturesSection({ features }: FeaturesSectionProps) {
|
||||
key={idx}
|
||||
className="group relative p-8 bg-white hover:bg-bg-secondary transition-all duration-500"
|
||||
>
|
||||
<div className="flex items-start gap-5">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-soft flex items-center justify-center text-brand shrink-0 mt-0.5">
|
||||
<div className="flex items-center gap-5">
|
||||
<div className="w-10 h-10 rounded-xl bg-brand-soft flex items-center justify-center text-brand shrink-0">
|
||||
<Check className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="text-lg text-ink font-medium leading-relaxed">{feature}</p>
|
||||
@@ -182,7 +182,7 @@ function BenefitsSection({ benefits }: BenefitsSectionProps) {
|
||||
key={idx}
|
||||
className="group relative p-8 bg-white hover:bg-bg-secondary transition-all duration-500"
|
||||
>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="w-12 h-12 rounded-2xl bg-brand-soft/50 flex items-center justify-center text-brand shrink-0">
|
||||
<TrendingUp className="w-6 h-6" />
|
||||
</div>
|
||||
|
||||
@@ -148,6 +148,8 @@ function HeroSection() {
|
||||
|
||||
function ProductCard({ product }: { product: Product }) {
|
||||
const bundleLabel = product.bundle === 'enterprise' ? '企业套装' : '专业产品';
|
||||
const linkHref = product.externalUrl || `/products/${product.id}`;
|
||||
const isExternal = !!product.externalUrl;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
@@ -157,8 +159,9 @@ function ProductCard({ product }: { product: Product }) {
|
||||
transition={{ duration: 0.6, ease: EASE_OUT }}
|
||||
>
|
||||
<StaticLink
|
||||
href={`/products/${product.id}`}
|
||||
href={linkHref}
|
||||
className="group block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-500 flex flex-col h-full"
|
||||
{...(isExternal ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
||||
>
|
||||
<div className="p-6 sm:p-7 lg:p-8 flex flex-col flex-1">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { getPublishedItemBySlug, getPublishedItems } from '@/lib/cms/data-server';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
import { StandaloneProductClient } from './client';
|
||||
@@ -35,5 +35,11 @@ export default async function StandaloneProductPage({ params }: { params: Promis
|
||||
notFound();
|
||||
}
|
||||
|
||||
// If the product has an external URL, redirect to it
|
||||
const externalUrl = item.data.externalUrl as string | undefined;
|
||||
if (externalUrl) {
|
||||
redirect(externalUrl);
|
||||
}
|
||||
|
||||
return <StandaloneProductClient item={JSON.parse(JSON.stringify(item))} />;
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import { toast } from '@/components/ui/sonner';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
X,
|
||||
AlertTriangle,
|
||||
CheckCircle,
|
||||
Cloud,
|
||||
@@ -60,7 +59,7 @@ interface ModelData {
|
||||
// 自动保存延迟(毫秒)
|
||||
const AUTO_SAVE_DELAY = 3000;
|
||||
|
||||
function TagInput({
|
||||
function JsonEditor({
|
||||
id,
|
||||
value,
|
||||
onChange,
|
||||
@@ -69,72 +68,50 @@ function TagInput({
|
||||
}: {
|
||||
id: string;
|
||||
value: unknown;
|
||||
onChange: (tags: string[]) => void;
|
||||
onChange: (value: unknown) => void;
|
||||
placeholder?: string;
|
||||
error?: string;
|
||||
}) {
|
||||
const tags = Array.isArray(value) ? value.filter((t): t is string => typeof t === 'string') : [];
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const addTag = () => {
|
||||
const raw = input.trim();
|
||||
if (!raw) return;
|
||||
const newTags = raw.split(/[,,]/).map((t) => t.trim()).filter(Boolean);
|
||||
if (newTags.length === 0) return;
|
||||
const merged = [...new Set([...tags, ...newTags])];
|
||||
onChange(merged);
|
||||
setInput('');
|
||||
};
|
||||
|
||||
const removeTag = (tag: string) => {
|
||||
onChange(tags.filter((t) => t !== tag));
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addTag();
|
||||
const [text, setText] = useState(() => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
if (e.key === 'Backspace' && !input && tags.length > 0) {
|
||||
onChange(tags.slice(0, -1));
|
||||
});
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (newText: string) => {
|
||||
setText(newText);
|
||||
if (!newText.trim()) {
|
||||
setParseError(null);
|
||||
onChange(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(newText);
|
||||
setParseError(null);
|
||||
onChange(parsed);
|
||||
} catch (e) {
|
||||
setParseError(e instanceof Error ? e.message : 'JSON 格式错误');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
<textarea
|
||||
id={id}
|
||||
className={`w-full min-h-[42px] px-3 py-2 border rounded-lg text-sm focus-within:outline-none focus-within:ring-2 focus-within:ring-gray-900 focus-within:border-transparent flex flex-wrap gap-2 ${
|
||||
error ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
value={text}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder={placeholder || '输入 JSON 数据'}
|
||||
rows={10}
|
||||
className={`w-full px-3 py-2 border rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-gray-900 focus:border-transparent resize-y ${
|
||||
error || parseError ? 'border-red-300 bg-red-50' : 'border-gray-300'
|
||||
}`}
|
||||
>
|
||||
{tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 bg-gray-100 text-gray-700 px-2 py-0.5 rounded text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeTag(tag)}
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
aria-label={`移除标签 ${tag}`}
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
type="text"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={addTag}
|
||||
placeholder={tags.length === 0 ? placeholder || '输入标签,按回车添加' : ''}
|
||||
className="flex-1 min-w-[120px] outline-none bg-transparent text-sm"
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{parseError && <p className="mt-1 text-xs text-red-500">{parseError}</p>}
|
||||
{error && !parseError && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -560,10 +537,10 @@ export default function ContentEditorPage() {
|
||||
{field.description && (
|
||||
<p className="text-xs text-gray-400 mb-1.5">{field.description}</p>
|
||||
)}
|
||||
<TagInput
|
||||
<JsonEditor
|
||||
id={key}
|
||||
value={value}
|
||||
onChange={(tags) => handleFieldChange(field.name, tags)}
|
||||
onChange={(json) => handleFieldChange(field.name, json)}
|
||||
placeholder={field.placeholder}
|
||||
error={error}
|
||||
/>
|
||||
|
||||
+34
-4
@@ -14,7 +14,7 @@ import { ScrollProgress } from "@/components/ui/scroll-progress";
|
||||
import { BackToTop } from "@/components/ui/back-to-top";
|
||||
import { ClientLayout } from "@/components/layout/client-layout";
|
||||
import { getPublishedItems } from "@/lib/cms/data-server";
|
||||
import { SiteConfigProvider, type SiteConfig, type NavigationItem, type MegaDropdownGroup } from "@/lib/site-config";
|
||||
import { SiteConfigProvider, type SiteConfig, type NavigationItem, type MegaDropdownGroup, type MegaDropdownItem } from "@/lib/site-config";
|
||||
import { COMPANY_INFO } from "@/lib/constants/company";
|
||||
import { NAVIGATION_V2, MEGA_DROPDOWN_DATA } from "@/lib/constants/navigation";
|
||||
|
||||
@@ -82,15 +82,45 @@ export default async function RootLayout({
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// Fetch site configuration and navigation from CMS
|
||||
const [navItems, configItems] = await Promise.all([
|
||||
// Fetch site configuration, navigation, and standalone products from CMS
|
||||
const [navItems, configItems, standaloneItems] = await Promise.all([
|
||||
getPublishedItems('navigation').catch(() => []),
|
||||
getPublishedItems('site-config').catch(() => []),
|
||||
getPublishedItems('standalone-product').catch(() => []),
|
||||
]);
|
||||
|
||||
const navData = navItems[0]?.data as Record<string, unknown> | undefined;
|
||||
const configData = configItems[0]?.data as Record<string, unknown> | undefined;
|
||||
|
||||
// Generate standalone product navigation items from CMS data
|
||||
const standaloneNavItems: MegaDropdownItem[] = standaloneItems.map((item) => {
|
||||
const data = item.data as Record<string, unknown>;
|
||||
const externalUrl = data.externalUrl as string | undefined;
|
||||
return {
|
||||
id: (data.id as string) || item.slug || '',
|
||||
title: (data.title as string) || item.title,
|
||||
description: (data.description as string) || '',
|
||||
href: externalUrl || `/products/standalone/${item.slug || data.id}`,
|
||||
badge: (data.status as string) === '内测中' ? '内测中' : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Build megaDropdown with standalone products dynamically injected
|
||||
const baseMegaDropdown = (navData?.megaDropdown as Record<string, MegaDropdownGroup[]>) || MEGA_DROPDOWN_DATA;
|
||||
const megaDropdown: Record<string, MegaDropdownGroup[]> = {};
|
||||
for (const [key, groups] of Object.entries(baseMegaDropdown)) {
|
||||
if (key === 'products' && standaloneNavItems.length > 0) {
|
||||
megaDropdown[key] = groups.map((group) => {
|
||||
if (group.id === 'standalone-products') {
|
||||
return { ...group, description: undefined, items: standaloneNavItems };
|
||||
}
|
||||
return group;
|
||||
});
|
||||
} else {
|
||||
megaDropdown[key] = groups;
|
||||
}
|
||||
}
|
||||
|
||||
const siteConfig: SiteConfig = {
|
||||
name: (configData?.name as string) || COMPANY_INFO.name,
|
||||
shortName: (configData?.shortName as string) || COMPANY_INFO.shortName,
|
||||
@@ -104,7 +134,7 @@ export default async function RootLayout({
|
||||
icp: (configData?.icp as string) || COMPANY_INFO.icp,
|
||||
police: (configData?.police as string) || COMPANY_INFO.police,
|
||||
mainNav: (navData?.mainNav as NavigationItem[]) || NAVIGATION_V2,
|
||||
megaDropdown: (navData?.megaDropdown as Record<string, MegaDropdownGroup[]>) || MEGA_DROPDOWN_DATA,
|
||||
megaDropdown,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -161,7 +161,7 @@ export function ProductValueSection({ product }: ProductValueSectionProps) {
|
||||
}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 pt-1">
|
||||
<div className="flex-1 min-w-0 mt-3">
|
||||
<FeatureTags text={feature} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -68,7 +68,7 @@ export function ServiceValueSection({ service }: ServiceValueSectionProps) {
|
||||
}}
|
||||
className="bain-card p-6"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`shrink-0 w-11 h-11 flex items-center justify-center ${
|
||||
index === 0
|
||||
? 'bg-brand text-white'
|
||||
@@ -76,7 +76,7 @@ export function ServiceValueSection({ service }: ServiceValueSectionProps) {
|
||||
}`}>
|
||||
<Icon className="w-5 h-5" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-secondary leading-relaxed pt-2">
|
||||
<p className="text-sm font-medium text-text-secondary leading-relaxed">
|
||||
{feature.split(':')[1] || feature}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -181,10 +181,11 @@ describe('Footer', () => {
|
||||
expect(screen.getByTestId('card-solutions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render contact card with contact info and QR code', () => {
|
||||
it('should render contact card with contact info and QR codes', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByTestId('card-contact')).toBeInTheDocument();
|
||||
expect(screen.getByText('关注公众号')).toBeInTheDocument();
|
||||
expect(screen.getByText('业务咨询')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -255,9 +256,20 @@ describe('Footer', () => {
|
||||
expect(qrCode).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render business QR code image', () => {
|
||||
render(<Footer />);
|
||||
const qrCode = screen.getByAltText('业务咨询微信二维码');
|
||||
expect(qrCode).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render WeChat QR code description', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('关注公众号')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render business QR code description', () => {
|
||||
render(<Footer />);
|
||||
expect(screen.getByText('业务咨询')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,17 +86,32 @@ export function Footer() {
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-3 tracking-wide">关注公众号</p>
|
||||
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
|
||||
<Image
|
||||
src="/images/qrcode.webp"
|
||||
alt="微信公众号二维码"
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-24 h-24"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex gap-6">
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-3 tracking-wide">关注公众号</p>
|
||||
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
|
||||
<Image
|
||||
src="/images/qrcode.webp"
|
||||
alt="微信公众号二维码"
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-24 h-24"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-3 tracking-wide">业务咨询</p>
|
||||
<div className="inline-block p-2 border border-gray-800 bg-gray-900">
|
||||
<Image
|
||||
src="/images/wechat-business-qr.webp"
|
||||
alt="业务咨询微信二维码"
|
||||
width={96}
|
||||
height={96}
|
||||
className="w-24 h-24"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -71,11 +71,9 @@ export const MEGA_DROPDOWN_DATA: MegaDropdownData = {
|
||||
{
|
||||
id: 'standalone-products',
|
||||
title: '专业产品',
|
||||
description: '即将推出',
|
||||
description: '专业领域独立产品线',
|
||||
items: [
|
||||
{ id: 'security', title: '安全产品', description: '企业安全防护方案', href: '#', badge: '敬请期待' },
|
||||
{ id: 'specialized-software', title: '特种行业软件', description: '垂直行业专业解决方案', href: '#', badge: '敬请期待' },
|
||||
{ id: 'hardware', title: '硬件产品', description: '智能硬件与IoT设备', href: '#', badge: '敬请期待' },
|
||||
{ id: 'novavis', title: '睿视 NovaVis', description: '面向执法机关的离线智能数据分析与资金关系图谱平台', href: 'https://novavis.p.novalon.cn', badge: '内测中' },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -70,6 +70,7 @@ export interface Product {
|
||||
specs: string[];
|
||||
tags: string[];
|
||||
heroThemeId: string;
|
||||
externalUrl?: string;
|
||||
caseStudies: CaseStudy[];
|
||||
dataProofs: DataProof[];
|
||||
certifications: Certification[];
|
||||
|
||||
Reference in New Issue
Block a user