import { test, expect } from "./fixtures"; /** * P4: 性能与可访问性审计 * * 目标: * 1. 性能指标验证(加载时间、渲染性能、资源优化) * 2. 可访问性合规检查(WCAG 2.1 AA 标准) * 3. 资源优化验证(图片压缩、代码分割等) */ // 设置超时时间 test.setTimeout(60000); // ==================== 1. 性能指标测试 ==================== test.describe("性能 - 页面加载指标", { tag: "@regression" }, () => { test("首页 DOMContentLoaded 时间 < 3秒", async ({ page }) => { // 默认口径的 webServer 是 npm run dev:路由首次访问要现场编译,不预热就把编译器耗时算成页面耗时。 await page.goto("/", { waitUntil: "commit", timeout: 30000 }); const startTime = Date.now(); await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); const loadTime = Date.now() - startTime; console.log(`DOMContentLoaded time: ${loadTime}ms`); expect(loadTime).toBeLessThan(3000); }); test("首页完整加载时间 < 8秒", async ({ page }) => { await page.goto("/", { waitUntil: "commit", timeout: 30000 }); const startTime = Date.now(); await page.goto("/", { waitUntil: "load", timeout: 30000 }); const loadTime = Date.now() - startTime; await page.waitForTimeout(1000); // 等待客户端渲染,不计入加载耗时 console.log(`Full load time: ${loadTime}ms`); expect(loadTime).toBeLessThan(8000); }); test("关键页面加载时间在合理范围", async ({ page }) => { const pages = [ { path: "/", name: "首页" }, { path: "/about", name: "关于我们" }, { path: "/contact", name: "联系我们" }, ]; for (const { path, name } of pages) { 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); // 让水合后的 DOM 稳定,不计入加载耗时 console.log(`${name} load time: ${loadTime}ms`); // 每个页面应该在5秒内完成基本加载 expect(loadTime).toBeLessThan(5000); } }); test("首屏内容快速可见 (FCP < 2s)", async ({ page }) => { // 使用 Performance API 测量 // N-27:去掉 `if (fcpMetrics)` 守卫——单次 evaluate 时机过早时原实现会静默少测。 // 改为轮询直到 FCP 条目出现(paint timing 四浏览器引擎均支持),拿不到 ⇒ 用例失败。 await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); const readFcp = () => page.evaluate(() => { const paintEntries = performance .getEntriesByType("paint") .filter((entry) => entry.name === "first-contentful-paint"); return paintEntries.length > 0 ? paintEntries[0].startTime : null; }); await expect .poll(readFcp, { timeout: 15000, message: "等待 first-contentful-paint 度量条目出现", }) .not.toBeNull(); const fcpMetrics = await readFcp(); if (fcpMetrics === null) { throw new Error("first-contentful-paint 度量条目在轮询通过后仍缺失"); } console.log(`FCP: ${fcpMetrics}ms`); expect(fcpMetrics).toBeLessThan(2000); }); test("页面不阻塞主线程过长", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(2000); // 检查长任务(Long Tasks) const longTaskCount = await page.evaluate(() => { return new Promise((resolve) => { const observer = new PerformanceObserver((list) => { resolve(list.getEntries().length); }); observer.observe({ entryTypes: ["longtask"] }); // 3秒后如果没有长任务,返回0 setTimeout(() => resolve(0), 3000); }); }); // 长任务数量应该很少(理想情况下为0) console.log(`Long task count: ${longTaskCount}`); expect(longTaskCount).toBeLessThanOrEqual(3); }); }); // ==================== 2. 资源优化测试 ==================== test.describe("性能 - 资源优化", { tag: "@regression" }, () => { 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 optimizedCount = 0; let largeImages = 0; images.forEach((img) => { // 检查图片是否使用了现代格式 const src = img.src.toLowerCase(); if (src.includes(".webp") || src.includes(".avif")) { optimizedCount++; } // 检查是否有 width/height 属性(防止布局偏移) if (img.width && img.height && img.width > 0 && img.height > 0) { // 有尺寸属性,有助于 CLS 优化 } // 假设大图片(这里简化处理) if (img.naturalWidth > 1920) { largeImages++; } }); return { totalImages: images.length, optimizedFormatCount: optimizedCount, largeImageCount: largeImages, }; }); console.log("Image analysis:", imageAnalysis); expect(imageAnalysis.totalImages).toBeGreaterThan(0); }); test("CSS 和 JS 资源非阻塞加载", 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("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) { // 单个关键资源加载时间不应超过3秒 expect(resource.duration).toBeLessThan(3000); } }); test("字体加载策略合理", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(2000); const fontInfo = await page.evaluate(() => { const fonts = performance .getEntriesByType("resource") .filter((e: PerformanceResourceTiming) => e.initiatorType === "font"); return { fontCount: fonts.length, fonts: fonts.map((f: PerformanceResourceTiming) => ({ name: f.name.split("/").pop(), duration: Math.round(f.duration), })), }; }); console.log("Font loading:", fontInfo); // 字体数量不应过多 expect(fontInfo.fontCount).toBeLessThanOrEqual(10); }); }); // ==================== 3. 可访问性基础测试 ==================== test.describe("可访问性 - WCAG 2.1 AA 合规", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); }); test("所有交互元素可通过键盘访问", async ({ page }) => { // Count focusable elements (basic accessibility check) const focusableCount = await page.evaluate(() => { return document.querySelectorAll( 'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])', ).length; }); console.log(`Found ${focusableCount} focusable elements`); expect(focusableCount).toBeGreaterThan(0); // N-27:Tab 是否把焦点移入页面内容存在引擎差异(webkit 与 chromium-mobile 实测不移), // 而原断言 `expect(typeof hasFocus).toBe('boolean')` 恒真,正是为掩盖这点。 // 改成与引擎无关的确定性检查:页面里的可聚焦元素确实可获得焦点。 const firstFocusable = page .locator("a[href], button:not([disabled]), input, select, textarea") .first(); await expect(firstFocusable).toBeVisible(); await firstFocusable.focus(); await expect(firstFocusable).toBeFocused(); }); test("表单标签关联正确", async ({ page }) => { await page.goto("/contact", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(2500); // 检查表单输入框是否有关联的 label const formInputs = await page.evaluate(() => { const inputs = document.querySelectorAll("input, textarea, select"); const results: Array<{ tag: string; id: string; hasLabel: boolean; ariaLabel: string | null; }> = []; inputs.forEach((input) => { const id = input.id; const ariaLabel = input.getAttribute("aria-label"); // 检查是否有显式 label let hasLabel = false; if (id) { const label = document.querySelector(`label[for="${id}"]`); hasLabel = label !== null; } results.push({ tag: input.tagName, id: id || "", hasLabel, ariaLabel, }); }); return results; }); for (const input of formInputs) { // 每个输入框应该有 label 或 aria-label const hasAccessibleName = input.hasLabel || input.ariaLabel; expect( hasAccessibleName, `Input ${input.tag}${input.id ? "#" + input.id : ""} 缺少可访问名称`, ).toBeTruthy(); } }); test("颜色对比度符合标准(简化版)", async ({ page }) => { // 这是一个简化的对比度检查,完整检查应使用 axe-core 或类似工具 const contrastIssues = await page.evaluate(() => { const issues: string[] = []; // 检查主要文本元素 const textElements = document.querySelectorAll( "p, h1, h2, h3, h4, h5, h6, span, a, li, td, th", ); textElements.forEach((el) => { const style = window.getComputedStyle(el); const color = style.color; const bgColor = style.backgroundColor; // 简化检查:确保文本不是白色背景上的浅色文字 // 完整实现应计算相对亮度比值 if ( color === "rgb(255, 255, 255)" && bgColor === "rgb(255, 255, 255)" ) { issues.push(`${el.tagName}: 白色文字在白色背景上`); } }); return issues; }); expect( contrastIssues.length, `发现颜色对比度问题:\n${contrastIssues.join("\n")}`, ).toBe(0); }); test("ARIA 标签使用正确", async ({ page }) => { // 检查 ARIA 属性的使用情况 const ariaUsage = await page.evaluate(() => { const elementsWithAria = document.querySelectorAll( "[role], [aria-label], [aria-live], [aria-expanded]", ); const issues: string[] = []; elementsWithAria.forEach((el) => { const role = el.getAttribute("role"); // 如果有 role,应该有相应的语义 if (role) { // 检查 role 是否有效 const validRoles = [ "button", "link", "navigation", "main", "banner", "contentinfo", "dialog", "alert", "menu", "menuitem", "checkbox", "radio", "textbox", "combobox", "listbox", "option", "tree", "treeitem", "tab", "tabpanel", "progressbar", "tooltip", ]; if (!validRoles.includes(role!)) { issues.push(`Invalid role "${role}" on ${el.tagName}`); } } }); return { elementsWithAria: elementsWithAria.length, issues, }; }); console.log("ARIA usage:", ariaUsage); expect( ariaUsage.issues.length, `ARIA 使用问题:\n${ariaUsage.issues.join("\n")}`, ).toBe(0); }); test("焦点管理清晰可见", async ({ page }) => { // Tab 到第一个链接并检查焦点样式 // N-27:去掉 `if (focusedElement.count() > 0)` 守卫。'/' 首个 Tab 焦点落在跳转链接 // (layout.tsx:201),按不到焦点元素本身就应失败。 await page.keyboard.press("Tab"); await page.waitForTimeout(200); const focusedElement = page.locator(":focus").first(); await expect(focusedElement).toBeAttached(); // 检查焦点元素是否有明显的焦点样式(outline 或 box-shadow) const hasOutlineStyle = await focusedElement.evaluate((el) => { const style = window.getComputedStyle(el); return ( style.outlineStyle !== "none" || style.boxShadow !== "none" || el.hasAttribute("data-focus-visible") ); }); // N-27:原实现只 console.log 这个布尔值,全程没有任何断言 ⇒ 焦点样式被删掉也照样绿。 expect(hasOutlineStyle).toBe(true); }); test("跳转链接功能正常", async ({ page }) => { // Find the skip-to-content link // N-27:去掉 `if (count() > 0)` 守卫。跳转链接由根布局无条件渲染(layout.tsx:201-207), // 从 DOM 移除即失败。 const skipLink = page.locator("[data-skip-to-content]").first(); await expect(skipLink).toBeVisible(); // Focus the skip link first (it's off-screen until focused) await skipLink.focus(); await page.waitForTimeout(200); // Navigate to the main content via keyboard await page.keyboard.press("Enter"); await page.waitForTimeout(500); // Focus should have moved to the main content area const activeElementId = await page.evaluate( () => document.activeElement?.id || "", ); const urlHash = page.url().includes("#main-content"); // Either the main element is focused or the URL hash changed expect(activeElementId || urlHash).toBeTruthy(); }); test("错误消息对屏幕阅读器友好", async ({ page }) => { await page.goto("/contact", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(2500); // 尝试提交空表单以触发验证错误 // N-27:去掉 `if (isVisible)` 与 `if (errorCount > 0)` 双守卫——提交按钮 // (contact-content-v3.tsx 的 submit-button testid)与空表单校验错误 // (zod safeParse 5 条,labeled-input.tsx:56-65 role="alert")都是页面的确定装配, // 任一消失 ⇒ 用例失败而不是静默通过。 const submitButton = page .locator('[data-testid="submit-button"], button[type="submit"]') .first(); await expect(submitButton).toBeVisible(); await submitButton.click(); const errorMessages = page.locator( '[role="alert"], [data-testid="error-message"], .error-message', ); await expect(errorMessages.first()).toBeVisible({ timeout: 5000 }); const errorCount = await errorMessages.count(); expect(errorCount).toBeGreaterThan(0); for (let i = 0; i < errorCount; i++) { const errorMsg = errorMessages.nth(i); const role = await errorMsg.getAttribute("role"); const ariaLive = await errorMsg.getAttribute("aria-live"); // 错误消息应该有 alert role 或 aria-live 属性 const isAccessible = role === "alert" || role === "status" || ariaLive === "assertive" || ariaLive === "polite"; expect(isAccessible, `错误消息缺少必要的 ARIA 属性`).toBeTruthy(); } }); }); // ==================== 4. SEO 与元数据测试 ==================== test.describe("SEO - 元数据完整性", { tag: "@regression" }, () => { test("各主要页面有唯一标题", async ({ page }) => { const pages = [ "/", "/about", "/contact", "/products", "/services", "/solutions", ]; const titles: string[] = []; for (const path of pages) { await page.goto(path, { waitUntil: "domcontentloaded", timeout: 30000 }); const title = await page.title(); titles.push(title); } // 检查标题唯一性 const uniqueTitles = new Set(titles); console.log( `Found ${uniqueTitles.size} unique titles out of ${pages.length} pages`, ); // 至少应该有一半以上的页面有不同标题 expect(uniqueTitles.size).toBeGreaterThanOrEqual( Math.ceil(pages.length / 2), ); }); test("Meta Description 存在且长度合适", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); // N-27:去掉 `if (count > 0)` 守卫。description 由根布局 metadata 无条件输出 // (src/app/layout.tsx:50,64 个字符,落在 50–160 区间内)。 const metaDescription = page.locator('meta[name="description"]'); await expect(metaDescription).toHaveCount(1); const content = await metaDescription.getAttribute("content"); expect(content).toBeTruthy(); expect(content!.length).toBeGreaterThanOrEqual(50); // 不宜过短 expect(content!.length).toBeLessThanOrEqual(160); // SEO 推荐长度 }); test("Open Graph 标签存在(社交媒体分享)", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); // N-27:去掉 `if (hasOgTitle)` 守卫。openGraph.title/description/images // 由根布局 metadata 无条件输出(src/app/layout.tsx:65-80)。 const ogTitle = page.locator('meta[property="og:title"]'); await expect(ogTitle).toHaveCount(1); const content = await ogTitle.getAttribute("content"); expect(content).toBeTruthy(); const ogDescription = page.locator('meta[property="og:description"]'); const ogImage = page.locator('meta[property="og:image"]'); console.log("OG tags present:", { title: await ogTitle.count(), description: await ogDescription.count(), image: await ogImage.count(), }); }); test("Canonical URL 正确设置", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); // N-27:去掉 `if (count() > 0)` 守卫。alternatives.canonical 在根布局固定为 // https://www.novalon.cn(src/app/layout.tsx:87-89),metadataBase 同域(:44)。 const canonical = page.locator('link[rel="canonical"]'); await expect(canonical).toHaveCount(1); const href = await canonical.getAttribute("href"); expect(href).toBeTruthy(); expect(href).toMatch(/^https?:\/\/[^/]+\.cn/); }); test("结构化数据存在(JSON-LD)", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); // N-27:去掉 `if (count > 0)` 守卫。根布局无条件渲染 OrganizationSchema // (src/app/layout.tsx:192 → components/seo/structured-data), // 站点 JSON-LD 从 DOM 消失即失败。 const jsonLdScripts = page.locator('script[type="application/ld+json"]'); await expect(jsonLdScripts.first()).toBeAttached(); const count = await jsonLdScripts.count(); expect(count).toBeGreaterThan(0); // 验证 JSON-LD 格式正确 for (let i = 0; i < count; i++) { const content = await jsonLdScripts.nth(i).textContent(); expect(content).toBeTruthy(); try { JSON.parse(content!); // 应该是有效的JSON } catch { throw new Error(`Invalid JSON-LD at index ${i}`); } } console.log(`Found ${count} JSON-LD scripts`); }); }); // ==================== 5. 安全性基础检查 ==================== test.describe("安全性 - 基础安全头", { tag: "@regression" }, () => { test("安全响应头设置", async ({ page }) => { const response = await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000, }); // N-27 修复:原 `if (response)` 守卫让全部断言在响应缺失时静默跳过。 // 改为硬断言。判据来自 next.config.mjs 的 APP_SECURITY_HEADERS(应用层注入, // dev 与 prod harness 均生效);HSTS 由边缘层注入、本地 harness 不产生(见 N-23), // 故不在此处断言 strict-transport-security。 if (!response) { throw new Error("首页导航没有返回响应,无法检查安全响应头"); } const headers = response.headers(); // 常见安全头快照(仅用于失败信息可读) const securityHeaders = { "X-Content-Type-Options": headers["x-content-type-options"], "X-Frame-Options": headers["x-frame-options"], "X-XSS-Protection": headers["x-xss-protection"], "Content-Security-Policy": headers["content-security-policy"], "Referrer-Policy": headers["referrer-policy"], }; // 至少应该有一些安全头 const headerCount = Object.values(securityHeaders).filter( (h) => h, ).length; expect(headerCount).toBeGreaterThanOrEqual(1); // next.config.mjs:49-67 APP_SECURITY_HEADERS 中承诺的三项硬契约 expect(headers["x-content-type-options"], JSON.stringify(securityHeaders)).toBe("nosniff"); expect(headers["x-frame-options"], JSON.stringify(securityHeaders)).toBe("DENY"); expect( headers["content-security-policy"], JSON.stringify(securityHeaders), ).toBeTruthy(); }); test("没有混合内容警告", async ({ page, baseURL }) => { // N-27:原实现在末尾注明「只记录不强制断言」⇒ 本例永远不会失败。 // 该判据只在 HTTPS 页面上有意义(本站 harness 恒为 http://localhost,见 playwright.config.ts:27), // 故显式 skip 而不是伪装通过,与 security-headers 的 HTTPS 检查同法。 test.skip( !!baseURL && baseURL.startsWith("http://localhost"), "本地 harness 以 http://localhost 提供站点,混合内容判据只在 HTTPS 部署上成立", ); // 在 HTTPS 页面上检查是否有 HTTP 资源 const mixedContent = await page.evaluate(() => { const resources = performance.getEntriesByType( "resource", ) as PerformanceResourceTiming[]; return resources .filter((r) => r.name.startsWith("http://")) .map((r) => r.name); }); expect(mixedContent).toEqual([]); }); });