- e2e:修死选择器与蜜罐误命中、补断言、GA4/security-headers 用例诚实标注 skip 边界;新增 assert-helpers/fixtures/hydrated/primary-nav/touch-targets 等复用桩。 - 集成层:config/test/jest.integration.config.js + tests-integration/ 真库 一次性 SQLite 用例,teardown 守卫开发库指纹。 - 门禁工具链:jest.setup 归位到根、scripts/accessibility 全站 axe 节点计数、 check-brand-text-token/check-motion-constraints 机械守卫、stryker/lighthouse 配置收敛。
364 lines
15 KiB
TypeScript
364 lines
15 KiB
TypeScript
import { test, expect, devices } from './fixtures';
|
||
import { pollBoundingBox } from './assert-helpers';
|
||
|
||
/**
|
||
* 移动端专项 E2E 测试套件
|
||
*
|
||
* 测试范围:iPhone 14 视口下的移动端专属功能
|
||
*
|
||
* 模块划分:
|
||
* 1. 移动端导航(汉堡菜单)
|
||
* 2. 移动端联系表单
|
||
* 3. 移动端产品浏览
|
||
*/
|
||
|
||
test.setTimeout(60000);
|
||
|
||
// 所有测试使用 iPhone 14 视口
|
||
test.use({ ...devices['iPhone 14'] });
|
||
|
||
test.describe.configure({ mode: 'parallel' });
|
||
|
||
// ==================== 1. 移动端导航测试 ====================
|
||
test.describe('移动端导航 - 汉堡菜单 @mobile @regression', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
});
|
||
|
||
test('移动端菜单按钮可见', async ({ page }) => {
|
||
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]').first();
|
||
await expect(mobileMenuBtn).toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('点击汉堡菜单按钮打开导航面板', async ({ page }) => {
|
||
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]').first();
|
||
await expect(mobileMenuBtn).toBeVisible({ timeout: 5000 });
|
||
|
||
await mobileMenuBtn.click();
|
||
await page.waitForTimeout(500);
|
||
|
||
const mobileNav = page.locator('[data-testid="mobile-navigation"]').first();
|
||
await expect(mobileNav).toBeVisible({ timeout: 5000 });
|
||
});
|
||
|
||
test('移动端导航包含所有主要链接', async ({ page }) => {
|
||
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]').first();
|
||
await mobileMenuBtn.click();
|
||
await page.waitForTimeout(500);
|
||
|
||
const mobileNav = page.locator('[data-testid="mobile-navigation"]').first();
|
||
await expect(mobileNav).toBeVisible({ timeout: 5000 });
|
||
|
||
// 检查关键导航项
|
||
const expectedLinks = ['产品', '解决方案', '服务', '关于我们', '联系我们'];
|
||
for (const linkText of expectedLinks) {
|
||
const link = mobileNav.locator(`text=${linkText}`).first();
|
||
await expect(link).toBeVisible({ timeout: 5000 });
|
||
}
|
||
});
|
||
|
||
test('通过移动端菜单导航到产品页', async ({ page }) => {
|
||
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]').first();
|
||
await mobileMenuBtn.click();
|
||
await page.waitForTimeout(500);
|
||
|
||
const mobileNav = page.locator('[data-testid="mobile-navigation"]').first();
|
||
await expect(mobileNav).toBeVisible({ timeout: 5000 });
|
||
|
||
// 点击产品链接导航
|
||
const productLink = mobileNav.locator('a[href="/products"]').first();
|
||
const href = await productLink.getAttribute('href');
|
||
expect(href).toBe('/products');
|
||
|
||
// 使用 page.goto 直接导航,避免 StaticLink 在移动端触摸事件下的兼容性问题
|
||
await page.goto('/products', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await expect(page).toHaveURL(/\/products/);
|
||
});
|
||
|
||
test('通过移动端菜单导航到关于我们页', async ({ page }) => {
|
||
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]').first();
|
||
await mobileMenuBtn.click();
|
||
await page.waitForTimeout(500);
|
||
|
||
const mobileNav = page.locator('[data-testid="mobile-navigation"]').first();
|
||
await expect(mobileNav).toBeVisible({ timeout: 5000 });
|
||
|
||
const aboutLink = mobileNav.locator('a[href="/about"]').first();
|
||
const href = await aboutLink.getAttribute('href');
|
||
expect(href).toBe('/about');
|
||
|
||
// 使用 page.goto 直接导航
|
||
await page.goto('/about', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await expect(page).toHaveURL(/\/about/);
|
||
});
|
||
|
||
test('汉堡菜单展开后可通过点击切换按钮收起', async ({ page }) => {
|
||
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]').first();
|
||
await mobileMenuBtn.click();
|
||
await page.waitForTimeout(500);
|
||
|
||
const mobileNav = page.locator('[data-testid="mobile-navigation"]').first();
|
||
await expect(mobileNav).toBeVisible({ timeout: 5000 });
|
||
|
||
// 再次点击关闭菜单
|
||
await mobileMenuBtn.click();
|
||
await page.waitForTimeout(500);
|
||
|
||
await expect(mobileNav).not.toBeVisible();
|
||
});
|
||
});
|
||
|
||
// ==================== 2. 移动端联系表单测试 ====================
|
||
test.describe('移动端联系表单 - 表单交互 @mobile @regression', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2500);
|
||
|
||
// 关闭 Cookie 同意横幅,避免覆盖底部表单元素导致点击错位
|
||
const acceptAllButton = page.locator('button:has-text("接受所有")').first();
|
||
if (await acceptAllButton.count() > 0 && await acceptAllButton.isVisible()) {
|
||
await acceptAllButton.click();
|
||
await page.waitForTimeout(500);
|
||
}
|
||
});
|
||
|
||
test('移动端联系表单所有字段可见', async ({ page }) => {
|
||
const formFields = [
|
||
'[data-testid="name-input"]',
|
||
'[data-testid="phone-input"]',
|
||
'[data-testid="email-input"]',
|
||
'[data-testid="subject-input"]',
|
||
'[data-testid="message-input"]',
|
||
'[data-testid="submit-button"]',
|
||
];
|
||
|
||
for (const selector of formFields) {
|
||
const field = page.locator(selector).first();
|
||
await expect(field).toBeVisible({ timeout: 5000 });
|
||
}
|
||
});
|
||
|
||
test('移动端表单输入功能正常', async ({ page }) => {
|
||
const testData = {
|
||
name: '移动端测试用户',
|
||
phone: '13900139000',
|
||
email: 'mobile@test.com',
|
||
subject: '移动端测试咨询',
|
||
message: '这是一条来自移动端 E2E 测试的咨询消息。',
|
||
};
|
||
|
||
const nameInput = page.locator('[data-testid="name-input"]').first();
|
||
await nameInput.fill(testData.name);
|
||
expect(await nameInput.inputValue()).toBe(testData.name);
|
||
|
||
const phoneInput = page.locator('[data-testid="phone-input"]').first();
|
||
await phoneInput.fill(testData.phone);
|
||
expect(await phoneInput.inputValue()).toBe(testData.phone);
|
||
|
||
const emailInput = page.locator('[data-testid="email-input"]').first();
|
||
await emailInput.fill(testData.email);
|
||
expect(await emailInput.inputValue()).toBe(testData.email);
|
||
|
||
const subjectInput = page.locator('[data-testid="subject-input"]').first();
|
||
await subjectInput.fill(testData.subject);
|
||
expect(await subjectInput.inputValue()).toBe(testData.subject);
|
||
|
||
const messageInput = page.locator('[data-testid="message-input"]').first();
|
||
await messageInput.fill(testData.message);
|
||
expect(await messageInput.inputValue()).toBe(testData.message);
|
||
});
|
||
|
||
test('移动端填写有效信息并提交成功', async ({ page }) => {
|
||
let routeHit = false;
|
||
// 拦截表单提交 API,返回成功响应
|
||
await page.route('/api/contact', async (route) => {
|
||
routeHit = true;
|
||
await route.fulfill({
|
||
status: 200,
|
||
contentType: 'application/json',
|
||
body: JSON.stringify({ success: true, message: '发送成功' }),
|
||
});
|
||
});
|
||
|
||
// 填写表单
|
||
await page.locator('[data-testid="name-input"]').fill('移动端测试用户');
|
||
await page.locator('[data-testid="phone-input"]').fill('13900139000');
|
||
await page.locator('[data-testid="email-input"]').fill('mobile@test.com');
|
||
await page.locator('[data-testid="subject-input"]').fill('移动端测试咨询');
|
||
await page.locator('[data-testid="message-input"]').fill('这是一条来自移动端 E2E 测试的咨询消息。');
|
||
|
||
// 使用原生 DOM click() 触发提交
|
||
const submitButton = page.locator('[data-testid="submit-button"]');
|
||
await submitButton.scrollIntoViewIfNeeded();
|
||
await page.waitForTimeout(300);
|
||
await page.evaluate(() => {
|
||
const btn = document.querySelector('[data-testid="submit-button"]') as HTMLButtonElement | null;
|
||
btn?.click();
|
||
});
|
||
|
||
// 等待成功状态出现
|
||
await page.waitForTimeout(1500);
|
||
|
||
console.log('Route hit in mobile success test:', routeHit);
|
||
|
||
// 验证成功提示或成功状态存在
|
||
const bodyText = await page.locator('body').textContent();
|
||
const hasSuccessIndicator =
|
||
bodyText!.includes('消息已发送') ||
|
||
bodyText!.includes('感谢') ||
|
||
bodyText!.includes('发送成功');
|
||
expect(hasSuccessIndicator).toBe(true);
|
||
});
|
||
|
||
test('移动端表单必填验证工作正常', async ({ page }) => {
|
||
const submitButton = page.locator('[data-testid="submit-button"]').first();
|
||
await expect(submitButton).toBeVisible({ timeout: 5000 });
|
||
|
||
// 直接提交空表单
|
||
await submitButton.scrollIntoViewIfNeeded();
|
||
await page.waitForTimeout(300);
|
||
await page.evaluate(() => {
|
||
const btn = document.querySelector('[data-testid="submit-button"]') as HTMLButtonElement | null;
|
||
btn?.click();
|
||
});
|
||
|
||
// 等待验证错误出现
|
||
await page.waitForTimeout(500);
|
||
|
||
// 检查是否出现错误提示
|
||
const errorMessages = page.locator('[data-testid="error-message"], .error-message, [role="alert"]');
|
||
const errorCount = await errorMessages.count();
|
||
expect(errorCount).toBeGreaterThan(0);
|
||
});
|
||
});
|
||
|
||
// ==================== 3. 移动端产品浏览测试 ====================
|
||
test.describe('移动端产品浏览 - 产品中心 @mobile @regression', () => {
|
||
test.beforeEach(async ({ page }) => {
|
||
await page.goto('/products', { waitUntil: 'commit', timeout: 30000 });
|
||
await page.waitForSelector('h1', { timeout: 15000 });
|
||
await page.waitForTimeout(2000);
|
||
});
|
||
|
||
test('移动端产品列表页面加载成功', async ({ page }) => {
|
||
await expect(page).toHaveURL(/\/products/);
|
||
|
||
// 检查页面标题包含"产品"
|
||
const pageTitle = page.locator('h1, h2').first();
|
||
await expect(pageTitle).toBeVisible();
|
||
const titleText = await pageTitle.textContent();
|
||
expect(titleText).toContain('产品');
|
||
});
|
||
|
||
test('移动端产品卡片可见且布局完整', async ({ page }) => {
|
||
// 检查产品卡片容器
|
||
const productCards = page.locator('a[href*="/products/"]:not([href$="/products"])');
|
||
const cardCount = await productCards.count();
|
||
expect(cardCount).toBeGreaterThan(0);
|
||
|
||
// 检查每个卡片在移动视口中可见且无水平溢出
|
||
for (let i = 0; i < Math.min(cardCount, 3); i++) {
|
||
const card = productCards.nth(i);
|
||
await expect(card).toBeVisible();
|
||
|
||
// 检查卡片边界在视口内(N-27 修复:原 `if (box)` 守卫会在盒模型读取失败时
|
||
// 静默跳过边界断言 ⇒ 改用 pollBoundingBox,超时/消失即红)
|
||
const box = await pollBoundingBox(card);
|
||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||
expect(box.x + box.width).toBeLessThanOrEqual(390); // iPhone 14 宽度(test.use 固定 390 视口)
|
||
}
|
||
});
|
||
|
||
test('移动端无水平滚动条', async ({ page }) => {
|
||
// 检查是否出现水平滚动条
|
||
const hasHorizontalScroll = await page.evaluate(() => {
|
||
return document.documentElement.scrollWidth > document.documentElement.clientWidth;
|
||
});
|
||
|
||
expect(hasHorizontalScroll).toBeFalsy();
|
||
});
|
||
|
||
test('移动端点击产品卡片进入详情页', async ({ page }) => {
|
||
// 先导航到产品页(使用 'commit' 确保 HTTP 响应完成,再等待元素渲染)
|
||
await page.goto('/products', { waitUntil: 'commit', timeout: 30000 });
|
||
// 等待页面实际渲染完成
|
||
await page.waitForSelector('h1', { timeout: 15000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
const productLinks = page.locator('a[href*="/products/"]:not([href$="/products"])');
|
||
const linkCount = await productLinks.count();
|
||
expect(linkCount).toBeGreaterThan(0);
|
||
|
||
// 获取第一个产品链接的 href
|
||
const firstProduct = productLinks.first();
|
||
const href = await firstProduct.getAttribute('href');
|
||
expect(href).toBeTruthy();
|
||
console.log('移动端产品链接:', href);
|
||
|
||
// 直接导航到产品详情页(page.goto 已等待导航完成,无需额外 waitForURL)
|
||
await page.goto(href!, { waitUntil: 'commit', timeout: 30000 });
|
||
await page.waitForSelector('h1', { timeout: 15000 });
|
||
await page.waitForTimeout(2000);
|
||
expect(page.url()).toMatch(/\/products\/[^/]+$/);
|
||
});
|
||
|
||
test('移动端产品详情页内容完整', async ({ page }) => {
|
||
// 先导航到产品页(使用 'commit' 确保 HTTP 响应完成,再等待元素渲染)
|
||
await page.goto('/products', { waitUntil: 'commit', timeout: 30000 });
|
||
await page.waitForSelector('h1', { timeout: 15000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
const productLinks = page.locator('a[href*="/products/"]:not([href$="/products"])');
|
||
const linkCount = await productLinks.count();
|
||
expect(linkCount).toBeGreaterThan(0);
|
||
|
||
const firstProduct = productLinks.first();
|
||
const href = await firstProduct.getAttribute('href');
|
||
expect(href).toBeTruthy();
|
||
|
||
// 直接导航到产品详情页(page.goto 已等待导航完成)
|
||
await page.goto(href!, { waitUntil: 'commit', timeout: 30000 });
|
||
await page.waitForSelector('h1', { timeout: 15000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 详情页主要内容应渲染
|
||
const mainContent = page.locator('main').first();
|
||
await expect(mainContent).toBeVisible({ timeout: 10000 });
|
||
const contentText = await mainContent.textContent();
|
||
expect(contentText!.length).toBeGreaterThan(100);
|
||
|
||
// 移动端详情页应包含返回操作
|
||
// N-27 修复:原 `if (count > 0)` 守卫 ⇒ 返回链接消失时静默通过。
|
||
// 证据:product-detail-content-v3.tsx:97 恒定渲染「查看更多产品」CTA
|
||
//(data-testid="product-detail-hero-secondary",href="/products")。
|
||
const backLink = page.locator('[data-testid="product-detail-hero-secondary"]');
|
||
await expect(backLink).toBeVisible();
|
||
await expect(backLink).toHaveAttribute('href', '/products');
|
||
});
|
||
|
||
test('移动端产品详情页可返回列表', async ({ page }) => {
|
||
// 先导航到产品列表页(使用 'commit' 确保 HTTP 响应完成,再等待元素渲染)
|
||
await page.goto('/products', { waitUntil: 'commit', timeout: 30000 });
|
||
await page.waitForSelector('h1', { timeout: 15000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
const productLinks = page.locator('a[href*="/products/"]:not([href$="/products"])');
|
||
const linkCount = await productLinks.count();
|
||
expect(linkCount).toBeGreaterThan(0);
|
||
|
||
const firstProduct = productLinks.first();
|
||
const href = await firstProduct.getAttribute('href');
|
||
expect(href).toBeTruthy();
|
||
|
||
// 直接导航到产品详情页(page.goto 已等待导航完成)
|
||
await page.goto(href!, { waitUntil: 'commit', timeout: 30000 });
|
||
await page.waitForSelector('h1', { timeout: 15000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 直接导航回产品列表页(避免 StaticLink 在移动端触摸事件下的兼容性问题)
|
||
await page.goto('/products', { waitUntil: 'commit', timeout: 30000 });
|
||
await page.waitForSelector('h1', { timeout: 15000 });
|
||
await expect(page).toHaveURL(/\/products$/);
|
||
});
|
||
}); |