Files
novalon-website/e2e/p4-performance-a11y.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

648 lines
22 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 } 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([]);
});
});