Files
novalon-website/docs/testing.md
T
zhangxiang a0328a623f chore(qa): 验收台账与证据入库 + 构建/部署配置同步
- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。
- 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。
- 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径;
  next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐
  standalone 产物装配与部署形态。
2026-09-28 10:48:09 +08:00

36 KiB
Raw Blame History

测试文档

⚠ 结构时效警示(2026-08-31 全站一致性审计):本文档描述的 e2e/src/ 分层结构(config/data/fixtures/pages/tests 子目录、Page Object 模式)与当前代码不符——现行 e2e/ 为扁平 spec 结构(如 cases-filter.spec.ts、user-journey.spec.ts 等直接位于 e2e/ 根,配合 playwright.config.ts)。Page Object 类、fixtures 等示例代码在代码库中不存在,npm scripts 以实际 package.json 为准。本文档保留作 Playwright 使用参考,具体结构以代码为准。

测试概述

项目使用 Playwright 进行端到端(E2E)测试,测试框架位于 e2e/ 目录(扁平 spec 结构)。

测试框架结构

e2e/
├── src/
│   ├── config/              # 配置文件
│   │   ├── environments.ts  # 环境配置
│   │   └── network-configs.ts # 网络配置
│   ├── data/                # 测试数据
│   │   └── test-data.ts
│   ├── fixtures/            # 测试 Fixtures
│   │   ├── base.fixture.ts  # 基础 Fixture
│   │   └── a11y.fixture.ts  # 可访问性 Fixture
│   ├── pages/               # Page Object Model
│   │   ├── BasePage.ts      # 基础页面
│   │   ├── HomePage.ts      # 首页
│   │   ├── AboutPage.ts     # 关于页
│   │   ├── CasesPage.ts     # 案例页
│   │   ├── ContactPage.ts   # 联系页
│   │   ├── NewsPage.ts      # 新闻页
│   │   ├── ProductsPage.ts  # 产品页
│   │   ├── ServicesPage.ts  # 服务页
│   │   └── SolutionsPage.ts # 解决方案页
│   └── tests/               # 测试用例
│       ├── accessibility/   # 可访问性测试
│       ├── debug/           # 调试测试
│       ├── deployment/      # 部署就绪测试
│       ├── error-handling/  # 错误处理测试
│       ├── mobile/          # 移动端测试
│       ├── performance/     # 性能测试
│       ├── regression/      # 回归测试
│       ├── responsive/      # 响应式测试
│       ├── security/        # 安全测试
│       ├── smoke/           # 冒烟测试
│       ├── utils/           # 工具测试
│       └── visual/          # 视觉回归测试
├── playwright.config.ts     # Playwright 配置
├── package.json
└── .env.example

测试类型

1. 冒烟测试 (Smoke Tests)

目录: e2e/src/tests/smoke/

目的: 验证核心功能是否正常工作

测试文件:

  • all-pages.spec.ts - 所有页面加载测试
  • home-page.smoke.spec.ts - 首页冒烟测试
  • contact-page.smoke.spec.ts - 联系页冒烟测试
  • navigation.smoke.spec.ts - 导航冒烟测试

运行命令:

npm run test:smoke
# 或
npx playwright test --grep @smoke

2. 回归测试 (Regression Tests)

目录: e2e/src/tests/regression/

目的: 确保新代码没有破坏现有功能

测试文件:

  • contact-form.regression.spec.ts - 联系表单回归测试
  • home-page.regression.spec.ts - 首页回归测试
  • navigation.spec.ts - 导航回归测试

运行命令(package.json 无 test:regression 脚本,真实入口是 test:e2e:standard):

npm run test:e2e:standard              # = cd e2e && npx playwright test --grep @regression

3. 性能测试 (Performance Tests)

目录: e2e/src/tests/performance/

目的: 验证页面性能指标

测试文件:

  • core-web-vitals.spec.ts - Core Web Vitals 测试
  • performance.spec.ts - 通用性能测试
  • image-loading.spec.ts - 图片加载性能
  • interaction-performance.spec.ts - 交互性能测试

监控指标:

  • LCP (Largest Contentful Paint) < 2.5s
  • FID (First Input Delay) < 100ms
  • CLS (Cumulative Layout Shift) < 0.1
  • TTFB (Time to First Byte) < 600ms

运行命令(两条入口不是一回事,勿混用):

npm run test:performance        # = k6 run tests/performance/load-test.js(压测脚本,不是 Playwright 用例;文件确在磁盘)
npx playwright test --grep @performance   # 浏览器内性能用例(e2e/mobile-performance.spec.ts)

