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

831 lines
33 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 } 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/);
});
});