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:
2026-09-28 10:48:08 +08:00
parent a366bd1400
commit 6bb7c557ee
142 changed files with 6597 additions and 2653 deletions
+137 -113
View File
@@ -1,8 +1,34 @@
import { test, expect } from '@playwright/test';
import { test, expect, type Locator } from './fixtures';
import { primaryNav } from './primary-nav';
import { expectHydrated } from './hydrated';
interface BoxLike {
x: number;
y: number;
width: number;
height: number;
}
/**
* N-27 修复辅助:轮询直到 boundingBox 非空并返回它。
* 旧写法 `const box = await expect.poll(() => el.boundingBox()).not.toBeNull()` 的
* await 结果是 undefined(断言不产数),随后的 `if (box)` 恒假 ⇒ 几何断言全部死代码。
* 本函数把「等到非空」与「拿到值」分开,且拿不到值直接 throw(元素消失 ⇒ 用例失败)。
*/
async function pollBoundingBox(target: Locator, timeout = 5000): Promise<BoxLike> {
await expect
.poll(() => target.boundingBox(), { timeout, message: '等待 boundingBox 就绪' })
.not.toBeNull();
const box = await target.boundingBox();
if (!box) {
throw new Error('boundingBox 在轮询通过后仍为 null(元素在断言瞬间消失)');
}
return box;
}
test.describe('网站全面测试验收', { tag: '@regression' }, () => {
test.beforeEach(async ({ page }) => {
await page.goto('/', { waitUntil: 'networkidle', timeout: 30000 });
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
});
test('首页加载正常', async ({ page }) => {
@@ -12,59 +38,55 @@ test.describe('网站全面测试验收', { tag: '@regression' }, () => {
});
test('公司Logo可见且不被覆盖', async ({ page }) => {
const logo = page.locator('header img[alt*="睿新致远"], [data-testid="logo"]');
const logoCount = await logo.count();
// N-27:去掉 `if (logoBox && headerBox)`(其值恒为 undefined,见 pollBoundingBox)
// 与 `if (logoCount === 0)` 双分支守卫。logo 锚定 header「返回首页」链接内
// 按当前主题可见的那张 img(src/components/layout/header.tsx:96-122 明暗双版,
// globals.css 按 html[data-theme] 显隐 ⇒ 恒有一张可见)。
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
await expect(logo).toBeVisible({ timeout: 10000 });
async function assertLogoBoundingBox(logoLocator: ReturnType<typeof page.locator>) {
await expect(logoLocator).toBeVisible({ timeout: 10000 });
// 等待 layout 完成,避免 WebKit 中 boundingBox() 首次返回 null
const logoBox = await expect.poll(async () => logoLocator.boundingBox(), {
timeout: 5000,
intervals: [100, 200, 200],
}).not.toBeNull();
const logoBox = await pollBoundingBox(logo);
const headerBox = await pollBoundingBox(page.locator('header'));
const header = page.locator('header');
const headerBox = await expect.poll(async () => header.boundingBox(), {
timeout: 5000,
intervals: [100, 200, 200],
}).not.toBeNull();
if (logoBox && headerBox) {
expect(logoBox.x).toBeGreaterThanOrEqual(headerBox.x);
expect(logoBox.y).toBeGreaterThanOrEqual(headerBox.y);
expect(logoBox.x + logoBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width);
expect(logoBox.y + logoBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height);
}
}
if (logoCount === 0) {
const allImages = page.locator('header img');
expect(await allImages.count()).toBeGreaterThan(0);
await assertLogoBoundingBox(allImages.first());
} else {
await assertLogoBoundingBox(logo.first());
}
expect(logoBox.x).toBeGreaterThanOrEqual(headerBox.x);
expect(logoBox.y).toBeGreaterThanOrEqual(headerBox.y);
expect(logoBox.x + logoBox.width).toBeLessThanOrEqual(headerBox.x + headerBox.width);
expect(logoBox.y + logoBox.height).toBeLessThanOrEqual(headerBox.y + headerBox.height);
});
test('导航菜单功能正常', async ({ page }) => {
const navLinks = page.locator('[data-testid="desktop-navigation"] a, nav a');
const count = await navLinks.count();
expect(count).toBeGreaterThan(0);
await navLinks.nth(0).click();
await page.waitForURL(/\/.+/, { timeout: 10000 });
// 桌面走顶栏、移动走抽屉;原因见 primary-nav.ts。
// N-27:原实现零断言,仅靠 `waitForURL(/\/.+/)` 隐式放行(任何路径变化都算过)。
// 改为断言「点击的链接确实落到它自己声明的 href」(同 p2 已修用例的口径)。
const nav = await primaryNav(page);
const link = nav.locator('a[href^="/"]').first();
await expect(link).toBeVisible();
const href = await link.getAttribute('href');
if (!href) {
throw new Error('首个导航链接缺少 href,无法判定跳转目标');
}
await Promise.all([
page.waitForURL((url) => url.pathname === href, {
waitUntil: 'commit',
timeout: 15000,
}),
link.click(),
]);
await expect(page).toHaveURL((url) => url.pathname === href);
});
test('联系我们页面没有显示公司电话', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'load' });
await page.waitForTimeout(2000);
// N-27:去掉 `if (isVisible)` 守卫。联系信息区块由 contact-content-v3.tsx:316 的
// data-testid="contact-info" 无条件渲染(N-27 复核已确认存在);区块消失 ⇒ 本例失败。
const contactInfoSection = page.locator('[data-testid="contact-info"]');
if (await contactInfoSection.isVisible()) {
const phoneInContactInfo = contactInfoSection.locator('text=/电话|028-88888888|联系电话/');
const phoneCount = await phoneInContactInfo.count();
expect(phoneCount).toBe(0);
}
await expect(contactInfoSection).toBeVisible();
const phoneInContactInfo = contactInfoSection.locator(
'text=/电话|028-88888888|联系电话/',
);
expect(await phoneInContactInfo.count()).toBe(0);
});
test('联系我们页面表单正常显示', async ({ page }) => {
@@ -90,22 +112,15 @@ test.describe('网站全面测试验收', { tag: '@regression' }, () => {
test('关于我们页面没有显示公司电话', async ({ page }) => {
await page.goto('/about', { waitUntil: 'load' });
await page.waitForTimeout(2000);
const contactTexts = page.locator('text=/联系我们|联系方式|Contact/');
const contactCount = await contactTexts.count();
if (contactCount > 0) {
for (let i = 0; i < contactCount; i++) {
const contactElement = contactTexts.nth(i);
const parent = contactElement.locator('..');
if (await parent.isVisible()) {
const phoneText = parent.locator('text=/联系电话|028-88888888|电话:/');
const phoneCount = await phoneText.count();
expect(phoneCount).toBe(0);
break;
}
}
}
// N-27:原实现是「contactTexts 存在 → 遍历 → 第一个 visible 父级断言无电话 → break」,
// 三重守卫下 /about 没有「联系我们」文案时整例静默通过。改为对正文整体做无条件负断言:
// 页面上任何位置出现电话样式 ⇒ 失败;正文本身消失同样失败。
const mainContent = page.locator('main').first();
await expect(mainContent).toBeVisible();
const mainText = (await mainContent.textContent()) ?? '';
expect(mainText.length).toBeGreaterThan(100);
expect(mainText).not.toMatch(/028-\d{8}|1[3-9]\d{9}|联系电话|电话:/);
});
test('响应式设计正常工作', async ({ page }) => {
@@ -115,23 +130,46 @@ test.describe('网站全面测试验收', { tag: '@regression' }, () => {
const mobileMenuButton = page.locator('[data-testid="mobile-menu-button"]');
await expect(mobileMenuButton).toBeVisible({ timeout: 5000 });
await expectHydrated(mobileMenuButton);
await mobileMenuButton.click();
await expect(page.locator('[data-testid="mobile-navigation"]')).toBeVisible({ timeout: 5000 });
});
test('页面跳转功能正常', async ({ page }) => {
const contactLink = page.locator('text=联系我们').first();
if (await contactLink.isVisible()) {
await contactLink.click();
await page.waitForURL(/\/contact/, { timeout: 10000 });
}
const homeLink = page.locator('text=首页').first();
if (await homeLink.isVisible()) {
await homeLink.click();
await page.waitForURL(/\//, { timeout: 10000 });
}
// N-27:原实现两条点击都包在 `if (isVisible)` 里——移动 project 顶栏(hidden md:flex)
// 不存在 ⇒ 两个守卫恒假,整例静默通过;且 `waitForURL(/\//)` 恒真。
// 固定桌面视口后本例验证的就是桌面顶栏的两段跳转,守卫改为硬断言。
// 顶栏没有 a[href="/"](NAVIGATION_V2 无首页项,src/lib/constants/navigation.ts:45-53),
// 回首页的真实入口是 logo 链接(header.tsx:96 的 aria-label="返回首页")。
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/', { waitUntil: 'domcontentloaded', timeout: 30000 });
const nav = page.getByTestId('desktop-navigation');
await expect(nav).toBeVisible();
await expectHydrated(nav);
const contactLink = nav.locator('a[href="/contact"]').first();
await expect(contactLink).toBeVisible();
await Promise.all([
page.waitForURL((url) => url.pathname === '/contact', {
waitUntil: 'commit',
timeout: 15000,
}),
contactLink.click(),
]);
await expect(page).toHaveURL((url) => url.pathname === '/contact');
const homeLink = page.locator('header a[aria-label="返回首页"]');
await expect(homeLink).toBeVisible();
await Promise.all([
page.waitForURL((url) => url.pathname === '/', {
waitUntil: 'commit',
timeout: 15000,
}),
homeLink.click(),
]);
await expect(page).toHaveURL((url) => url.pathname === '/');
});
test('Footer链接正常工作', async ({ page }) => {
@@ -145,11 +183,12 @@ test.describe('网站全面测试验收', { tag: '@regression' }, () => {
});
test('表单验证功能正常', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'networkidle' });
await page.goto('/contact', { waitUntil: 'domcontentloaded' });
// 联系表单是 'use client' 组件,需等待 hydration 完成后再交互
const submitButton = page.locator('[data-testid="submit-button"]');
await expect(submitButton).toBeVisible({ timeout: 15000 });
await expectHydrated(submitButton);
await submitButton.click();
// 等待 Zod 验证错误渲染(客户端校验触发后 DOM 更新)
@@ -200,46 +239,31 @@ test.describe('网站全面测试验收', { tag: '@regression' }, () => {
test('联系我们页面没有返回按钮覆盖logo', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'load' });
await page.waitForTimeout(2000);
const logoSelectors = [
'header img[alt*="睿新致远"]',
'header img[alt*="novalon"]',
'[data-testid="logo"]',
'header img'
];
let logo = null;
for (const selector of logoSelectors) {
const element = page.locator(selector).first();
if (await element.isVisible().catch(() => false)) {
logo = element;
break;
}
}
if (!logo) {
const allLogos = page.locator('header img');
expect(await allLogos.count()).toBeGreaterThan(0);
logo = allLogos.first();
}
await expect(logo).toBeVisible();
const logoBox = await logo.boundingBox();
expect(logoBox).not.toBeNull();
const header = page.locator('header');
const headerBox = await header.boundingBox();
expect(headerBox).not.toBeNull();
if (logoBox && headerBox) {
const logoCenterX = logoBox.x + logoBox.width / 2;
const logoCenterY = logoBox.y + logoBox.height / 2;
expect(logoCenterX).toBeGreaterThan(headerBox.x);
expect(logoCenterX).toBeLessThan(headerBox.x + headerBox.width);
expect(logoCenterY).toBeGreaterThan(headerBox.y);
expect(logoCenterY).toBeLessThan(headerBox.y + headerBox.height);
}
// N-27:去掉四元 selector 探测 + `.catch(() => false)` 吞异常 + `if (logoBox && headerBox)`
// 死守卫。logo 恒为「返回首页」链接内按主题可见的那张 img(header.tsx:96-122)。
const logo = page.locator('header a[aria-label="返回首页"] img:visible').first();
await expect(logo).toBeVisible({ timeout: 10000 });
const logoBox = await pollBoundingBox(logo);
const headerBox = await pollBoundingBox(page.locator('header'));
const logoCenterX = logoBox.x + logoBox.width / 2;
const logoCenterY = logoBox.y + logoBox.height / 2;
expect(logoCenterX).toBeGreaterThan(headerBox.x);
expect(logoCenterX).toBeLessThan(headerBox.x + headerBox.width);
expect(logoCenterY).toBeGreaterThan(headerBox.y);
expect(logoCenterY).toBeLessThan(headerBox.y + headerBox.height);
// 意图的正面表达:logo 中心点上最顶端的元素必须仍属于 logo 链接本身,
// 若「返回按钮」等浮层再次盖住 logo,elementFromPoint 会返回浮层 ⇒ 用例失败。
const topElementClass = await page.evaluate(({ x, y }) => {
const el = document.elementFromPoint(x, y);
if (!el) return 'none';
const host = el.closest('a[aria-label="返回首页"]');
return host ? 'logo-link' : el.tagName.toLowerCase();
}, { x: logoCenterX, y: logoCenterY });
expect(topElementClass).toBe('logo-link');
});
});