test:performance / test:stress / test:performance:api / test:performance:soak 四条都依赖本机 k6 CLI——package.json 里的 k6@0.0.0 只是 Grafana/LoadImpact 官方的「Dummy package for autocompleting k6 scripts」编辑器类型包,不含可执行文件(验收 N-10)。

4. 响应式测试 (Responsive Tests)

目录: e2e/src/tests/responsive/

目的: 验证多设备适配

测试文件:

  • responsive.spec.ts - 响应式布局测试
  • mobile-interaction.spec.ts - 移动端交互测试

测试设备:

  • Desktop: 1920x1080, 1366x768
  • Tablet: iPad Pro (1024x1366), iPad Air (820x1180)
  • Mobile: iPhone 12 (390x844), iPhone SE (375x667), Pixel 5 (393x851)

运行命令(⚠ @responsive 这个标签在当前 e2e/*.spec.ts 里零命中,--grep @responsive 会跑到 0 个用例;响应式覆盖由 project 维度承担,真实存在的入口只有这两条):

npm run test:visual:tablet             # visual-chromium-tablet(平板断点全页)
npm run test:visual:mobile             # visual-chromium-mobile(窄视口全页)
# 功能侧的窄视口执行量在 chromium-mobile project(iPhone 14, 390×844):
cd e2e && npx playwright test --project=chromium-mobile

5. 可访问性测试 (Accessibility Tests)

目录: e2e/src/tests/accessibility/

目的: 验证 WCAG 合规性

测试文件:

  • accessibility.spec.ts - 可访问性测试
  • wcag-compliance.spec.ts - WCAG 合规测试

检查项:

  • 颜色对比度 ≥ 4.5:1 (AA 级别);≥24px 大字档(含 aria-hidden 的装饰序号)按 1.4.3 的 3:1 判定——axe-core 的 color-contrast 不因 aria-hidden / role="none" / CSS opacity 豁免,装饰文字须自带合规对比度(用 text-text-hint 令牌)
  • 键盘导航支持
  • 屏幕阅读器兼容
  • ARIA 属性正确性
  • 焦点顺序合理
  • 触摸目标:硬门禁为 WCAG 2.2 AA SC 2.5.8 ≥ 24×24px(e2e/touch-targets.ts 的 aaViolations,须为 0);SC 2.5.5 ≥ 44×44px 属 AAA,仅作为建议清单打印不失败。历史上曾把 44px 误标为 AA,导致门禁长期红色。

运行命令(package.json 中真实存在的脚本;本节其余示例里的 test:accessibility / test:responsive 并不存在,勿照抄):

npm run check:a11y             # 静态令牌门禁伞:check:contrast + check:headings + check:brand-token(已并入 test:all)
npm run check:contrast         # 只读 globals.css 令牌表做配对计算(不启服务)
npm run check:headings         # 自起服务扫描渲染后的标题层级:存在 dist/standalone/server.js 时**直起 standalone**,
                               # 产物缺失才回退 `npm run preview` 并打印告警(验收 N-24③,见 CLAUDE.md「Build & Preview」)
npm run check:brand-token      # 品牌红文字通道令牌化检查(静态扫描源码)
npx playwright test --grep @accessibility   # 浏览器内 axe / 触摸目标用例

6. 安全测试 (Security Tests)

目录: e2e/src/tests/security/

目的: 验证安全防护措施

测试文件:

  • security.spec.ts - 通用安全测试
  • xss-protection.spec.ts - XSS 防护测试
  • csrf-protection.spec.ts - CSRF 防护测试

检查项:

  • XSS 攻击防护
  • CSRF Token 验证
  • 安全头部配置
  • 表单验证

运行命令(两条入口不是一回事,勿混用):

npm run test:security           # = npm audit --audit-level=high && npm run test:security:headers(依赖审计 + 线上响应头脚本,不跑 Playwright 用例)
npx playwright test --grep @security        # 浏览器侧的安全头用例(e2e/security-headers.spec.ts)

7. 视觉回归测试 (Visual Tests)

测试文件: e2e/visual-regression.spec.ts(L1 全页 15 页 × 5 project + L2 组件状态 + L3 主题 = --list 实测 25 例/project × 5 project = 125 例)

⚠ 三个口径别混:VISUAL_TEST_PAGES 的页面数 15、每 project 的用例数 25、磁盘上的参照 PNG 数 107(5 个 project 目录各 21 张 + 目录外 manual-acceptance/ 2 张)。用例数 − 参照数 ≠ 0 的那部分是覆盖空洞而非「多余用例」——/products/erp-upgrade 与 /about/brand 在两页于 5 个 project 下均无参照,另有卡片/输入框两例死在选择器上([class*="card"] 零匹配、input[type="text"] 的 .first() 命中按设计隐藏的反垃圾蜜罐)。定性见 docs/acceptance/2026-09-23-gates/final-verdict.md N-15 与 A-9。@visual 标签不存在(e2e/*.spec.ts 内零命中),视觉层只按 project / spec 文件选取。

Project: visual-chromium-desktop / -tablet / -mobile / visual-firefox-desktop / visual-webkit-desktop,全部 reducedMotion: 'reduce'

快照目录: e2e/visual-snapshots/{projectName}/{testFilePath}/{arg}-{projectName}{ext}(模板见 e2e/playwright.config.ts)

运行命令:

npm run test:visual          # 仅 chromium 桌面
npm run test:visual:all      # 5 个 project 全量
npm run test:visual:browsers # 三引擎桌面

更新快照:

npm run test:visual:all -- --update-snapshots

确定性约束(2026-09-21 验收 83 例失败的根因,详见 docs/lessons-learned.md §5.14 / §5.15):

  1. 快照与 E2E_TARGET 无关——e2e/fixtures.ts 移除 next dev 注入的 <nextjs-portal>(左下角 devtools 指示器),否则 dev 采集的基线在 production 复核时每个全页都多出同一簇。
  2. 视觉阶段不得与写库用例并发——npm run test 已拆为 test:functional → test:visual:all 两阶段串行。CMS 用例「建条目 → revalidate → 删条目」若与截图并发,生产 server 的预渲染缓存会保留含测试条目的那一版(x-nextjs-cache: HIT),数据行删干净了页面仍是脏的。
  3. dev 指示器由测试夹具自己剥掉,不是靠配置关掉——e2e/fixtures.ts 的 context 夹具注入 init script,用 document.querySelectorAll('nextjs-portal').forEach(n => n.remove()) 移除 next dev 注入的 <nextjs-portal>(挂 MutationObserver 于 document,因为 document start 时 documentElement 还是 null)。它内部 shadow DOM 的 <footer class="error-overlay-footer"> 会被 Playwright 的 CSS 引擎穿透 open shadow root,使 locator('footer') 严格模式解析到 2 个元素(2026-09-21 功能 E2E 68 例失败中的 56 例)。 以下写法是错的,勿再引入:曾有文档写「e2e/playwright.config.ts 的 webServer.env 注入 NEXT_DEV_INDICATORS=off,next.config.mjs 据此设 devIndicators: false」——2026-09-23 全仓 grep 证实:代码里不存在该机制。NEXT_DEV_INDICATORS 仅出现在文档(docs/lessons-learned.md §5.16 的历史记录)中,devIndicators 在代码里的唯一出现是 e2e/fixtures.ts:8 的一句注释(自述「实测 devIndicators:false 也拿不掉」);next.config.mjs 与 e2e/playwright.config.ts 两个字符串都没有(后者 webServer.env 只注入 CMS_REVALIDATE_SECRET,:142-144)。该方案当年被实测回滚:设了变量后 nextjs-portal 数量仍为 1,因为官方文档写明 false 只隐藏指示器本身,而 dev 下 CSP 禁 eval 的报错把 overlay 常驻挂住(详见 §5.16)。
  4. 交互客户端组件前先 expectHydrated()(e2e/hydrated.ts)——SSR HTML 一到 DOM 就 visible,但 onClick / onMouseEnter 要等 hydration(实测 /contact 提交按钮 first visible 后 1735ms);窗口内的指针事件被丢弃且不重放,表现为动作成功、状态永不变、轮询到 timeout。
  5. Cookie 同意条只在「未存偏好」时渲染,而 storageState.json 预置了 novalon-cookie-preferences——需要覆盖同意条的用例必须先 addInitScript 清 localStorage,再用 [data-testid="cookie-consent-banner"] 定位(a[href="/privacy"] 会先命中 footer 的同名链接)。
  6. 视觉基线口径固定为「已表达 Cookie 偏好」——同意条在挂载 2s 后才出现,而 visual-firefox-desktop / visual-webkit-desktop 用空 storage state(见 5a4d310),慢引擎会把它画进首屏、快引擎赶不上。visual-regression.spec.ts 顶部用 addInitScript 预置偏好(时间戳为常量)消除该竞态,详见 §5.19。
  7. 重采基线前先确认 ContentItem 无残留测试数据:sqlite3 "file:prisma/dev.db?mode=ro" "select modelCode,count(*) from ContentItem group by 1;"。

8. 移动端测试 (Mobile Tests)

目录: e2e/src/tests/mobile/

目的: 验证移动端功能

测试文件:

  • compatibility/mobile-compatibility.spec.ts - 兼容性测试
  • gesture/mobile-gesture.spec.ts - 手势测试
  • network/network-environment.spec.ts - 网络环境测试
  • performance/mobile-performance.spec.ts - 移动性能测试
  • pwa/pwa-functionality.spec.ts - PWA 功能测试
  • mobile-ux.spec.ts - 移动端 UX 测试

视口约定:mobile-*.spec.ts 顶部必须显式 test.use({ ...devices['iPhone 14'] })。只有 chromium-mobile project 在配置里设了移动视口,而这两个 spec 同时被 chromium / firefox / webkit 桌面 project 复用——不在 spec 内钉住视口,同一份断言会有一半执行量到桌面布局(断点类名、抽屉是否挂载、触摸目标尺寸全都不同),却被当成移动端口径。

E2E 目标(默认是 dev,不是产物):e2e/playwright.config.ts:132-135 按 E2E_TARGET 切换 webServer——production 走 HOSTNAME=localhost PORT=3000 node dist/standalone/server.js(服务 output: 'standalone' 产物,next.config.mjs:44),否则走 npm run dev。不是 npm run start:Next 16 在 standalone 输出下明确告警 next start 不可用,走它即测的是不受支持的降级路径(配置内注释 :128-131)。npm run test / test:functional / test:e2e 都不设该变量,即默认打 dev server;dev 下 Next 的安全头、压缩、预取与 ISR 行为与线上不同。要走产物口径用 npm run test:e2e:prod(脚本本身已带 E2E_TARGET=production,--grep '@smoke|@critical|@journey' × 4 project),且须先自行 npm run build——webServer 只负责起服务,不代跑构建,且 standalone 目录不自含 dist/static 与 public,须按 Jenkinsfile:353-355 的装配方式先拷进去(缺了它们 /_next/static/** 全 404)。reuseExistingServer 在 production 目标下恒为 false(:139),:3000 被占用会直接中止本轮,避免静默复用陈旧服务。

⚠ dev 目标下的 skipped 口径(不可忽略):ga4-event-tracking.spec.ts 的 4 个 @critical 用例(TC-GA4-001..004)在默认 dev 轮次里每个 project 都 skip,4 project 共 16 个 skipped(不是 passed)。链路:NEXT_PUBLIC_GA_MEASUREMENT_ID 只写在 .env.production(.env / .env.local 里没有)→ dev 下 src/components/analytics/GoogleAnalytics.tsx:81,119 提前 return null → 无 gtag/js 请求 → 用例内 test.skip(measurementId === null, '构建未注入 GA 脚本…') 触发。证据:docs/acceptance/2026-09-21-gates/skipped-tests-final-tree.json(最终树 npm run test 全量 1195 结果 = 1167 passed / 28 skipped,其中该组 count: 16;另两组为 cms-workflow「仅 chromium 执行」9、p3「Touchscreen API 仅 Chromium」3)。产物口径的 4 passed 见同目录 ga4-production-run.txt。 结论:报告 GA4 覆盖时必须引用 test:e2e:prod 的结果,把 npm run test 的「全绿」当作 GA4 已验证即为口径错误。CI 侧的对应关系:Jenkinsfile「🌐 E2E 测试」阶段(仅 branch 'main')先 npm run build 再 npm run test:e2e:prod,即产物口径在 main 上被真实执行;本地开发轮的 npm run test 不覆盖它。

iPhone SE 口径复核(验收 §8-⑥):375×667 / deviceScaleFactor: 2 / isMobile + hasTouch / colorScheme 强制深·浅 × 同意条 pending·dismissed × 首页 / 新闻详情 / 新闻列表 / 联系页 = 16 组,产物在 docs/acceptance/2026-09-21-iphone-se/(32 图 + measurements.json + 探针脚本 iphone-se-audit.mjs)。口径与阈值:scrollWidth == clientWidth(无横向溢出)、footerBottomGap(备案行到 <footer> 底边的留白,R-1 修复前 192–226,修复后恒为 64)、bannerOverlapsFooter(首次访客的 fixed 浮层覆盖页脚属设计行为)。复跑:OUT=/tmp/se node --input-type=module -e "$(cat docs/acceptance/2026-09-21-iphone-se/iphone-se-audit.mjs)"(需在仓库目录内执行,脚本要 import 'playwright';且需一个跑在 3000 端口的 dev server)。滚到底必须逐帧轮询:首页入场分级会持续增高文档,单次 scrollTo(0, scrollHeight) 会停在半页并测出无效 footer 位置。该口径是浏览器仿真,不替代真机走查。

axe 全站节点计数门禁(验收 §8-⑤,2026-09-23 起为常驻门禁):既有 e2e/mobile-accessibility.spec.ts 不满足该条口径——它只断言 critical/serious 的规则条数 = 0(:67),覆盖 9 个路由、单一主题,因此「同一规则下 20 个节点不达标」与「0 个节点不达标」在它眼里等价。为此单独建 scripts/accessibility/axe-node-count.mjs(由 docs/acceptance/2026-09-21-axe/axe-contrast-evidence.mjs 平移而来,证据 JSON 仍留在原目录):在 chromium + firefox 两个引擎 × light + dark 两个主题下逐页跑 axe-core 4.11.4(wcag2a/wcag2aa/wcag21a/wcag21aa),按节点数记账并落 axe-evidence.json。闸口同时成立才算过:violationNodes / color-contrast 节点数 = 0(四个组合任一 > 0 即判红)、themeMismatch = 0(回读 document.documentElement[data-theme],防「以为在量深色其实还是浅色」)、bgMismatch = 0(body 底色须等于令牌值 light rgb(255,255,255) / dark rgb(10,14,20),防样式未加载导致的「对比度意外达标」),外加规则级第二次 runOnly 的分母断言:autocomplete-valid / presentation-role-conflict / svg-img-alt 三条必须在每一页 × 每一个组合都被判定过(Σ = 路由 × 4 × 3,且 extraRulePagesUnderCovered = 0)——脚本里另有一份写死的下限清单,删掉运行列表中的一条规则会当场判红而不是悄悄少测。

分母口径(2026-09-22 升级,勿再退回单取 sitemap):清单来源是 /sitemap.xml ∪ 预渲染产物(dist/standalone/dist/server/app/**/*.html)∪ 站内链接 BFS(scripts/accessibility/crawl-routes.mjs,原 docs/acceptance/2026-09-21-axe/crawl-sitemap.mjs),最终树实测 34 条路由(axe-evidence.json:routeCount=34、rows=136 = 34 × 4 组合、passed=true)。sitemap.xml 只覆盖「希望被收录的页」,单独取用它即为口径错误——扩目录首轮当场挖出 /_not-found 与 /_global-error 两个真实缺口(见 docs/lessons-learned.md §5.24、CONTEXT.md 决策行)。历史对照:axe-evidence-sitemap29.json(29 页版,当时的 0 是真 0,但分母不足)。

