Files
novalon-website/e2e/website-acceptance.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

270 lines
12 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, 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: 'domcontentloaded', timeout: 30000 });
});
test('首页加载正常', async ({ page }) => {
await expect(page).toHaveTitle(/四川睿新致远科技有限公司/);
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('[data-testid="footer"]')).toBeVisible();
});
test('公司Logo可见且不被覆盖', async ({ page }) => {
// 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 });
const logoBox = await pollBoundingBox(logo);
const headerBox = await pollBoundingBox(page.locator('header'));
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 }) => {
// 桌面走顶栏、移动走抽屉;原因见 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"]');
await expect(contactInfoSection).toBeVisible();
const phoneInContactInfo = contactInfoSection.locator(
'text=/电话|028-88888888|联系电话/',
);
expect(await phoneInContactInfo.count()).toBe(0);
});
test('联系我们页面表单正常显示', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'load' });
// 等待 React 客户端渲染完成
await page.waitForTimeout(2000);
await expect(page.locator('[data-testid="name-input"]')).toBeVisible();
await expect(page.locator('[data-testid="phone-input"]')).toBeVisible();
await expect(page.locator('[data-testid="email-input"]')).toBeVisible();
await expect(page.locator('[data-testid="subject-input"]')).toBeVisible();
await expect(page.locator('[data-testid="message-input"]')).toBeVisible();
await expect(page.locator('[data-testid="submit-button"]')).toBeVisible();
});
test('ICP备案号正确显示', async ({ page }) => {
const footer = page.locator('[data-testid="footer"], footer');
await expect(footer).toBeVisible();
const icpText = await footer.textContent();
expect(icpText).toContain('蜀ICP备2026013658号');
});
test('关于我们页面没有显示公司电话', async ({ page }) => {
await page.goto('/about', { waitUntil: 'load' });
await page.waitForTimeout(2000);
// 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 }) => {
await page.setViewportSize({ width: 375, height: 667 });
await expect(page.locator('header')).toBeVisible();
await expect(page.locator('[data-testid="footer"], footer')).toBeVisible();
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 }) => {
// 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 }) => {
const footer = page.locator('footer').first();
await expect(footer).toBeVisible({ timeout: 10000 });
// Check footer contains expected links and legal info
const footerText = await footer.textContent();
expect(footerText).toContain('ICP');
expect(footerText).toContain('隐私');
});
test('表单验证功能正常', async ({ page }) => {
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 更新)
const errorMessages = page.locator('[data-testid="error-message"]');
await expect(errorMessages.first()).toBeVisible({ timeout: 10000 });
const errorCount = await errorMessages.count();
expect(errorCount).toBeGreaterThan(0);
});
test('页面加载性能良好', async ({ page }) => {
// 确保访问本地服务器而非外部域名
await page.goto('/', { waitUntil: 'load', timeout: 30000 });
const performanceMetrics = await page.evaluate(() => {
const navigation = performance.getEntriesByType('navigation')[0] as PerformanceNavigationTiming;
return {
domContentLoaded: navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart,
loadComplete: navigation.loadEventEnd - navigation.loadEventStart,
};
});
expect(performanceMetrics.domContentLoaded).toBeLessThan(5000);
expect(performanceMetrics.loadComplete).toBeLessThan(15000);
});
test('无障碍访问正常', async ({ page }) => {
const htmlLang = await page.locator('html').getAttribute('lang');
expect(htmlLang).toBeTruthy();
// Skip link 通过 CSS left-[-9999px] 视觉隐藏,仅在 focus 时显示(标准无障碍模式)
// 使用 toBeAttached 验证 DOM 存在性而非视觉可见性
const skipLink = page.locator('[data-skip-to-content]');
await expect(skipLink).toBeAttached();
expect(await skipLink.textContent()).toContain('跳转到主要内容');
const main = page.locator('main').first();
await expect(main).toBeVisible({ timeout: 10000 });
// Check for navigation and footer existence (structure may vary)
const footer = page.locator('footer').first();
await expect(footer).toBeVisible({ timeout: 5000 });
const focusableElements = page.locator('a, button, input, select, textarea, [tabindex]:not([tabindex="-1"])');
const focusCount = await focusableElements.count();
expect(focusCount).toBeGreaterThan(0);
});
test('联系我们页面没有返回按钮覆盖logo', async ({ page }) => {
await page.goto('/contact', { waitUntil: 'load' });
await page.waitForTimeout(2000);
// 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');
});
});