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:
+137
-113
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user