From f3fc969bef0e56399e4f01a80f54a890a48e19a2 Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Mon, 3 Aug 2026 18:32:29 +0800 Subject: [PATCH] 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 --- README.md | 12 +- e2e/mobile-accessibility.spec.ts | 216 ++++++++ e2e/mobile-performance.spec.ts | 256 ++++++++++ e2e/mobile-user-journeys.spec.ts | 469 ++++++++++++++++++ e2e/playwright.config.ts | 10 + package.json | 2 + prisma/seeds/standalone-products.ts | 1 + .../contact/contact-content-v3.tsx | 4 +- .../erp-upgrade/erp-upgrade-content-v2.tsx | 2 +- .../products/product-detail-content-v3.tsx | 6 +- .../products/products-content-v3.tsx | 5 +- .../products/standalone/[id]/page.tsx | 8 +- .../content/[modelCode]/[itemId]/page.tsx | 95 ++-- src/app/layout.tsx | 38 +- .../detail/detail-product-value.tsx | 2 +- src/components/detail/service-value.tsx | 4 +- src/components/layout/footer.test.tsx | 14 +- src/components/layout/footer.tsx | 37 +- src/lib/constants/navigation.ts | 6 +- src/lib/constants/products.ts | 1 + 20 files changed, 1095 insertions(+), 93 deletions(-) create mode 100644 e2e/mobile-accessibility.spec.ts create mode 100644 e2e/mobile-performance.spec.ts create mode 100644 e2e/mobile-user-journeys.spec.ts diff --git a/README.md b/README.md index 9e1723b..07a4b58 100644 --- a/README.md +++ b/README.md @@ -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 # 全量变异测试 diff --git a/e2e/mobile-accessibility.spec.ts b/e2e/mobile-accessibility.spec.ts new file mode 100644 index 0000000..67ba7fb --- /dev/null +++ b/e2e/mobile-accessibility.spec.ts @@ -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); + }); +}); \ No newline at end of file diff --git a/e2e/mobile-performance.spec.ts b/e2e/mobile-performance.spec.ts new file mode 100644 index 0000000..3f8f1bd --- /dev/null +++ b/e2e/mobile-performance.spec.ts @@ -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((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((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); + }); +}); \ No newline at end of file diff --git a/e2e/mobile-user-journeys.spec.ts b/e2e/mobile-user-journeys.spec.ts new file mode 100644 index 0000000..4a13412 --- /dev/null +++ b/e2e/mobile-user-journeys.spec.ts @@ -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); + } +} + +/** + * 通过移动端汉堡菜单导航到指定页面 + * 移动端导航菜单中所有项均为 `` 链接(无下拉展开),直接点击对应标签跳转。 + */ +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 = { + '/products': '产品', + '/solutions': '解决方案', + '/services': '服务', + '/cases': '案例', + '/news': '新闻动态', + '/about': '关于我们', + '/contact': '联系我们', + }; + + const label = pathToLabel[targetPath]; + if (!label) { + // 未知路径,直接导航 + await page.goto(targetPath, { waitUntil: 'domcontentloaded', timeout: 30000 }); + return; + } + + // 移动端菜单中所有项都是 `` 链接,直接点击 + 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'); + }); +}); \ No newline at end of file diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index ee1d2b7..d787223 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -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'] }, diff --git a/package.json b/package.json index e8f9576..f711392 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/prisma/seeds/standalone-products.ts b/prisma/seeds/standalone-products.ts index 243aa24..16ef6a1 100644 --- a/prisma/seeds/standalone-products.ts +++ b/prisma/seeds/standalone-products.ts @@ -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分析', '离线运行', '执法情报'], diff --git a/src/app/(marketing)/contact/contact-content-v3.tsx b/src/app/(marketing)/contact/contact-content-v3.tsx index 708c65e..8fad9a3 100644 --- a/src/app/(marketing)/contact/contact-content-v3.tsx +++ b/src/app/(marketing)/contact/contact-content-v3.tsx @@ -288,7 +288,7 @@ function ContactFormContent({ data }: { data: ContactData }) { -
+

{data.emailLabel || ''}