退出码即门禁判定(不再只是打印 PASSED):0 通过 / 1 判红(violation 节点 > 0、主题或底色不匹配、规则级通道分母不闭合、整组响应全非 2xx 的「你在扫 404」保险丝、扫描中途异常)/ 2 输入不可用(路由清单缺失或解析出 0 条;爬虫侧同样在清单为空或 BASE 完全不可达时退出 2 且不覆盖上一份清单)。

复跑(两条脚本可零参数直跑,env 有默认值):

npm run build                        # 产出 dist/standalone,并由 postbuild 自动装配静态资源(见下)
PORT=3100 HOSTNAME=127.0.0.1 node dist/standalone/server.js &   # 不用 npm run start:Next 对 output:'standalone' 下的 next start 明确告警不支持
npm run check:axe:routes             # → /tmp/axe-routes.xml(BASE 默认 http://localhost:3100)
npm run check:axe                    # 读同一份清单,证据写 docs/acceptance/2026-09-21-axe/axe-evidence.json

装配这一步现在由 package.json 的 postbuild 代做,等价于手工执行:

mkdir -p dist/standalone/dist/static dist/standalone/public
cp -R dist/static/. dist/standalone/dist/static/ && cp -R public/. dist/standalone/public/   # 与 Dockerfile.prod 同一套装配

