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

645 lines
36 KiB
Markdown
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.
# 测试文档
> **⚠ 结构时效警示(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` - 导航冒烟测试
**运行命令**:
```bash
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`):
```bash
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
**运行命令**(两条入口不是一回事,勿混用):
```bash
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 维度承担,真实存在的入口只有这两条):
```bash
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` 并不存在,勿照抄):
```bash
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 验证
- 安全头部配置
- 表单验证
**运行命令**(两条入口不是一回事,勿混用):
```bash
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](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`)
**运行命令**:
```bash
npm run test:visual # 仅 chromium 桌面
npm run test:visual:all # 5 个 project 全量
npm run test:visual:browsers # 三引擎桌面
```
**更新快照**:
```bash
npm run test:visual:all -- --update-snapshots
```
**确定性约束**(2026-09-21 验收 83 例失败的根因,详见 [docs/lessons-learned.md](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 有默认值):
```bash
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` 代做,等价于手工执行:
```bash
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](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
### 基础页面类
```typescript
// 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
```typescript
// 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();
}
}
```
### 使用示例
```typescript
// 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 配置
```typescript
// 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,
},
});
```
### 环境配置
```typescript
// 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];
}
```
## 运行测试
### 本地运行
```bash
# 依赖装在仓库根: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 环境运行
```bash
# CI 模式(禁止 only、增加重试)
CI=true npx playwright test
```
## 测试报告
### HTML 报告
```bash
npx playwright show-report
```
### Allure 报告
```bash
# 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
```tsx
// 组件中
<div data-testid="hero-section">
// 测试中
await page.locator('[data-testid="hero-section"]')
```
### 2. 等待策略
```typescript
// 等待元素可见
await expect(locator).toBeVisible();
// 等待网络空闲
await page.waitForLoadState('networkidle');
// 等待特定响应
await page.waitForResponse('**/api/contact');
```
### 3. 避免硬编码等待
```typescript
// 不推荐
await page.waitForTimeout(1000);
// 推荐
await expect(locator).toBeVisible();
```
### 4. 使用 Fixtures
```typescript
// 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. 测试隔离
```typescript
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` 探测 | 探测类 `|| echo` 合法 |
| 📥 安装依赖 | `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
```bash
# 运行测试并生成 trace
npx playwright test --trace on
# 查看 trace
npx playwright show-trace trace.zip
```
### 2. 截图和视频
```typescript
// 手动截图
await page.screenshot({ path: 'debug.png' });
// 元素截图
await locator.screenshot({ path: 'element.png' });
// 全页截图
await page.screenshot({ path: 'full.png', fullPage: true });
```
### 3. 控制台日志
```typescript
// 监听控制台
page.on('console', msg => console.log(msg.text()));
// 监听页面错误
page.on('pageerror', error => console.error(error));
```
### 4. Playwright Inspector
```bash
npx playwright test --debug
```