test(qa): e2e 装配修正 + 集成/门禁测试与工具链重构
- 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 配置收敛。
This commit is contained in:
+229
-183
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect, devices } from "./fixtures";
|
||||
import { scanTouchTargets, formatTargets } from "./touch-targets";
|
||||
|
||||
/**
|
||||
* 移动端性能基线测试
|
||||
@@ -20,187 +21,254 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
test.setTimeout(90000);
|
||||
|
||||
// 文件头声明的是「iPhone 14 视口(390x844)」,但 viewport 此前由 project 决定 ——
|
||||
// 桌面 project 量到的是桌面布局。显式钉住设备,指标与触摸目标才对应同一条移动布局。
|
||||
test.use({ ...devices["iPhone 14"] });
|
||||
|
||||
// ==================== 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;
|
||||
});
|
||||
test.describe(
|
||||
"移动端性能 - 核心加载指标",
|
||||
{ tag: "@mobile @performance" },
|
||||
() => {
|
||||
// 默认口径的 webServer 是 npm run dev:某路由首次访问要现场编译,编译耗时会计进 paint / navigation 计时
|
||||
// (chain4 实测首页 LCP 2736ms 破 2500ms 线)。先预热一次,量到的才是页面本身。
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "commit", timeout: 30000 });
|
||||
});
|
||||
|
||||
if (fcp !== null) {
|
||||
test("首页 FCP < 2s", async ({ page }) => {
|
||||
// N-27 修复:原 `if (fcp !== null)` 守卫 ⇒ FCP 永不出现(白屏/脚本崩溃)时
|
||||
// 本例静默通过。改为轮询直到 paint 条目出现,超时即红;出现后做硬阈值断言。
|
||||
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
|
||||
const readFcp = () =>
|
||||
page.evaluate(() => {
|
||||
const entries = performance.getEntriesByType("paint");
|
||||
const fcpEntry = entries.find(
|
||||
(e) => e.name === "first-contentful-paint",
|
||||
);
|
||||
return fcpEntry ? fcpEntry.startTime : null;
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(readFcp, { timeout: 15000, message: "FCP 在 15s 内未出现" })
|
||||
.not.toBeNull();
|
||||
const fcp = await readFcp();
|
||||
if (fcp === null) {
|
||||
throw new Error("FCP 在轮询通过后仍为 null(paint 条目在读取瞬间消失)");
|
||||
}
|
||||
|
||||
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) {
|
||||
test("首页 LCP < 2.5s", async ({ page }) => {
|
||||
// N-27 修复:原 `if (lcp !== null)` 守卫 ⇒ 观测不到 LCP 时静默通过。
|
||||
// 改为轮询直到拿到非 null 的 LCP(每轮内部 5s 超时返回 null 后由 poll 重试),
|
||||
// 15s 仍取不到即红。若某浏览器引擎不支持 LCP 条目,本例将红——这是刻意收紧,
|
||||
// 已在整改报告中列为待运行时验证项。
|
||||
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
|
||||
const readLcp = () =>
|
||||
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,由 expect.poll 继续重试
|
||||
setTimeout(() => resolve(null), 5000);
|
||||
});
|
||||
});
|
||||
|
||||
await expect
|
||||
.poll(readLcp, { timeout: 15000, message: "LCP 在 15s 内未采到" })
|
||||
.not.toBeNull();
|
||||
const lcp = await readLcp();
|
||||
if (lcp === null) {
|
||||
throw new Error("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);
|
||||
});
|
||||
});
|
||||
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: '案例' },
|
||||
];
|
||||
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);
|
||||
});
|
||||
}
|
||||
});
|
||||
for (const { path, name } of pages) {
|
||||
test(`${name} (${path}) 加载 < 5s`, async ({ page }) => {
|
||||
// 同核心指标块:预热排除 dev 现场编译,且把水合等待移出计时区间,
|
||||
// 否则 5000ms 预算里有 1500ms 是固定的 settle 等待(chain3/chain4 同类破线根因)。
|
||||
await page.goto(path, { waitUntil: "commit", timeout: 30000 });
|
||||
|
||||
const startTime = Date.now();
|
||||
await page.goto(path, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
const loadTime = Date.now() - startTime;
|
||||
await page.waitForTimeout(1500);
|
||||
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);
|
||||
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;
|
||||
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++;
|
||||
}
|
||||
images.forEach((img) => {
|
||||
if (img.naturalWidth > 1920) {
|
||||
largeImages++;
|
||||
}
|
||||
// 检查移动端是否加载了远大于视口的图片
|
||||
if (img.naturalWidth > 800 && img.naturalWidth > 0) {
|
||||
oversizedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
totalImages: images.length,
|
||||
largeImageCount: largeImages,
|
||||
oversizedForMobile: oversizedCount,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
totalImages: images.length,
|
||||
largeImageCount: largeImages,
|
||||
oversizedForMobile: oversizedCount,
|
||||
};
|
||||
console.log("Mobile image analysis:", imageAnalysis);
|
||||
expect(imageAnalysis.totalImages).toBeGreaterThan(0);
|
||||
// N-33:largeImageCount / oversizedForMobile 原本算完只 log、从不断言(等于白算)。
|
||||
// 阈值不是拍脑袋:在装配后的 standalone 产物上以 375px 视口实测 /、/about、/products、
|
||||
// /cases、/news 五页,naturalWidth > 1920 的图片数为 0(首页最大 480,/cases 与 /news 最大 1108),
|
||||
// 故按「不得引入超过 1920 宽的图源」设棘轮;>800 的张数在 /cases 与 /news 上为 1~2,属设计现状,
|
||||
// 因此只作可观测输出、不设阈值(硬设会误伤真实图片)。
|
||||
expect(imageAnalysis.largeImageCount).toBe(0);
|
||||
});
|
||||
|
||||
console.log('Mobile image analysis:', imageAnalysis);
|
||||
expect(imageAnalysis.totalImages).toBeGreaterThan(0);
|
||||
});
|
||||
test("JS/CSS 资源加载时间合理", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
|
||||
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,
|
||||
}));
|
||||
});
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
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 });
|
||||
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;
|
||||
return (
|
||||
document.documentElement.scrollWidth >
|
||||
document.documentElement.clientWidth
|
||||
);
|
||||
});
|
||||
|
||||
expect(hasHorizontalScroll).toBeFalsy();
|
||||
});
|
||||
|
||||
test('滚动流畅无卡顿', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
test("滚动流畅无卡顿", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
|
||||
// 模拟快速滚动
|
||||
@@ -210,47 +278,25 @@ test.describe('移动端性能 - 渲染性能', { tag: '@mobile @performance' },
|
||||
}
|
||||
|
||||
// 滚动后页面不应崩溃
|
||||
const bodyVisible = await page.locator('body').isVisible();
|
||||
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 });
|
||||
test("触摸目标尺寸:AA 2.5.8 ≥ 24px 硬门禁", 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 { aaViolations, aaaShortlist } = await scanTouchTargets(page);
|
||||
|
||||
if (aaaShortlist.length > 0) {
|
||||
console.log(
|
||||
`未达 AAA 2.5.5(44px)的目标 ${aaaShortlist.length} 个:\n${formatTargets(aaaShortlist)}`,
|
||||
);
|
||||
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);
|
||||
|
||||
expect(
|
||||
aaViolations.length,
|
||||
`低于 WCAG 2.2 AA 2.5.8(24×24)的触摸目标:\n${formatTargets(aaViolations)}`,
|
||||
).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user