静态资源必须在起服务之前并入 standalone 根目录:server.js 启动时缓存静态索引,缺 dist/static 会让 /_next/static/** 全 404,页面落在默认黑白底上——对比度会「意外达标」,正是 bgMismatch 判据要拦的情形。脚本要 import 'playwright' 与读取 node_modules/axe-core/axe.min.js,两者已按仓库根解析(不再依赖 cwd)。注入 axe 依赖站点 CSP 含 script-src 'unsafe-inline'(next.config.mjs:29-31);若收紧该指令,此扫描与既有 axe 用例会同时失效。

✅ CI 接线(2026-09-23 落地):Jenkinsfile 新增「♿♿ 全站 axe 节点计数」阶段(仅 branch 'main',与 E2E / Lighthouse 同档,因为 34 路由 × 4 组合 ≈ 14 分钟),复用前一阶段构建好的 dist/standalone,按上面的顺序装配静态资源 → 起 node dist/standalone/server.js(专用端口 AXE_PORT,默认 3100)→ 依次 npm run check:axe:routes 与 npm run check:axe,set -e 下任一非 0 退出即判红、无 || echo;服务进程按记录到的 PID 收服(含 trap … EXIT),只 kill 本阶段自己启动的进程——历史事故是只 kill 包装进程留下 next-server 孤儿占住 :3000,被 reuseExistingServer 静默复用而毒化整轮验证(docs/acceptance/2026-09-21-gates/final-tree-results.md「已知口径限制」1)。证据 axe-evidence.json 在 post { always } 归档,判红时同样留档。

📊 最近一次全链路实测(2026-09-23,装配后的 standalone 产物):axe PASSED=true(chromium/firefox × light/dark 各 34 页,bgMismatch / contrastNodes / violationNodes 全为 0;唯一 non2xxResponses=1 是 /_not-found 的正确 404),Lighthouse 27 次运行断言全通过、a11y 9/9 URL 均 100。这一格在修 N-9(standalone 缺 dist/static ⇒ 此前两条门禁实际是在 CSS 全 404 的裸 HTML 上出数)之前是不可信的。完整判定、未闭环项与待授权清单见 docs/acceptance/2026-09-23-gates/final-verdict.md。

9. 部署就绪测试 (Deployment Tests)

目录: e2e/src/tests/deployment/

目的: 验证部署前检查

测试文件:

  • deployment-readiness.spec.ts - 部署就绪检查
  • quick-check.spec.ts - 快速检查

门禁命令对照(2026-09-23 与 package.json 逐条核对)

只列 package.json 中真实存在的脚本;本节之外的示例命令(如 test:accessibility、test:responsive、test:regression、test:ui、test:debug、test:headed、test:allure*、test:ci)在当前脚本表中不存在,属历史参考;test:performance 与 test:security 虽然存在,但含义与下方各节的字面期待不同(见 §3、§6 的运行命令块)。

脚本 实际命令 目标 / 口径 CI 阶段
npm run test test:functional + test:visual:all 串行 webServer = dev(npm run dev);GA4 的 16 个 @critical 实例 skipped —
npm run test:functional / test:e2e 4 功能 project 同上(dev 目标) —
npm run test:e2e:prod E2E_TARGET=production + --grep '@smoke|@critical|@journey' × 4 project 产物目标(node dist/standalone/server.js,非 next start);须先 npm run build 并装配 dist/static + public 🌐 E2E 测试(仅 main)
npm run test:critical / test:smoke / test:e2e:fast / test:e2e:standard / test:e2e:journey 按标签 grep 未设 E2E_TARGET ⇒ 仍是 dev 目标 —
npm run test:visual / :all / :mobile / :tablet / :browsers 视觉 project 子集 快照与目标无关(见 §7 约束 1) 👁️ 视觉回归(仅 main,跑 test:visual)
npm run check:a11y check:contrast → check:headings → check:brand-token 令牌/标题/双通道红三条静态门禁;已并入 test:all ♿ 可访问性门禁(逐条调用,全分支)
npm run check:axe / check:axe:routes node scripts/accessibility/{axe-node-count,crawl-routes}.mjs 全站路由清单(三源并集)+ 双引擎双主题 axe 节点计数与三条规则级通道的分母断言;退出码 0/1/2(2 = 清单缺失或 0 条)。需先有 node dist/standalone/server.js 在 :3100;未被 test:all 引用 ♿♿ 全站 axe 节点计数(仅 main,复用「🏗️ 构建 dist」的产物)
npm run test:all type-check + lint + test:coverage + test:integration:real + check:a11y + test:e2e:fast + test:security:headers(package.json:48 逐字) 一键本地门禁(其 E2E 段仍是 dev 目标);CI 不按此脚本跑,见上表「CI 集成」 —
npm run test:unit / test:coverage / test:coverage:check jest(阈值单一真源 config/test/jest.config.js,根 jest.config.js 仅转发) 单元 + 覆盖率棘轮 🧪 单元测试
npm run lighthouse lhci autorun --config=config/test/lighthouserc.json 性能 + axe 断言 ⚡ Lighthouse(仅 main,先 build)
npm run test:security / test:security:headers npm audit --audit-level=high + 头部检查 依赖与响应头 🔒 安全扫描

Page Object Model

基础页面类

// e2e/src/pages/BasePage.ts
export class BasePage {
  readonly page: Page;

  constructor(page: Page) {
    this.page = page;
  }

  async navigate(path: string) {
    await this.page.goto(path);
  }

  async waitForPageLoad() {
    await this.page.waitForLoadState('networkidle');
  }

  async takeScreenshot(name: string) {
    await this.page.screenshot({ path: `screenshots/${name}.png` });
  }
}

首页 Page Object

// e2e/src/pages/HomePage.ts
import { BasePage } from './BasePage';

export class HomePage extends BasePage {
  readonly heroSection: Locator;
  readonly servicesSection: Locator;
  readonly productsSection: Locator;

  constructor(page: Page) {
    super(page);
    this.heroSection = page.locator('[data-testid="hero-section"]');
    this.servicesSection = page.locator('#services');
    this.productsSection = page.locator('#products');
  }

  async goto() {
    await this.navigate('/');
    await this.waitForPageLoad();
  }

  async scrollToSection(sectionId: string) {
    await this.page.locator(`#${sectionId}`).scrollIntoViewIfNeeded();
  }
}

使用示例

// e2e/src/tests/smoke/home-page.smoke.spec.ts
import { test, expect } from '@playwright/test';
import { HomePage } from '../../pages/HomePage';

test.describe('首页冒烟测试', () => {
  let homePage: HomePage;

  test.beforeEach(async ({ page }) => {
    homePage = new HomePage(page);
    await homePage.goto();
  });

  test('首页加载成功', async () => {
    await expect(homePage.heroSection).toBeVisible();
  });
});

测试配置

Playwright 配置

// e2e/playwright.config.ts
export default defineConfig({
  testDir: './src/tests',
  fullyParallel: true,
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 2 : 0,
  workers: process.env.CI ? 4 : '50%',
  reporter: [
    ['html'],
    ['json', { outputFile: 'test-results/results.json' }],
    ['junit', { outputFile: 'test-results/junit.xml' }],
    ['allure-playwright'],
  ],
  timeout: 90000,
  use: {
    baseURL: 'http://localhost:3000',
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
    headless: true,
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
    { name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
  ],
  webServer: {
    command: 'cd .. && npm run dev',
    url: 'http://localhost:3000',
    reuseExistingServer: !process.env.CI,
  },
});

环境配置

// e2e/src/config/environments.ts
export interface Environment {
  name: string;
  baseURL: string;
  retries: number;
  trace: 'on' | 'off' | 'on-first-retry';
  screenshot: 'on' | 'off' | 'only-on-failure';
  video: 'on' | 'off' | 'on-first-retry';
  headless: boolean;
  slowMo: number;
}

export const environments: Record<string, Environment> = {
  development: {
    name: 'development',
    baseURL: 'http://localhost:3000',
    retries: 0,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
    headless: true,
    slowMo: 0,
  },
  production: {
    name: 'production',
    baseURL: 'https://www.novalon.cn',
    retries: 2,
    trace: 'on-first-retry',
    screenshot: 'only-on-failure',
    video: 'on-first-retry',
    headless: true,
    slowMo: 0,
  },
};

export function getEnvironment(): Environment {
  const env = process.env.TEST_ENV || 'development';
  return environments[env];
}

运行测试

本地运行

# 依赖装在仓库根:e2e/ 下**没有** package.json(`cd e2e && npm install` 会报 ENOENT / 装出空目录),
# Playwright 与全部 reporter 都是根 package.json 的 devDependencies
npm ci                              # 或 npm install
npx playwright install              # 安装 chromium / firefox / webkit 浏览器二进制

# 运行所有测试(功能 4 project → 视觉 5 project 串行;webServer 默认是 dev server)
npm run test

# 以下 playwright 命令须在 e2e/ 目录内执行(配置文件与 spec 同目录,根目录直接跑找不到 config)
cd e2e
npx playwright test path/to/test.spec.ts
npx playwright test --grep @smoke

# UI / 调试 / 有头模式:package.json 里没有 test:ui / test:debug / test:headed,
# 这三条此前是「读起来像真的」的假命令,跑必报 Missing script;正确写法是 Playwright 原生 flag
npx playwright test --ui
npx playwright test --debug
npx playwright test --headed

CI 环境运行

# CI 模式(禁止 only、增加重试)
CI=true npx playwright test

测试报告

HTML 报告

npx playwright show-report

Allure 报告

# Allure 报告:注意 package.json 未定义任何 test:allure* 脚本,
# 且 allure-commandline **不在 dependencies/devDependencies 里**(node_modules/allure-commandline 不存在),
# 因此旧版文档给的三条 `test:allure*` 脚本全部不可执行(package.json 里查无此名)。当前仓库真实产出的是
# e2e/allure-results/*.json(由 Playwright reporter 写盘)。要看报告需先自行安装 CLI:
npx -y allure-commandline generate e2e/allure-results -o e2e/allure-report --clean
npx -y allure-commandline open e2e/allure-report

JUnit 报告

用于 CI 集成,输出到 test-results/junit.xml。

测试最佳实践

1. 使用数据测试 ID

// 组件中
<div data-testid="hero-section">

// 测试中
await page.locator('[data-testid="hero-section"]')

2. 等待策略

// 等待元素可见
await expect(locator).toBeVisible();

// 等待网络空闲
await page.waitForLoadState('networkidle');

// 等待特定响应
await page.waitForResponse('**/api/contact');

