- 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 配置收敛。
259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
import { test, expect } from './fixtures';
|
||
import { pollBoundingBox } from './assert-helpers';
|
||
|
||
/**
|
||
* P3: 多浏览器与多设备兼容性测试
|
||
*
|
||
* 目标:验证网站在不同浏览器和设备上的显示一致性
|
||
*
|
||
* 测试矩阵:
|
||
* - 浏览器:Chrome, Firefox, Safari (WebKit)
|
||
* - 设备:Desktop, Tablet, Mobile (多种尺寸)
|
||
* - 关键页面:首页、关于、联系、产品等
|
||
*/
|
||
|
||
// 设置超时时间
|
||
test.setTimeout(60000);
|
||
|
||
// ==================== 关键页面的跨浏览器一致性测试 ====================
|
||
test.describe('跨浏览器 - 核心页面渲染', { tag: '@regression' }, () => {
|
||
|
||
const criticalPages = [
|
||
{ path: '/', name: '首页' },
|
||
{ path: '/about', name: '关于我们' },
|
||
{ path: '/contact', name: '联系我们' },
|
||
];
|
||
|
||
for (const { path, name } of criticalPages) {
|
||
test(`${name} (${path}) - 页面基本元素可见`, async ({ page }) => {
|
||
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 基本结构检查
|
||
await expect(page.locator('header')).toBeVisible();
|
||
await expect(page.locator('main, [role="main"]').first()).toBeVisible();
|
||
await expect(page.locator('footer, [data-testid="footer"]')).toBeVisible();
|
||
});
|
||
|
||
test(`${name} (${path}) - Logo 显示正常`, async ({ page }) => {
|
||
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// N-27 修复:原守卫 `if (logo.count() > 0)` 让 Logo 消失时静默通过。
|
||
// 证据:header.tsx:96-121 恒定渲染 a[aria-label="返回首页"],内含明暗两版
|
||
// logo(按 html[data-theme] 显隐,恰有一个可见),CSS h-8 md:h-10 ⇒
|
||
// 高度 32/40px、宽度随 160:40 比例自动缩放,桌面与移动断点均成立。
|
||
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
|
||
await expect(logo).toBeVisible();
|
||
|
||
// 检查 Logo 尺寸合理
|
||
const box = await pollBoundingBox(logo);
|
||
expect(box.width).toBeGreaterThan(50);
|
||
expect(box.height).toBeGreaterThan(20);
|
||
});
|
||
|
||
test(`${name} (${path}) - 导航菜单可交互`, async ({ page }) => {
|
||
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// 桌面端导航或移动端按钮应该存在
|
||
const desktopNav = page.locator('[data-testid="desktop-navigation"]');
|
||
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]');
|
||
|
||
const hasDesktopNav = await desktopNav.count() > 0 && await desktopNav.first().isVisible();
|
||
const hasMobileBtn = await mobileMenuBtn.count() > 0 && await mobileMenuBtn.first().isVisible();
|
||
|
||
expect(hasDesktopNav || hasMobileBtn).toBeTruthy();
|
||
});
|
||
}
|
||
});
|
||
|
||
// ==================== 多设备视口适配测试 ====================
|
||
test.describe('多设备 - 视口响应式布局', { tag: '@regression' }, () => {
|
||
|
||
const viewports = [
|
||
{ name: 'Desktop XL', width: 1920, height: 1080, type: 'desktop' as const },
|
||
{ name: 'Desktop', width: 1280, height: 800, type: 'desktop' as const },
|
||
{ name: 'Laptop', width: 1024, height: 768, type: 'laptop' as const },
|
||
{ name: 'Tablet Landscape', width: 768, height: 1024, type: 'tablet' as const },
|
||
{ name: 'Tablet Portrait', width: 768, height: 1024, type: 'tablet' as const },
|
||
{ name: 'Mobile Large', width: 428, height: 926, type: 'mobile' as const },
|
||
{ name: 'Mobile Medium', width: 375, height: 667, type: 'mobile' as const },
|
||
{ name: 'Mobile Small', width: 320, height: 568, type: 'mobile' as const },
|
||
];
|
||
|
||
for (const viewport of viewports) {
|
||
test(`${viewport.name} (${viewport.width}x${viewport.height}) - 首页布局完整`, async ({ page }) => {
|
||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// Header 可见
|
||
await expect(page.locator('header')).toBeVisible();
|
||
|
||
// Main content 可见
|
||
const mainContent = page.locator('main, [role="main"], #main-content').first();
|
||
await expect(mainContent).toBeVisible();
|
||
|
||
// Footer 可见
|
||
await expect(page.locator('footer, [data-testid="footer"]')).toBeVisible();
|
||
});
|
||
|
||
test(`${viewport.name} (${viewport.width}x${viewport.height}) - 无水平滚动条`, async ({ page }) => {
|
||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||
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();
|
||
});
|
||
|
||
if (viewport.type === 'mobile') {
|
||
test(`${viewport.name} - 移动端导航可用`, async ({ page }) => {
|
||
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// N-27 修复:本组用例显式设到移动视口(≤428px < md 断点),
|
||
// header.tsx:192 的 mobile-menu-button(min 44×44px)必须可见。
|
||
// 原守卫 `if (isVisible)` ⇒ 按钮消失时静默通过;改为硬断言 + 轮询盒模型。
|
||
const mobileMenuBtn = page.locator('[data-testid="mobile-menu-button"]').first();
|
||
await expect(mobileMenuBtn).toBeVisible();
|
||
|
||
// 按钮应在可视区域内(pollBoundingBox 等待节点稳定,超时即红)
|
||
const box = await pollBoundingBox(mobileMenuBtn);
|
||
expect(box.x).toBeGreaterThanOrEqual(0);
|
||
expect(box.y).toBeGreaterThanOrEqual(0);
|
||
expect(box.x + box.width).toBeLessThanOrEqual(viewport.width + 10); // 允许小误差
|
||
});
|
||
}
|
||
}
|
||
});
|
||
|
||
// ==================== 特定浏览器行为测试 ====================
|
||
test.describe('特定浏览器 - 行为差异检查', { tag: '@regression' }, () => {
|
||
|
||
test('字体回退机制工作正常', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// 检查 body 的 font-family 是否设置
|
||
const fontFamily = await page.evaluate(() => {
|
||
return window.getComputedStyle(document.body).fontFamily;
|
||
});
|
||
|
||
expect(fontFamily).toBeTruthy();
|
||
expect(fontFamily.length).toBeGreaterThan(0);
|
||
// 应该包含中文字体或通用字体族
|
||
expect(fontFamily.toLowerCase()).toMatch(/sans-serif|serif|system-ui|pingfang|microsoft|hei/i);
|
||
});
|
||
|
||
test('CSS Grid/Flexbox 兼容性', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// 检查是否有使用现代布局的容器
|
||
const modernLayoutUsed = await page.evaluate(() => {
|
||
const allElements = document.querySelectorAll('*');
|
||
let usesGrid = false;
|
||
let usesFlex = false;
|
||
|
||
for (const el of allElements) {
|
||
const style = window.getComputedStyle(el);
|
||
if (style.display === 'grid') usesGrid = true;
|
||
if (style.display === 'flex') usesFlex = true;
|
||
|
||
if (usesGrid && usesFlex) break;
|
||
}
|
||
|
||
return { usesGrid, usesFlex };
|
||
});
|
||
|
||
// 网站应该使用现代CSS布局
|
||
expect(modernLayoutUsed.usesGrid || modernLayoutUsed.usesFlex).toBeTruthy();
|
||
});
|
||
|
||
test('图片懒加载兼容性', async ({ page }) => {
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(2000);
|
||
|
||
// 检查图片是否正确加载
|
||
const images = page.locator('img');
|
||
const imageCount = await images.count();
|
||
|
||
// N-27 修复:原 `if (imageCount > 0)` 守卫——首页 img 数为 0 时本例静默通过。
|
||
// header.tsx:103-121 恒渲染两枚 logo <img>(priority/eager),img 数为 0 即真实回归。
|
||
expect(imageCount).toBeGreaterThan(0);
|
||
|
||
let loadedCount = 0;
|
||
|
||
for (let i = 0; i < Math.min(imageCount, 10); i++) {
|
||
const img = images.nth(i);
|
||
const naturalWidth = await img.evaluate((el: HTMLImageElement) => el.naturalWidth);
|
||
if (naturalWidth > 0) loadedCount++;
|
||
}
|
||
|
||
// 至少应该有一些图片加载成功(无条件断言)
|
||
expect(loadedCount).toBeGreaterThan(0);
|
||
});
|
||
|
||
test('表单元素样式一致', async ({ page }) => {
|
||
await page.goto('/contact', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(3000);
|
||
|
||
// N-27 修复:原双守卫 `if (inputCount > 0)` + `if (box)` 使本例在表单消失时
|
||
// 静默通过。联系页表单为恒定契约(contact-content-v3 渲染必填输入),
|
||
// 改为主内容区内作用域的硬断言 + 轮询盒模型。
|
||
const inputs = page.locator('main input:visible, main textarea:visible');
|
||
const inputCount = await inputs.count();
|
||
expect(inputCount).toBeGreaterThan(0);
|
||
|
||
for (let i = 0; i < Math.min(inputCount, 5); i++) {
|
||
const input = inputs.nth(i);
|
||
// Verify input is visible and has reasonable dimensions
|
||
const box = await pollBoundingBox(input);
|
||
expect(box.width).toBeGreaterThan(50);
|
||
expect(box.height).toBeGreaterThan(20);
|
||
}
|
||
});
|
||
});
|
||
|
||
// ==================== 触摸设备模拟测试 ====================
|
||
test.describe('触摸设备 - 手势交互', { tag: '@regression' }, () => {
|
||
|
||
test('触摸滑动流畅(无卡顿)', async ({ page, browserName }) => {
|
||
// touchscreen API is only available in Chromium with touch emulation
|
||
test.skip(browserName !== 'chromium', 'Touchscreen API only available in Chromium');
|
||
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// Simulate scroll via mouse wheel instead of touchscreen (cross-browser compatible)
|
||
await page.mouse.wheel(0, 300);
|
||
await page.waitForTimeout(500);
|
||
|
||
// 页面不应该崩溃或有错误
|
||
const bodyVisible = await page.locator('body').isVisible();
|
||
expect(bodyVisible).toBeTruthy();
|
||
});
|
||
|
||
test('双击缩放不会破坏布局', async ({ page }) => {
|
||
await page.setViewportSize({ width: 375, height: 667 });
|
||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||
await page.waitForTimeout(1500);
|
||
|
||
// 模拟双击
|
||
const header = page.locator('header').first();
|
||
await header.dblclick();
|
||
await page.waitForTimeout(500);
|
||
|
||
// Header 应该仍然可见
|
||
await expect(header).toBeVisible();
|
||
});
|
||
});
|