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 { 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'); }); });