Files
novalon-website/e2e/mobile-performance.spec.ts
T
zhangxiang 6bb7c557ee 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
  配置收敛。
2026-09-28 10:48:08 +08:00

303 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect, devices } from "./fixtures";
import { scanTouchTargets, formatTargets } from "./touch-targets";
/**
* 移动端性能基线测试
*
* 目标:在 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);
// 文件头声明的是「iPhone 14 视口(390x844)」,但 viewport 此前由 project 决定 ——
// 桌面 project 量到的是桌面布局。显式钉住设备,指标与触摸目标才对应同一条移动布局。
test.use({ ...devices["iPhone 14"] });
// ==================== 1. 核心性能指标 ====================
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 });
});
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 }) => {
// 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);
});
},
);
// ==================== 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 }) => {
// 同核心指标块:预热排除 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);
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);
// 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);
});
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("触摸目标尺寸:AA 2.5.8 ≥ 24px 硬门禁", async ({ page }) => {
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
await page.waitForTimeout(1500);
const { aaViolations, aaaShortlist } = await scanTouchTargets(page);
if (aaaShortlist.length > 0) {
console.log(
`未达 AAA 2.5.5(44px)的目标 ${aaaShortlist.length} 个:\n${formatTargets(aaaShortlist)}`,
);
}
expect(
aaViolations.length,
`低于 WCAG 2.2 AA 2.5.8(24×24)的触摸目标:\n${formatTargets(aaViolations)}`,
).toBe(0);
});
});