3. 避免硬编码等待

// 不推荐
await page.waitForTimeout(1000);

// 推荐
await expect(locator).toBeVisible();

4. 使用 Fixtures

// e2e/src/fixtures/base.fixture.ts
import { test as base } from '@playwright/test';
import { HomePage } from '../pages/HomePage';

export const test = base.extend<{
  homePage: HomePage;
}>({
  homePage: async ({ page }, use) => {
    const homePage = new HomePage(page);
    await use(homePage);
  },
});

5. 测试隔离

test.describe('测试组', () => {
  test.beforeEach(async ({ page }) => {
    // 每个测试前的初始化
  });

  test.afterEach(async ({ page }) => {
    // 每个测试后的清理
  });
});

CI 集成(本仓库的真实流水线)

本仓库的 CI 只有一套:根目录 Jenkinsfile(Jenkins 声明式 Pipeline)。 以下系统在本仓库不存在,历史文档若引用即为假事实,已删除:

  • .woodpecker.yml / Woodpecker CI —— 磁盘上无此文件
  • GitLab CI(pipeline: / image: 语法)—— 无 .gitlab-ci.yml;本节此前那段 image: node:18-alpine 且调用一个不存在的脚本(test:ci)的片段三重过期(CI 系统、node 版本、脚本名都不对),已整段移除
  • GitHub Actions —— .github/ 目录不存在
  • .gitea/ 存在但只有 PULL_REQUEST_TEMPLATE.md(PR 模板,供 scripts/check-pr-checklist.sh 校验),不是 workflow 定义

