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((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((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); }); });