- 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 配置收敛。
270 lines
12 KiB
TypeScript
270 lines
12 KiB
TypeScript
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');
|
||
});
|
||
});
|