Jenkinsfile 实际阶段与命令(when { branch 'main' } 标 ★,其余全分支执行;失败处理已统一为 set -e / || exit 1,历史上吞错的 || echo 只剩环境探测块,见验收 A-5):

阶段 命令 说明
🔧 环境检测与准备 node -v / npm -v / rsync、ssh、curl 探测 探测类 `
📥 安装依赖 npm ci →(失败兜底)npm install --legacy-peer-deps ⚠ 该块先 rm -rf node_modules package-lock.json 再 npm ci,故 lockfile 从未真正生效(验收 N-26②,未修待决策)
🔍 代码质量检查 → ESLint / TypeScript npm run lint / npm run type-check 判定口径 0 error(warning 不判红)
🧪 单元测试 CI=true npm run test:coverage:check 覆盖率阈值真源 config/test/jest.config.js
🌐 E2E 测试 ★ npm run build → npm run test:e2e:prod 产物目标;GA4 的 16 例只在这里断言
👁️ 视觉回归测试 ★ npm run test:visual 只跑桌面 chromium 一个 project,另四个视觉 project 在 CI 从不执行(验收 N-28)
♿ 可访问性门禁 check:contrast → check:brand-token → check:headings 三条静态门禁,全分支
⚡ Lighthouse 性能与无障碍 ★ npm run build → npm run lighthouse lhci 自起产物服务并断言
🧬 变异测试 ★ npm run test:mutation:quick 仅 src/lib/utils.ts;post 清 .stryker-tmp
🔒 安全扫描 ★ npm audit --audit-level=high → npm run test:security:headers ⚠ 该脚本默认打线上 https://novalon.cn,与阶段上方「检查本分支构建产物」的打印相反(验收 N-23,未修待决策)
🏗️ 构建 dist npm run build:clean + 产物存在性/文件数校验 post { success } 归档 dist/**
♿♿ 全站 axe 节点计数 ★ node dist/standalone/server.js(AXE_PORT,默认 3100)→ OUT=$ROUTES npm run check:axe:routes → SITEMAP=$ROUTES npm run check:axe 复用上一阶段产物;按记录 PID 收服,只 kill 本阶段自起的进程
🚀 部署到生产环境 ★ ./scripts/deploy.sh deploy --skip-build(失败自动 rollback) 需 DEPLOY_TO_PRODUCTION 参数

本地一键门禁对应关系:npm run test:all = type-check + lint + test:coverage + test:integration:real + check:a11y + test:e2e:fast + test:security:headers;CI 不跑 test:all(它按上表逐阶段拆开跑),两者集合不同——test:all 里的 E2E 是 dev 目标,CI 的 E2E 是产物目标。

调试技巧

1. Trace Viewer

# 运行测试并生成 trace
npx playwright test --trace on

# 查看 trace
npx playwright show-trace trace.zip

2. 截图和视频

// 手动截图
await page.screenshot({ path: 'debug.png' });

// 元素截图
await locator.screenshot({ path: 'element.png' });

// 全页截图
await page.screenshot({ path: 'full.png', fullPage: true });

3. 控制台日志

// 监听控制台
page.on('console', msg => console.log(msg.text()));

// 监听页面错误
page.on('pageerror', error => console.error(error));

4. Playwright Inspector

npx playwright test --debug