import { test, expect } from "./fixtures"; import { primaryNav } from "./primary-nav"; /** * P2: 全页面功能E2E测试套件 * * 测试范围:覆盖网站所有主要功能和用户流程 * * 模块划分: * 1. 首页功能测试(Hero区、产品展示、解决方案、CTA等) * 2. 导航与路由测试 * 3. 产品中心测试 * 4. 解决方案测试 * 5. 服务介绍测试 * 6. 关于我们测试 * 7. 联系我们表单测试 * 8. 新闻动态测试 * 9. 团队介绍测试 * 10. 法律页面测试(隐私政策、服务条款) * 11. 响应式布局测试 * 12. 无障碍访问测试 */ // 设置超时时间 test.setTimeout(60000); // ==================== 1. 首页功能测试 ==================== test.describe("首页 - 核心功能区", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(2000); }); test("Hero区域正确显示", async ({ page }) => { // 检查主标题(Framer Motion 入场动画初始 opacity:0,需等待动画完成) const heroTitle = page.locator("h1").first(); await expect(heroTitle).toBeVisible({ timeout: 15000 }); const titleText = await heroTitle.textContent(); expect(titleText).toBeTruthy(); expect(titleText!.length).toBeGreaterThan(0); // 检查副标题或描述文本 const heroDescription = page.locator("main p").first(); await expect(heroDescription).toBeVisible(); }); test("产品矩阵入口链接显示正常", async ({ page }) => { // N-27:原守卫 `text=产品矩阵` 在首页永远不成立 ⇒ 整例静默零断言: // ProductMatrixSection(src/components/sections/product-matrix-section.tsx)从未被挂载, // 且 seed 把 hero 副 CTA 覆写为「了解我们的方法论」(prisma/seed.ts:607)。 // 首页真实的产品矩阵入口 = 叙事区第三幕「浏览产品矩阵 → /products」(prisma/seed.ts narrativeActs[2], // 渲染于 home-content-v15.tsx:386-427 的 [data-testid="narrative-section"])。桌面/移动 project 均渲染。 const productsEntry = page .locator('[data-testid="narrative-section"] a[href="/products"]') .first(); await expect(productsEntry).toBeVisible(); await expect(productsEntry).toContainText("浏览产品矩阵"); }); test("章节式叙事区块(问题→方法→结果)显示", async ({ page }) => { // N-27:原实现守卫挑战区块(ChallengeSection 未挂载于首页,永假 ⇒ 静默通过)。 // 意图改由真实装配的叙事区承接:三幕标题来自 seed 的 page-copy.home.narrativeActs。 await expect(page.locator('[data-testid="narrative-section"]')).toBeVisible(); for (const act of ["诊断现状", "设计路径", "交付成果"]) { await expect(page.getByText(act, { exact: true }).first()).toBeVisible(); } }); test("信任标识区块显示", async ({ page }) => { // N-27:去掉 `if (isVisible)` 守卫。eyebrow 文案由 seed 固化为「值得信赖」, // 区块容器有 data-testid(home-content-v15.tsx:313),标签取自 TRUST_SIGNALS(company.ts:88-113)。 const trustSection = page.locator('[data-testid="trust-section"]'); await expect(trustSection).toBeVisible(); await expect(trustSection.getByText("值得信赖").first()).toBeVisible(); const trustItems = trustSection.locator( "text=/私有化部署|本地化服务|陪伴式交付|首批客户共创/", ); expect(await trustItems.count()).toBeGreaterThan(0); }); test("CTA按钮可见且可点击", async ({ page }) => { // N-27:原 `text=开始合作` 在 src/ 里根本不存在 ⇒ isVisible() 恒 false ⇒ 整例零断言。 // 换成首页真实装配的 CTA(home-content-v15.tsx:700 的 dataTestId),并去掉条件守卫。 const ctaButton = page .locator('[data-testid="cases-co-creation-cta"]') .first(); await expect(ctaButton).toBeVisible(); await expect(ctaButton).toBeEnabled(); }); }); // ==================== 2. 导航与路由测试 ==================== test.describe("导航系统 - 路由与菜单", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(2000); }); test("主导航菜单包含所有主要链接", async ({ page }) => { // 同一 spec 跑桌面与移动两个 project:桌面导航是 `hidden md:flex`,移动入口是抽屉, // 抽屉仅在 isOpen 时挂载且要等 hydration —— 统一交给 primaryNav 处理。 const nav = await primaryNav(page); // 检查关键导航项 const expectedLinks = ["产品", "解决方案", "服务", "关于我们", "联系我们"]; for (const linkText of expectedLinks) { // 某些项在下拉菜单里(收起状态不可见),所以只断言「存在于导航容器内」, // 用带重试的 toHaveCount 取代裸 count(),避免移动抽屉异步挂载时读到 0。 await expect(nav.locator(`text=${linkText}`)).not.toHaveCount(0, { timeout: 5000, }); } }); test("Logo点击返回首页", async ({ page }) => { // N-27:去掉 `if (logo.count() > 0)` 守卫,并把「点 logo 后 URL 含 '/'」这种恒真断言 // 升级为「从 /products 点 logo 后 pathname 回到 /」。 // logo 链接是 header 的 StaticLink(src/components/layout/header.tsx:96-122,aria-label="返回首页",href="/"); // 明暗双 img 之一被 CSS 隐藏,所以锚定链接本身而非 img。 await page.goto("/products", { waitUntil: "domcontentloaded", timeout: 30000, }); const logoLink = page.locator('header a[aria-label="返回首页"]'); await expect(logoLink).toBeVisible(); await logoLink.click(); await expect .poll(() => new URL(page.url()).pathname, { timeout: 15000 }) .toBe("/"); }); test("导航链接可点击且跳转正确", async ({ page }) => { // N-27:原实现全程零断言,且 `waitForURL(...).catch(() => {})` 会把「点了没跳转」吞成绿灯。 // 补法不能写死链接文案:桌面顶栏有「联系我们」,移动抽屉没有 ⇒ 硬编码文案在 chromium-mobile 必红。 // 因此从「当前视口下真正可用的导航容器」里取链接本身,断言点击后落到该链接声明的目标路径。 for (let idx = 0; idx < 2; idx++) { const nav = await primaryNav(page); const link = nav.locator('a[href^="/"]').nth(idx); await expect(link).toBeVisible(); const href = await link.getAttribute("href"); if (!href) { throw new Error(`第 ${idx + 1} 个导航链接缺少 href,无法判定跳转目标`); } // 注意:Playwright 的 URL 匹配跑的是**完整** URL,`^/services` 这种锚定永远不成立。 const landsOnTarget = (url: URL) => url.pathname === href || url.pathname.startsWith(`${href}/`); await Promise.all([ // waitUntil 默认 'load':整页资源加载完才算数,在 4 project × 6 worker 并发下 10s 不够 // (firefox 隔离复跑 14.4s 通过、全量并发下超时)。URL 判定只需等导航 commit,这才是降本体成本。 page.waitForURL(landsOnTarget, { waitUntil: "commit", timeout: 10000 }), link.click(), ]); await expect(page).toHaveURL(landsOnTarget); await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); } }); }); // ==================== 3. 产品中心测试 ==================== test.describe("产品中心 - 页面功能", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/products", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(2000); }); test("产品列表页面加载成功", async ({ page }) => { await expect(page).toHaveURL(/\/products/); // 检查页面标题包含"产品" const pageTitle = page.locator("h1, h2").first(); await expect(pageTitle).toBeVisible(); const titleText = await pageTitle.textContent(); expect(titleText).toContain("产品"); }); test("产品卡片可点击跳转到详情", async ({ page }) => { // N-27:去掉 `if (linkCount > 0)` 守卫——选择器失效时用例必须变红。 // 锚定 /products 正文的产品卡(BentoItem data-testid,products-content-v3.tsx:105), // `a[href^="/products/"]` 同时排除 externalUrl 外链卡(href 为 https,seed 的 standalone-product)。 const firstProduct = page .locator('[data-testid^="bento-product-card-"] a[href^="/products/"]') .first(); await expect(firstProduct).toBeVisible(); const href = await firstProduct.getAttribute("href"); if (!href) { throw new Error("首张产品卡缺少 href,无法判定跳转目标"); } await Promise.all([ page.waitForURL((url) => url.pathname === href, { waitUntil: "commit", timeout: 15000, }), firstProduct.click(), ]); await expect(page).toHaveURL((url) => url.pathname === href); }); test("ERP升级专题页面可访问", async ({ page }) => { await page.goto("/products/erp-upgrade", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(1500); // 页面应正常加载 const content = page.locator("main").first(); await expect(content).toBeVisible(); }); }); // ==================== 4. 解决方案测试 ==================== test.describe("解决方案 - 行业方案展示", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/solutions", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(2000); }); test("解决方案列表页面加载成功", async ({ page }) => { await expect(page).toHaveURL(/\/solutions/); const pageTitle = page.locator("h1, h2").first(); await expect(pageTitle).toBeVisible(); }); test("行业分类标签可见", async ({ page }) => { // N-27:去掉 `if (count() > 0)` 守卫,并把标签列表改为从真实数据派生。 // 原列表里的「零售业」根本不存在(seed 值是「贸易零售」,prisma/seeds/solutions.ts:47), // 迭代被守卫吞掉后整例只数恒真。六个行业标签渲染在方案卡的 industry span(solutions-content-v3.tsx:119)。 const industries = [ "制造业", "贸易零售", "教育培训", "医疗健康", "金融服务", "物流运输", ]; for (const industry of industries) { const industryTag = page .locator("[data-testid^='solution-card-']") .filter({ hasText: industry }) .first(); await expect(industryTag).toBeVisible(); } }); test("解决方案详情可查看", async ({ page }) => { // N-27:去掉 count/isVisible 双守卫;点击目标锚定到正文方案卡(solution-card-*, // href 形如 /solutions/${id},solutions-content-v3.tsx:103-112),不再依赖隐藏的下拉链接。 const solutionLink = page .locator('[data-testid^="solution-card-"] a[href^="/solutions/"]') .first(); await expect(solutionLink).toBeVisible(); const href = await solutionLink.getAttribute("href"); if (!href) { throw new Error("首个方案卡缺少 href,无法判定跳转目标"); } await Promise.all([ page.waitForURL((url) => url.pathname === href, { waitUntil: "commit", timeout: 15000, }), solutionLink.click(), ]); await expect(page).toHaveURL((url) => url.pathname === href); }); }); // ==================== 5. 服务介绍测试 ==================== test.describe("服务介绍 - 服务内容展示", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/services", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(2000); }); test("服务页面加载成功", async ({ page }) => { await expect(page).toHaveURL(/\/services/); const pageTitle = page.locator("h1, h2").first(); await expect(pageTitle).toBeVisible(); }); test("服务项目列表显示", async ({ page }) => { // N-27:去掉 `if (count() > 0)` 守卫。服务卡有稳定 testid(services-content-v3.tsx:197 // 的 service-card-${id},seed 发布 4 条 service);空态分支是 services-empty-*(同文件 163-166), // 一旦数据被清空本例会红,而不是静默少测。 const serviceCards = page.locator('[data-testid^="service-card-"]'); await expect(serviceCards.first()).toBeVisible(); expect(await serviceCards.count()).toBeGreaterThan(0); }); test("服务详情可访问", async ({ page }) => { // 限定在正文区:页头 mega dropdown 里的 /services/* 链接依赖 hover 意图, // 高负载下下拉在 click 前收起,点击会落到底下的 /services 触发器上(chain3 实测)。 const serviceLink = page.locator('main a[href*="/services/"]').first(); // N-27:去掉 count/isVisible 双守卫与只打日志的分母——断言本身即分母(见 docs/lessons-learned.md §5.23)。 await expect(serviceLink).toBeVisible(); const expectedHref = await serviceLink.getAttribute("href"); if (!expectedHref) { throw new Error("正文首条 /services/* 链接缺少 href,无法判定跳转目标"); } await Promise.all([ page.waitForURL((url) => url.pathname === expectedHref, { waitUntil: "commit", timeout: 15000, }), serviceLink.click(), ]); await expect(page).toHaveURL((url) => url.pathname === expectedHref); }); }); // ==================== 6. 关于我们测试 ==================== test.describe("关于我们 - 公司信息展示", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/about", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(2000); }); test("关于页面加载成功", async ({ page }) => { await expect(page).toHaveURL(/\/about/); // Check that the page renders meaningful content (CMS-driven title varies) const mainContent = page.locator("main").first(); await expect(mainContent).toBeVisible(); const contentText = await mainContent.textContent(); expect(contentText!.length).toBeGreaterThan(100); }); test("公司信息展示完整", async ({ page }) => { // 检查公司描述或使命愿景等内容 const mainContent = page.locator("main").first(); await expect(mainContent).toBeVisible(); const contentText = await mainContent.textContent(); expect(contentText!.length).toBeGreaterThan(100); // 应有足够的内容 }); test("不显示敏感联系信息(电话号码)", async ({ page }) => { // Check main content only (exclude footer with ICP/police numbers) const mainContent = page.locator("main").first(); const mainText = await mainContent.textContent(); // Only check for explicit phone patterns like 028-XXXXXXXX const phonePattern = /028-\d{8}/; expect(mainText).not.toMatch(phonePattern); }); }); // ==================== 7. 联系我们表单测试 ==================== test.describe("联系我们 - 表单交互", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/contact", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(2500); // 表单可能需要更长的渲染时间 }); test("联系表单所有字段可见", async ({ page }) => { // 检查表单字段 const formFields = [ '[data-testid="name-input"]', '[data-testid="phone-input"]', '[data-testid="email-input"]', '[data-testid="subject-input"]', '[data-testid="message-input"]', '[data-testid="submit-button"]', ]; for (const selector of formFields) { const field = page.locator(selector).first(); await expect(field).toBeVisible({ timeout: 5000 }); } }); test("表单必填验证工作正常", async ({ page }) => { const submitButton = page.locator('[data-testid="submit-button"]').first(); await expect(submitButton).toBeVisible({ timeout: 5000 }); // 直接提交空表单 await submitButton.click(); // 等待验证错误出现 await page.waitForTimeout(500); // 检查是否出现错误提示 const errorMessages = page.locator( '[data-testid="error-message"], .error-message, [role="alert"]', ); const errorCount = await errorMessages.count(); expect(errorCount).toBeGreaterThan(0); }); test("表单输入功能正常", async ({ page }) => { const testData = { name: "测试用户", phone: "13800138000", email: "test@example.com", subject: "测试咨询", message: "这是一条测试消息,用于验证表单功能。", }; // 填写姓名 const nameInput = page.locator('[data-testid="name-input"]').first(); await nameInput.fill(testData.name); expect(await nameInput.inputValue()).toBe(testData.name); // 填写邮箱 const emailInput = page.locator('[data-testid="email-input"]').first(); await emailInput.fill(testData.email); expect(await emailInput.inputValue()).toBe(testData.email); // 填写消息 const messageInput = page.locator('[data-testid="message-input"]').first(); await messageInput.fill(testData.message); expect(await messageInput.inputValue()).toBe(testData.message); }); test("邮箱格式验证", async ({ page }) => { const emailInput = page.locator('[data-testid="email-input"]').first(); const submitButton = page.locator('[data-testid="submit-button"]').first(); // 输入无效邮箱 await emailInput.fill("invalid-email"); // 填写其他必填字段以通过基本验证 const nameInput = page.locator('[data-testid="name-input"]').first(); await nameInput.fill("测试用户"); await submitButton.click(); // N-27:去掉 `if (errorExists > 0)` 守卫。校验文案是 schema 常量 // 「请输入有效的邮箱地址」(contact-content-v3.tsx:37 的 z.string().email(...)), // 错误渲染带 data-testid="error-message" + role="alert"(labeled-input.tsx:56-65)。 // 用邮箱专属文案定位,避免 first() 落到手机号错误上。 const emailError = page .locator('[data-testid="error-message"]', { hasText: "请输入有效的邮箱地址" }) .first(); await expect(emailError).toBeVisible(); }); }); // ==================== 8. 新闻动态测试 ==================== test.describe("新闻动态 - 内容展示", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/news", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(2000); }); test("新闻列表页面加载成功", async ({ page }) => { await expect(page).toHaveURL(/\/news/); const pageTitle = page.locator("h1, h2").first(); await expect(pageTitle).toBeVisible(); }); test("新闻文章可点击查看详情", async ({ page }) => { // N-27:去掉 `if (linkCount > 0)` 守卫。新闻列表项锚定到正文卡链接 // (news-content-v3.tsx:30 的 href=`/news/${newsItem.id}`,seed 发布 2 条 news)。 const firstNews = page.locator('main a[href^="/news/"]').first(); await expect(firstNews).toBeVisible(); const href = await firstNews.getAttribute("href"); if (!href) { throw new Error("首条新闻链接缺少 href,无法判定跳转目标"); } await Promise.all([ page.waitForURL((url) => url.pathname === href, { waitUntil: "commit", timeout: 15000, }), firstNews.click(), ]); await expect(page).toHaveURL((url) => url.pathname === href); }); }); // ==================== 9. 团队介绍测试 ==================== test.describe("团队介绍 - 成员展示", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/team", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(2000); }); test("团队页面加载成功", async ({ page }) => { await expect(page).toHaveURL(/\/team/); const content = page.locator("main").first(); await expect(content).toBeVisible(); }); test("团队信息展示", async ({ page }) => { // N-27:去掉 `if (count() > 0)` 守卫,并改用 /team 的真实装配。 // 页面没有成员卡片 DOM(原选择器 [class*="member"] 命中的是兜底 article/任意 class), // 实际内容由 seed 的 team-page 渲染:L1 hero 标题「复合型团队」+ L2「团队实力」5 条 strengths // (team-content-v3.tsx:240-254,prisma/seed.ts team-page.strengths)。 const strengthsHeading = page.getByText("团队实力", { exact: true }).first(); await expect(strengthsHeading).toBeVisible(); for (const title of ["行业场景聚焦", "专业工程背景", "全栈技术能力"]) { await expect(page.getByText(title, { exact: true }).first()).toBeVisible(); } }); }); // ==================== 10. 法律页面测试 ==================== test.describe("法律页面 - 隐私政策与服务条款", { tag: "@regression" }, () => { test("隐私政策页面内容完整", async ({ page }) => { await page.goto("/privacy", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(1500); await expect(page).toHaveURL(/\/privacy/); // 检查页面标题 const pageTitle = page.locator("h1").first(); await expect(pageTitle).toBeVisible(); const titleText = await pageTitle.textContent(); expect(titleText).toContain("隐私"); // 检查正文内容足够长(法律文档应该有较多内容) const bodyText = await page.locator("body").textContent(); expect(bodyText!.length).toBeGreaterThan(500); }); test("服务条款页面内容完整", async ({ page }) => { await page.goto("/terms", { waitUntil: "domcontentloaded", timeout: 30000, }); await page.waitForTimeout(1500); await expect(page).toHaveURL(/\/terms/); const pageTitle = page.locator("h1").first(); await expect(pageTitle).toBeVisible(); const titleText = await pageTitle.textContent(); // N-27:`expect(a) || expect(b)` 不是「或断言」——toContain 失败会抛错,右侧永远不执行。 expect(titleText).toMatch(/条款|服务/); const bodyText = await page.locator("body").textContent(); expect(bodyText!.length).toBeGreaterThan(500); }); }); // ==================== 11. 响应式布局测试 ==================== test.describe("响应式设计 - 多设备适配", { tag: "@regression" }, () => { test("桌面端布局正常", async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 }); await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); // Header 和 Footer 都应可见 await expect(page.locator("header")).toBeVisible(); await expect(page.locator('footer, [data-testid="footer"]')).toBeVisible(); // 主导航应水平显示(用例已显式设 1280 视口,desktop-navigation 在 md+ 恒可见, // N-27:去掉 `if (count() > 0)` 守卫) const desktopNav = page.getByTestId("desktop-navigation"); await expect(desktopNav).toBeVisible(); }); test("平板端布局正常", async ({ page }) => { await page.setViewportSize({ width: 768, height: 1024 }); await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); await expect(page.locator("header")).toBeVisible(); await expect(page.locator('footer, [data-testid="footer"]')).toBeVisible(); }); test("移动端布局正常", async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); // Header 和 Footer 可见 await expect(page.locator("header")).toBeVisible(); await expect(page.locator('footer, [data-testid="footer"]')).toBeVisible(); // 移动端菜单按钮应可见(用例显式 375 视口,md:hidden ⇒ 四 project 一致,N-27 去守卫) await expect(page.getByTestId("mobile-menu-button")).toBeVisible({ timeout: 5000, }); }); test("移动端菜单展开/收起功能", async ({ page }) => { await page.setViewportSize({ width: 375, height: 667 }); await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); const mobileMenuBtn = page.getByTestId("mobile-menu-button"); await expect(mobileMenuBtn).toBeVisible(); // N-27:去掉 `if (isVisible)` 守卫。展开交给已在四 project 实测的 primaryNav // (内部用 toPass 处理「hydration 前首击被吞」,见 e2e/primary-nav.ts 注释), // 再显式收起并断言抽屉消失。按钮/抽屉任一消失 ⇒ 用例失败,而不是静默跳过。 const mobileNav = await primaryNav(page); await expect(mobileNav).toBeVisible(); await expect(mobileNav.locator("a").first()).toBeVisible(); await mobileMenuBtn.click(); await expect(mobileNav).not.toBeVisible(); }); }); // ==================== 12. 无障碍访问测试 ==================== test.describe("无障碍性 - A11y 基础检查", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); }); test("HTML lang 属性设置正确", async ({ page }) => { const htmlLang = await page.locator("html").getAttribute("lang"); expect(htmlLang).toBe("zh-CN"); }); test("页面标题非空", async ({ page }) => { const title = await page.title(); expect(title).toBeTruthy(); expect(title.length).toBeGreaterThan(0); }); test("跳转链接存在", async ({ page }) => { // N-27:去掉 `if (count() > 0)` 守卫。跳转链接由根布局无条件渲染 // (src/app/layout.tsx:201-207,data-skip-to-content + href="#main-content", // 未聚焦时以 left:-9999px 移出屏外,但仍有非空 bounding box ⇒ Playwright 判 visible)。 const skipLink = page.locator("[data-skip-to-content]").first(); await expect(skipLink).toBeVisible(); await expect(skipLink).toHaveAttribute("href", "#main-content"); }); test("图片都有 alt 属性", async ({ page }) => { const images = page.locator("img"); const count = await images.count(); let imagesWithoutAlt = 0; for (let i = 0; i < count; i++) { const alt = await images.nth(i).getAttribute("alt"); if (!alt) imagesWithoutAlt++; } // 允许少量装饰性图片没有 alt(使用 alt="" 或 role="presentation") expect(imagesWithoutAlt).toBeLessThanOrEqual(2); }); test("Heading 层级结构合理", async ({ page }) => { // 检查页面有 h1 标签 const h1 = page.locator("h1").first(); await expect(h1).toBeVisible(); // 不应出现从 h1 直接跳到 h3+ 的情况(简化检查) const headings = await page.evaluate(() => { const levels = Array.from( document.querySelectorAll("h1, h2, h3, h4, h5, h6"), ).map((el) => parseInt(el.tagName.substring(1))); return levels; }); // N-27:去掉 `if (headings.length > 1)` 守卫——h1 断言之后页面必然还有 // 区块标题(首页 TrustSection/NarrativeSection 等各有 h2,home-content-v15.tsx:322/389), // 只剩单一标题就该失败,而不是跳过层级检查。 expect(headings.length).toBeGreaterThan(1); // 基本检查:不应该有太多级别跳跃 let maxJump = 0; for (let i = 1; i < headings.length; i++) { const jump = headings[i] - headings[i - 1]; if (jump > maxJump) maxJump = jump; } // 允许最多跳跃2级(如 h1 -> h3) expect(maxJump).toBeLessThanOrEqual(2); }); test("颜色对比度基础检查(确保文字可读)", async ({ page }) => { // 检查主要文本元素不是白色背景上的浅色文字 const body = page.locator("body").first(); const bgColor = await body.evaluate((el) => { return window.getComputedStyle(el).backgroundColor; }); // 背景应该是浅色或深色(不能是透明导致无法判断) expect(bgColor).not.toBe("rgba(0, 0, 0, 0)"); }); }); // ==================== 13. Footer 功能测试 ==================== test.describe("Footer - 全局底部区域", { tag: "@regression" }, () => { test.beforeEach(async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); // 滚动到底部 await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); await page.waitForTimeout(500); }); test("Footer 包含公司名称", async ({ page }) => { const footer = page.locator('footer, [data-testid="footer"]').first(); await expect(footer).toBeVisible(); const footerText = await footer.textContent(); expect(footerText).toContain("睿新致远"); }); test("Footer 包含ICP备案号", async ({ page }) => { const footer = page.locator('footer, [data-testid="footer"]').first(); const footerText = await footer.textContent(); expect(footerText).toContain("蜀ICP备"); }); test("Footer 导航链接可点击", async ({ page }) => { // 隐私政策链接:独立测试,避免 Firefox 中连续全页导航的竞态问题 await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); const privacyClicked = await page.evaluate(() => { const link = document.querySelector( 'footer a, [data-testid="footer"] a', ) as HTMLAnchorElement | null; if (!link) return false; const privacy = Array.from( document.querySelectorAll('footer a, [data-testid="footer"] a'), ).find((el) => el.textContent?.includes("隐私政策")) as HTMLAnchorElement | undefined; if (privacy) { privacy.scrollIntoView({ behavior: "instant", block: "center" }); privacy.click(); return true; } return false; }); expect(privacyClicked).toBe(true); await page.waitForURL(/\/privacy/, { timeout: 15000 }); expect(page.url()).toContain("/privacy"); }); test("Footer 服务条款链接可点击", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); await page.waitForTimeout(1500); const termsClicked = await page.evaluate(() => { const terms = Array.from( document.querySelectorAll('footer a, [data-testid="footer"] a'), ).find((el) => el.textContent?.includes("服务条款")) as HTMLAnchorElement | undefined; if (terms) { terms.scrollIntoView({ behavior: "instant", block: "center" }); terms.click(); return true; } return false; }); expect(termsClicked).toBe(true); await page.waitForURL(/\/terms/, { timeout: 15000 }); expect(page.url()).toContain("/terms"); }); test("Footer 包含联系方式(邮箱)", async ({ page }) => { const footer = page.locator('footer, [data-testid="footer"]').first(); const footerText = await footer.textContent(); // 应包含 @ 符号的邮箱地址 expect(footerText).toMatch(/@.*\.cn/); }); }); // ==================== 14. SEO 元数据测试 ==================== test.describe("SEO - 搜索引擎优化基础", { tag: "@regression" }, () => { test("首页 Meta Description 存在", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); const metaDescription = page.locator('meta[name="description"]').first(); const content = await metaDescription.getAttribute("content"); expect(content).toBeTruthy(); expect(content!.length).toBeGreaterThan(50); }); test("各页面有唯一标题", async ({ context }) => { const urls = [ "/", "/about", "/contact", "/products", "/services", "/solutions", ]; const titles: string[] = []; for (const url of urls) { const newPage = await context.newPage(); await newPage.goto(url, { waitUntil: "domcontentloaded", timeout: 30000, }); const title = await newPage.title(); titles.push(title); await newPage.close(); } // 所有标题应该唯一(或者至少不完全相同) const uniqueTitles = new Set(titles); expect(uniqueTitles.size).toBeGreaterThanOrEqual(urls.length / 2); }); test("Canonical URL 设置", async ({ page }) => { await page.goto("/", { waitUntil: "domcontentloaded", timeout: 30000 }); // N-27:去掉 `if (count() > 0)` 守卫。canonical 由根布局 metadata 无条件输出 // (src/app/layout.tsx:87-89,alternates.canonical = https://www.novalon.cn)。 const canonical = page.locator('link[rel="canonical"]').first(); await expect(canonical).toHaveAttribute("href", /https:\/\/(www\.)?novalon\.cn/); }); });