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:
+330
-233
@@ -1,4 +1,4 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { test, expect } from "./fixtures";
|
||||
|
||||
/**
|
||||
* P4: 性能与可访问性审计
|
||||
@@ -13,12 +13,14 @@ import { test, expect } from '@playwright/test';
|
||||
test.setTimeout(60000);
|
||||
|
||||
// ==================== 1. 性能指标测试 ====================
|
||||
test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
|
||||
test.describe("性能 - 页面加载指标", { tag: "@regression" }, () => {
|
||||
test("首页 DOMContentLoaded 时间 < 3秒", async ({ page }) => {
|
||||
// 默认口径的 webServer 是 npm run dev:路由首次访问要现场编译,不预热就把编译器耗时算成页面耗时。
|
||||
await page.goto("/", { waitUntil: "commit", timeout: 30000 });
|
||||
|
||||
test('首页 DOMContentLoaded 时间 < 3秒', async ({ page }) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
|
||||
const loadTime = Date.now() - startTime;
|
||||
console.log(`DOMContentLoaded time: ${loadTime}ms`);
|
||||
@@ -26,32 +28,36 @@ test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
|
||||
expect(loadTime).toBeLessThan(3000);
|
||||
});
|
||||
|
||||
test('首页完整加载时间 < 8秒', async ({ page }) => {
|
||||
test("首页完整加载时间 < 8秒", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "commit", timeout: 30000 });
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
await page.goto('/', { waitUntil: 'load', timeout: 30000 });
|
||||
await page.waitForTimeout(1000); // 等待客户端渲染
|
||||
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 }) => {
|
||||
test("关键页面加载时间在合理范围", async ({ page }) => {
|
||||
const pages = [
|
||||
{ path: '/', name: '首页' },
|
||||
{ path: '/about', name: '关于我们' },
|
||||
{ path: '/contact', name: '联系我们' },
|
||||
{ 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 });
|
||||
await page.waitForTimeout(1500);
|
||||
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秒内完成基本加载
|
||||
@@ -59,28 +65,37 @@ test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('首屏内容快速可见 (FCP < 2s)', async ({ page }) => {
|
||||
test("首屏内容快速可见 (FCP < 2s)", async ({ page }) => {
|
||||
// 使用 Performance API 测量
|
||||
const fcpMetrics = await page.goto('/', {
|
||||
waitUntil: 'domcontentloaded',
|
||||
timeout: 30000,
|
||||
}).then(async () => {
|
||||
await page.waitForTimeout(1000);
|
||||
return await page.evaluate(() => {
|
||||
const paintEntries = performance.getEntriesByType('paint')
|
||||
.filter(entry => entry.name === 'first-contentful-paint');
|
||||
// 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;
|
||||
});
|
||||
});
|
||||
|
||||
if (fcpMetrics) {
|
||||
console.log(`FCP: ${fcpMetrics}ms`);
|
||||
expect(fcpMetrics).toBeLessThan(2000);
|
||||
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 });
|
||||
test("页面不阻塞主线程过长", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// 检查长任务(Long Tasks)
|
||||
@@ -89,7 +104,7 @@ test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
|
||||
const observer = new PerformanceObserver((list) => {
|
||||
resolve(list.getEntries().length);
|
||||
});
|
||||
observer.observe({ entryTypes: ['longtask'] });
|
||||
observer.observe({ entryTypes: ["longtask"] });
|
||||
|
||||
// 3秒后如果没有长任务,返回0
|
||||
setTimeout(() => resolve(0), 3000);
|
||||
@@ -103,21 +118,20 @@ test.describe('性能 - 页面加载指标', { tag: '@regression' }, () => {
|
||||
});
|
||||
|
||||
// ==================== 2. 资源优化测试 ====================
|
||||
test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
|
||||
|
||||
test('图片使用适当格式和尺寸', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
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'));
|
||||
const images = Array.from(document.querySelectorAll("img"));
|
||||
let optimizedCount = 0;
|
||||
let largeImages = 0;
|
||||
|
||||
images.forEach(img => {
|
||||
images.forEach((img) => {
|
||||
// 检查图片是否使用了现代格式
|
||||
const src = img.src.toLowerCase();
|
||||
if (src.includes('.webp') || src.includes('.avif')) {
|
||||
if (src.includes(".webp") || src.includes(".avif")) {
|
||||
optimizedCount++;
|
||||
}
|
||||
|
||||
@@ -139,19 +153,24 @@ test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Image analysis:', imageAnalysis);
|
||||
console.log("Image analysis:", imageAnalysis);
|
||||
expect(imageAnalysis.totalImages).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('CSS 和 JS 资源非阻塞加载', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
test("CSS 和 JS 资源非阻塞加载", async ({ page }) => {
|
||||
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
|
||||
const resourceTiming = await page.evaluate(() => {
|
||||
const entries = performance.getEntriesByType('resource') as PerformanceResourceTiming[];
|
||||
const entries = performance.getEntriesByType(
|
||||
"resource",
|
||||
) as PerformanceResourceTiming[];
|
||||
return entries
|
||||
.filter(e => e.initiatorType === 'script' || e.initiatorType === 'stylesheet')
|
||||
.map(e => ({
|
||||
name: e.name.split('/').pop(),
|
||||
.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,
|
||||
@@ -159,13 +178,14 @@ test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
|
||||
});
|
||||
|
||||
// 输出资源加载信息用于分析
|
||||
console.log('Resource timing:', resourceTiming.slice(0, 10));
|
||||
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')
|
||||
const criticalResources = resourceTiming.filter(
|
||||
(r) =>
|
||||
r.name?.includes("main") ||
|
||||
r.name?.includes("chunk") ||
|
||||
r.name?.includes("app"),
|
||||
);
|
||||
|
||||
for (const resource of criticalResources) {
|
||||
@@ -174,24 +194,25 @@ test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('字体加载策略合理', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
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');
|
||||
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(),
|
||||
name: f.name.split("/").pop(),
|
||||
duration: Math.round(f.duration),
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
console.log('Font loading:', fontInfo);
|
||||
console.log("Font loading:", fontInfo);
|
||||
|
||||
// 字体数量不应过多
|
||||
expect(fontInfo.fontCount).toBeLessThanOrEqual(10);
|
||||
@@ -199,50 +220,54 @@ test.describe('性能 - 资源优化', { tag: '@regression' }, () => {
|
||||
});
|
||||
|
||||
// ==================== 3. 可访问性基础测试 ====================
|
||||
test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () => {
|
||||
|
||||
test.describe("可访问性 - WCAG 2.1 AA 合规", { tag: "@regression" }, () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 });
|
||||
await page.waitForTimeout(1500);
|
||||
});
|
||||
|
||||
test('所有交互元素可通过键盘访问', async ({ page }) => {
|
||||
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"])'
|
||||
'a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
).length;
|
||||
});
|
||||
|
||||
console.log(`Found ${focusableCount} focusable elements`);
|
||||
expect(focusableCount).toBeGreaterThan(0);
|
||||
|
||||
// Click on the page first to ensure it has focus context
|
||||
await page.locator('body').click();
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Try keyboard navigation - press Tab to move focus
|
||||
await page.keyboard.press('Tab');
|
||||
await page.waitForTimeout(300);
|
||||
|
||||
// Check if any element received focus (may not work in all browsers)
|
||||
const hasFocus = await page.evaluate(() => document.activeElement !== document.body);
|
||||
// Skip strict assertion; keyboard tab behavior varies by browser/OS settings
|
||||
expect(typeof hasFocus).toBe('boolean');
|
||||
// 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 });
|
||||
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 }> = [];
|
||||
const inputs = document.querySelectorAll("input, textarea, select");
|
||||
const results: Array<{
|
||||
tag: string;
|
||||
id: string;
|
||||
hasLabel: boolean;
|
||||
ariaLabel: string | null;
|
||||
}> = [];
|
||||
|
||||
inputs.forEach(input => {
|
||||
inputs.forEach((input) => {
|
||||
const id = input.id;
|
||||
const ariaLabel = input.getAttribute('aria-label');
|
||||
const ariaLabel = input.getAttribute("aria-label");
|
||||
|
||||
// 检查是否有显式 label
|
||||
let hasLabel = false;
|
||||
@@ -253,7 +278,7 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () =>
|
||||
|
||||
results.push({
|
||||
tag: input.tagName,
|
||||
id: id || '',
|
||||
id: id || "",
|
||||
hasLabel,
|
||||
ariaLabel,
|
||||
});
|
||||
@@ -267,27 +292,32 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () =>
|
||||
const hasAccessibleName = input.hasLabel || input.ariaLabel;
|
||||
expect(
|
||||
hasAccessibleName,
|
||||
`Input ${input.tag}${input.id ? '#' + input.id : ''} 缺少可访问名称`
|
||||
`Input ${input.tag}${input.id ? "#" + input.id : ""} 缺少可访问名称`,
|
||||
).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
test('颜色对比度符合标准(简化版)', async ({ page }) => {
|
||||
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');
|
||||
const textElements = document.querySelectorAll(
|
||||
"p, h1, h2, h3, h4, h5, h6, span, a, li, td, th",
|
||||
);
|
||||
|
||||
textElements.forEach(el => {
|
||||
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)') {
|
||||
if (
|
||||
color === "rgb(255, 255, 255)" &&
|
||||
bgColor === "rgb(255, 255, 255)"
|
||||
) {
|
||||
issues.push(`${el.tagName}: 白色文字在白色背景上`);
|
||||
}
|
||||
});
|
||||
@@ -295,26 +325,49 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () =>
|
||||
return issues;
|
||||
});
|
||||
|
||||
expect(contrastIssues.length, `发现颜色对比度问题:\n${contrastIssues.join('\n')}`).toBe(0);
|
||||
expect(
|
||||
contrastIssues.length,
|
||||
`发现颜色对比度问题:\n${contrastIssues.join("\n")}`,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('ARIA 标签使用正确', async ({ page }) => {
|
||||
test("ARIA 标签使用正确", async ({ page }) => {
|
||||
// 检查 ARIA 属性的使用情况
|
||||
const ariaUsage = await page.evaluate(() => {
|
||||
const elementsWithAria = document.querySelectorAll('[role], [aria-label], [aria-live], [aria-expanded]');
|
||||
const elementsWithAria = document.querySelectorAll(
|
||||
"[role], [aria-label], [aria-live], [aria-expanded]",
|
||||
);
|
||||
const issues: string[] = [];
|
||||
|
||||
elementsWithAria.forEach(el => {
|
||||
const role = el.getAttribute('role');
|
||||
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'
|
||||
"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!)) {
|
||||
@@ -329,180 +382,200 @@ test.describe('可访问性 - WCAG 2.1 AA 合规', { tag: '@regression' }, () =>
|
||||
};
|
||||
});
|
||||
|
||||
console.log('ARIA usage:', ariaUsage);
|
||||
expect(ariaUsage.issues.length, `ARIA 使用问题:\n${ariaUsage.issues.join('\n')}`).toBe(0);
|
||||
console.log("ARIA usage:", ariaUsage);
|
||||
expect(
|
||||
ariaUsage.issues.length,
|
||||
`ARIA 使用问题:\n${ariaUsage.issues.join("\n")}`,
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('焦点管理清晰可见', async ({ page }) => {
|
||||
test("焦点管理清晰可见", async ({ page }) => {
|
||||
// Tab 到第一个链接并检查焦点样式
|
||||
await page.keyboard.press('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();
|
||||
if (await focusedElement.count() > 0) {
|
||||
// 检查焦点元素是否有明显的焦点样式(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')
|
||||
);
|
||||
});
|
||||
const focusedElement = page.locator(":focus").first();
|
||||
await expect(focusedElement).toBeAttached();
|
||||
|
||||
// 至少应该有一些方式指示焦点(某些浏览器默认行为)
|
||||
console.log('Focused element has visible focus indicator:', hasOutlineStyle);
|
||||
}
|
||||
// 检查焦点元素是否有明显的焦点样式(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 }) => {
|
||||
test("跳转链接功能正常", async ({ page }) => {
|
||||
// Find the skip-to-content link
|
||||
const skipLink = page.locator('[data-skip-to-content]').first();
|
||||
// N-27:去掉 `if (count() > 0)` 守卫。跳转链接由根布局无条件渲染(layout.tsx:201-207),
|
||||
// 从 DOM 移除即失败。
|
||||
const skipLink = page.locator("[data-skip-to-content]").first();
|
||||
await expect(skipLink).toBeVisible();
|
||||
|
||||
if (await skipLink.count() > 0) {
|
||||
// Focus the skip link first (it's off-screen until focused)
|
||||
await skipLink.focus();
|
||||
await page.waitForTimeout(200);
|
||||
// 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);
|
||||
// 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');
|
||||
// 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();
|
||||
}
|
||||
// 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 });
|
||||
test("错误消息对屏幕阅读器友好", async ({ page }) => {
|
||||
await page.goto("/contact", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
await page.waitForTimeout(2500);
|
||||
|
||||
// 尝试提交空表单以触发验证错误
|
||||
const submitButton = page.locator('[data-testid="submit-button"], button[type="submit"]').first();
|
||||
if (await submitButton.isVisible()) {
|
||||
await submitButton.click();
|
||||
await page.waitForTimeout(500);
|
||||
// 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();
|
||||
|
||||
// 检查错误消息是否使用适当的 ARIA 属性
|
||||
const errorMessages = page.locator('[role="alert"], [data-testid="error-message"], .error-message');
|
||||
const errorCount = await errorMessages.count();
|
||||
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);
|
||||
|
||||
if (errorCount > 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');
|
||||
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';
|
||||
// 错误消息应该有 alert role 或 aria-live 属性
|
||||
const isAccessible =
|
||||
role === "alert" ||
|
||||
role === "status" ||
|
||||
ariaLive === "assertive" ||
|
||||
ariaLive === "polite";
|
||||
|
||||
expect(isAccessible, `错误消息缺少必要的 ARIA 属性`).toBeTruthy();
|
||||
}
|
||||
}
|
||||
expect(isAccessible, `错误消息缺少必要的 ARIA 属性`).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ==================== 4. SEO 与元数据测试 ====================
|
||||
test.describe('SEO - 元数据完整性', { tag: '@regression' }, () => {
|
||||
|
||||
test('各主要页面有唯一标题', async ({ page }) => {
|
||||
test.describe("SEO - 元数据完整性", { tag: "@regression" }, () => {
|
||||
test("各主要页面有唯一标题", async ({ page }) => {
|
||||
const pages = [
|
||||
'/',
|
||||
'/about',
|
||||
'/contact',
|
||||
'/products',
|
||||
'/services',
|
||||
'/solutions',
|
||||
"/",
|
||||
"/about",
|
||||
"/contact",
|
||||
"/products",
|
||||
"/services",
|
||||
"/solutions",
|
||||
];
|
||||
|
||||
const titles: string[] = [];
|
||||
|
||||
for (const path of pages) {
|
||||
await page.goto(path, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
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`);
|
||||
console.log(
|
||||
`Found ${uniqueTitles.size} unique titles out of ${pages.length} pages`,
|
||||
);
|
||||
|
||||
// 至少应该有一半以上的页面有不同标题
|
||||
expect(uniqueTitles.size).toBeGreaterThanOrEqual(Math.ceil(pages.length / 2));
|
||||
expect(uniqueTitles.size).toBeGreaterThanOrEqual(
|
||||
Math.ceil(pages.length / 2),
|
||||
);
|
||||
});
|
||||
|
||||
test('Meta Description 存在且长度合适', async ({ page }) => {
|
||||
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
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"]');
|
||||
const count = await metaDescription.count();
|
||||
|
||||
if (count > 0) {
|
||||
const content = await metaDescription.first().getAttribute('content');
|
||||
expect(content).toBeTruthy();
|
||||
expect(content!.length).toBeGreaterThanOrEqual(50); // 不宜过短
|
||||
expect(content!.length).toBeLessThanOrEqual(160); // SEO 推荐长度
|
||||
}
|
||||
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 });
|
||||
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"]');
|
||||
|
||||
// 至少应该有 OG 标题
|
||||
const hasOgTitle = await ogTitle.count() > 0;
|
||||
if (hasOgTitle) {
|
||||
const content = await ogTitle.getAttribute('content');
|
||||
expect(content).toBeTruthy();
|
||||
}
|
||||
|
||||
console.log('OG tags present:', {
|
||||
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 });
|
||||
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"]');
|
||||
if (await canonical.count() > 0) {
|
||||
const href = await canonical.getAttribute('href');
|
||||
expect(href).toBeTruthy();
|
||||
expect(href).toMatch(/^https?:\/\/[^/]+\.cn/);
|
||||
}
|
||||
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 });
|
||||
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);
|
||||
|
||||
if (count > 0) {
|
||||
// 验证 JSON-LD 格式正确
|
||||
for (let i = 0; i < count; i++) {
|
||||
const content = await jsonLdScripts.nth(i).textContent();
|
||||
expect(content).toBeTruthy();
|
||||
// 验证 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}`);
|
||||
}
|
||||
try {
|
||||
JSON.parse(content!); // 应该是有效的JSON
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON-LD at index ${i}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -511,40 +584,64 @@ test.describe('SEO - 元数据完整性', { tag: '@regression' }, () => {
|
||||
});
|
||||
|
||||
// ==================== 5. 安全性基础检查 ====================
|
||||
test.describe('安全性 - 基础安全头', { tag: '@regression' }, () => {
|
||||
|
||||
test('安全响应头设置', async ({ page }) => {
|
||||
const response = await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
|
||||
if (response) {
|
||||
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'],
|
||||
'Strict-Transport-Security': headers['strict-transport-security'],
|
||||
'Content-Security-Policy': headers['content-security-policy'],
|
||||
};
|
||||
|
||||
console.log('Security headers:', securityHeaders);
|
||||
|
||||
// 至少应该有一些安全头
|
||||
const headerCount = Object.values(securityHeaders).filter(h => h).length;
|
||||
expect(headerCount).toBeGreaterThanOrEqual(1);
|
||||
}
|
||||
});
|
||||
|
||||
test('没有混合内容警告', async ({ page }) => {
|
||||
// 在 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);
|
||||
test.describe("安全性 - 基础安全头", { tag: "@regression" }, () => {
|
||||
test("安全响应头设置", async ({ page }) => {
|
||||
const response = await page.goto("/", {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
console.log('Mixed content resources:', mixedContent);
|
||||
// 理想情况下不应该有混合内容
|
||||
// 但由于测试环境可能使用 HTTP,这里只记录不强制断言
|
||||
// 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([]);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user