- 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 配置收敛。
831 lines
33 KiB
TypeScript
831 lines
33 KiB
TypeScript
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/);
|
||
});
|
||
});
|