Files
novalon-website/docs/lessons-learned.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

547 lines
88 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.
# Lessons Learned(项目经验教训)
> 记录跨任务、跨阶段的工程经验教训,避免重复踩坑。按类别组织,定期更新。
## 目录
- [1. 技术选型与依赖](#1-技术选型与依赖)
- [2. 构建与部署](#2-构建与部署)
- [3. 样式与设计](#3-样式与设计)
- [4. 性能优化](#4-性能优化)
- [5. 测试](#5-测试)
- [6. 代码组织](#6-代码组织)
---
## 1. 技术选型与依赖
### 1.1 外部字体服务导致白屏
- **问题**:依赖 Google Fonts 等外部字体服务,网络加载失败时页面白屏。
- **根因**:字体加载阻塞首次渲染(FOUT/FOIT 未妥善处理)。
- **方案**:所有字体使用本地文件(`src/app/fonts/`),禁止外部字体 CDN。
- **来源**:`2026-03-04` 部署事故,`project_memory.md` Hard Constraints。
### 1.2 React 19 + Next.js 16 HMR 兼容性
- **问题**:开发模式下 HMR 报错 `module factory is not available`,需要频繁清除缓存。
- **根因**:React 19 与 Next.js 16 的 HMR 模块缓存机制不兼容。
- **方案**:禁用 `optimizeCss` 实验性功能;若持续影响开发,改用生产模式(`npm run build && npm run start`)。
- **来源**:`docs/HMR-ERROR-SOLUTIONS.md`。
### 1.3 webpackBuildWorker 导致静态资源 404
- **问题**:启用 `experimental.webpackBuildWorker: true` 后,客户端静态资源 404。
- **根因**:webpack 构建工作线程与 Next.js 静态导出不兼容。
- **方案**:强制禁用该功能,在 `next.config.ts` 中不启用或显式设为 `false`。
- **来源**:`project_memory.md` Hard Constraints。
---
## 2. 构建与部署
### 2.1 直接复制参考网站代码
- **问题**:从参考网站直接复制 HTML/CSS/JS 到项目中,导致样式冲突和技术债务。
- **根因**:参考网站的样式体系(Bootstrap/其他框架)与项目 Tailwind 体系冲突。
- **方案**:仅参考设计理念和布局,所有代码手动基于 Tailwind 实现。
- **来源**:`project_memory.md` Lessons Learned。
### 2.2 静态导出限制
- **问题**:`output: 'export'` 模式下,`next/image` 需要 `unoptimized`,某些 API 路由不可用。
- **根因**:Next.js 静态导出对 Server Components 和 API Routes 的限制。
- **方案**:移除 `output: 'export'`,改用 `standalone` 或混合模式;图片使用 `unoptimized` 或 `<img>` 标签。
- **来源**:`CLAUDE.md` Build Output 章节。
- **现状(2026-09-23 核对)**:方案已落地——`next.config.mjs:4` 为 `output: 'standalone'`,`/api/*` 与 `/admin/*` 由 Node 运行时承载(`nginx-static-production.conf:194-229`)。**但 `images.unoptimized: true` 的理由已换成新的一种**:产物经 Nginx 静态托管 + CDN 分发、无 Node 进程接管 `/_next/image`,改回 `false` 会让图片全量 404(`next.config.mjs:8-14` 已写明)。因此「unoptimized = 静态导出的限制」这句旧因果**不可再引用**,`CLAUDE.md` 与 `AGENTS.md` 的相应措辞已按此更正。
---
## 3. 样式与设计
### 3.1 光晕效果遮挡内容
- **问题**:Case Studies 区域卡片悬停光晕(halo effect)尺寸过大,遮挡文字。
- **根因**:光晕半径 256px + 不透明度 100%,超出卡片边界。
- **方案**:尺寸降至 192px,不透明度降至 0.08(降低 92%)。
- **来源**:`project_memory.md` Lessons Learned;后续推广为全局规则:所有光晕效果不透明度降低 30-50%。
### 3.2 未使用的大文件残留
- **问题**:项目中残留 4.2MB 的 AoyagiReisho 书法字体文件,增加页面加载时间。
- **根因**:早期设计尝试引入书法字体,切换方案后未清理。
- **方案**:定期检查 `public/fonts/` 和 `src/app/fonts/` 中未使用的字体文件,及时删除。
- **来源**:`project_memory.md` Lessons Learned。
### 3.3 品牌红使用过度
- **问题**:品牌红色(#C41E3A)在页面中占比超过 10%,视觉冲击过强。
- **根因**:缺乏品牌色使用规范约束。
- **方案**:制定品牌红贯穿规则——每页至少 3 处触达点,但面积 ≤ 10%;禁止作为段落文字色、大面积背景色。
- **来源**:`CONTEXT.md` 朱砂点睛章节。
### 3.4 `aria-hidden` 不豁免 WCAG 1.4.3 对比度
- **问题**:装饰序号 / 客户名首字占位等纯装饰文字用 `text-text-muted/10`(≈10% alpha)淡化,axe-core 仍持续报 `color-contrast`,A-12 无法清零。
- **根因**:仓库长期误以为「`aria-hidden`(或 `role="none"`、CSS `opacity`)可让装饰文字豁免对比度」。实测 axe-core 4.11.4 的 `color-contrast` 规则只看 DOM 中可见文字的颜色,不看语义豁免;变体矩阵(aria-hidden / role=none / opacity:0.1 / SVG `<text>` / `::before`)中只有后两种非文本编码能通过。
- **方案**:装饰文字改用项目唯一在册的大字档令牌 `text-text-hint`(#7C8CA5 / 深色 #94A3B8,对各级卡片底色 ≥3:1,满足 AA 大字标准),不再靠低 alpha 淡化;并在 `scripts/utils/check-color-contrast.ts` 的 REQUIRED_GROUPS 中加入「大号装饰文本 × 各级卡片底色」契约组,把该口径固化为门禁。
- **来源**:2026-09-21 验收 A-12 残留清零,变体探针脚本实测。
---
## 4. 性能优化
### 4.1 高并发下内存溢出
- **问题**:200 VUs 并发时服务崩溃,单实例无法处理高并发请求。
- **根因**:缺乏负载均衡、缓存策略和资源限制。
- **方案**:多实例部署(Docker Compose 3 实例)+ PM2 进程管理 + Nginx 负载均衡。
- **来源**:`docs/PERFORMANCE_OPTIMIZATION.md`。
---
## 5. 测试
### 5.1 测试框架冗余
- **问题**:项目存在三个独立的测试框架(e2e/, e2e-tests/, test-framework/),维护成本高。
- **根因**:早期多次试验不同测试方案,未及时清理废弃框架。
- **方案**:统一到 Playwright + Jest 体系,废弃 Python Playwright 和独立测试框架。
- **来源**:`docs/OPTIMIZATION_REPORT.md`。
### 5.2 测试覆盖率门槛
- **问题**:早期测试覆盖率低(Lines 29%),无法有效保障质量。
- **根因**:缺乏测试文化,工具函数和 hooks 未覆盖。
- **方案**:设置覆盖率门槛(branches 35%, functions/lines/statements 45%),分阶段提升至 80%;后续通过 TDD 流程提升至 85%+。
- **来源**:`docs/test-coverage-improvement-plan.md`,`CLAUDE.md` 测试命令。
### 5.3 E2E 断言文本必须与渲染文本保持一致
- **问题**:`e2e/nav-dropdown.spec.ts` 中 hover 测试失败,断言查找 `睿新ERP管理系统` 与 `行业方案`,但实际渲染为 `ERP 管理系统` 与 `行业解决方案`。
- **根因**:UI 文案迭代后,E2E 测试的文本断言未同步更新,导致测试假阴性(功能正常但测试报错)。
- **方案**:
1. 将 UI 文案断言改为稳定标识(如 `data-testid`、角色/aria 属性)或语义化选择器,减少对文案的强依赖。
2. 若必须使用文本,在修改文案时同步搜索并更新 `e2e/` 中的对应断言。
3. 建立「文案变更检查清单」,将 E2E 文本断言纳入审查范围。
- **来源**:2026-07-26 dogfood 后续验证,`e2e/nav-dropdown.spec.ts` 修复。
### 5.4 视觉回归基线需在 UI 变更后主动更新
- **问题**:dogfood 修复后(列表卡片链接、案例筛选、联系表单反馈、新闻占位图等),视觉回归测试大面积失败。
- **根因**:上述修复属于有意的 UI/交互变更,导致已有快照与当前渲染不一致;若不更新基线,后续所有构建都会报告假阳性。
- **方案**:
1. 任何涉及视觉/布局的修复完成后,运行 `npx playwright test visual-regression.spec.ts --update-snapshots` 更新全项目视觉基线。
2. 更新前通过浏览器截图/人工复核确认差异符合预期,避免将未发现的回归写入基线。
3. 将快照变更作为独立提交或 PR 变更的一部分,方便评审时比对视觉差异。
- **来源**:2026-07-26 更新 105 张视觉回归快照。
### 5.5 Playwright `storageState` 路径必须以配置文件为基准
- **问题**:全量 E2E 运行时 firefox/webkit 下所有测试瞬间失败,报错 `Error reading storage state from ./e2e/storageState.json: ENOENT`。
- **根因**:`e2e/playwright.config.ts` 中 `storageState` 被写成 `./e2e/storageState.json`,而 `npm run test` 实际在 `e2e/` 目录下执行(`cd e2e && npx playwright test`),导致 Playwright 去查找 `e2e/e2e/storageState.json`。
- **方案**:
1. 配置文件中的相对路径应基于配置文件自身目录,使用 `path.resolve(__dirname, 'storageState.json')`。
2. 修改 `storageState`、快照目录、报告目录等路径时,必须同时验证 `npm run test` 与从项目根目录直接运行 `npx playwright test --config=e2e/playwright.config.ts` 两种方式。
3. 将 `storageState.json` 纳入版本控制并作为 E2E 环境准备检查项。
- **来源**:2026-07-27 全量 E2E 回归失败排查,`e2e/playwright.config.ts` 修复。
### 5.6 Firefox 中连续全页导航易触发 Playwright locator 超时
- **问题**:`e2e/p2-functional-e2e.spec.ts` 中「Footer 导航链接可点击」在 Firefox 中反复超时,报错 `waiting for "http://localhost:3000/" navigation to finish`,即使页面已渲染完成。
- **根因**:Playwright 的 locator API 会在页面存在未完成的导航时阻塞;Firefox 对 `window.location.href` 全页导航的处理比 Chromium 更慢,连续两次 `page.goto('/')` + 点击 StaticLink 触发全页导航后,locator 评估极易进入等待导航的竞态。
- **方案**:
1. 将涉及两次独立全页导航的测试拆分为两个独立 test case,减少单次测试内的导航次数。
2. 在 Firefox 等容易触发竞态的浏览器中,对必须触发的全页导航链接点击,改用 `page.evaluate(() => element.click())` 绕过 locator 的导航状态检查。
3. 优先使用 `data-testid` 或语义选择器定位,点击后通过 `page.waitForURL()` 显式等待目标 URL,而非依赖 locator 的隐式等待。
- **来源**:2026-07-27 `e2e/p2-functional-e2e.spec.ts` Footer 链接测试修复。
### 5.7 封版阶段依赖升级需独立评估,避免 `--force` 一次性修复
- **问题**:`npm audit fix --force` 无差别升级依赖到最新版本,导致 `@lhci/cli` 被降级到使用 git+ssh 拉取 Lighthouse 的古老版本,且 `eslint-config-next@16` 与当前 `eslint@8` 不兼容。
- **根因**:`--force` 会执行 major version 升级,引入 breaking change 风险。
- **方案**:
1. 封版阶段不执行 `--force` 修复,仅执行向后兼容的 `npm audit fix`。
2. major version 升级应作为独立专项任务,在封版前或上线后安排。
3. 修复前备份 `package-lock.json`,并制定回退策略。
- **来源**:2026-08-12 封版验收阶段 1-B。
### 5.8 性能测试脚本必须与 API 接口契约对齐
- **问题**:`stress-test.js` 向 `/api/contact` 发送 JSON body,但接口期望 `formData`,且 IP 限流每小时 5 次/IP,导致 66.7% 请求失败。
- **根因**:测试脚本编写时未审查 API 接口的具体实现(`request.formData()` 和 `isRateLimited`)。
- **方案**:
1. 新增/修改 API 后同步审查性能测试脚本,确保请求体格式、认证/限流机制与接口契约一致。
2. 将对依赖外部服务的接口(如 `formsubmit.co`)的压测排除在压力测试之外。
3. 重写后的脚本仅对本地静态页面进行 GET 压力测试,避免外部依赖干扰。
- **来源**:2026-08-12 封版验收阶段 5,stress-test.js 修复。
### 5.9 测试摘要文件需在迭代中保护,避免被失败运行覆盖
- **问题**:k6 测试运行时预览服务器被停止,后续重新运行写入的摘要文件包含无效数据(0% 通过率),覆盖了之前成功的运行结果。
- **根因**:k6 的 `handleSummary` 输出路径与 `--summary-export` 路径不一致,且无版本保护机制。
- **方案**:
1. 使用 `--summary-export` 指定稳定路径(如 `tests/performance/`),与脚本内 `handleSummary` 路径统一。
2. 每次运行前备份前一次摘要文件。
3. 服务器重启后重新运行性能测试前,确认端口可用性。
- **来源**:2026-08-12 封版验收阶段 5,k6 摘要文件保护。
### 5.10 触摸目标阈值不能把 AAA 当 AA 断言
- **问题**:`mobile-accessibility.spec.ts` / `mobile-performance.spec.ts` 以「WCAG 2.1 AA」为名断言所有交互元素 ≥44×44px,修复后仍有 30+ 个 24–43px 的元素无法通过,门禁长期红色。
- **根因**:44×44 是 SC **2.5.5 Target Size(AAA)**;AA 对应的是 WCAG 2.2 SC **2.5.8 Target Size(Minimum)= 24×24**,且带 Spacing / Equivalent / Inline / Essential 例外。测试把标准档位写错,导致断言比规范严两档。
- **方案**:抽出共享扫描 `e2e/touch-targets.ts`,输出两份清单——AA 2.5.8(<24px)作为**硬门禁**断言为 0,AAA 2.5.5(<44px)作为**建议清单**只打印不失败;并按 2.5.8 的 Inline 例外用 `closest('p,li,h1..h6') && display==='inline'` 排除正文内联链接。
- **来源**:2026-09-21 验收 A-12 触摸目标项,W3C Understanding SC 2.5.8 / 2.5.5 对照。
### 5.11 `mobile-*` spec 会被桌面 project 复用,视口须在 spec 内钉住
- **问题**:`mobile-accessibility.spec.ts` / `mobile-performance.spec.ts` 带 `@mobile` 标签,但同时在 `chromium` / `firefox` / `webkit` 桌面 project 下执行——同一份断言在桌面 project 量到的是桌面布局(实测小目标 17 处 vs 移动 11 处,可聚焦元素 62 vs 11),两次结果互相矛盾却被当成「移动端口径」。
- **根因**:只有 `chromium-mobile` project 在 `playwright.config.ts` 里设了 `devices['iPhone 14']`;spec 文件名与注释声称移动端,却没有自己的视口约束,project 决定视口这一隐式约定在跨 project 复用时失效。
- **方案**:`mobile-*` spec 在文件内显式 `test.use({ ...devices['iPhone 14'] })`,让四个引擎量同一条移动布局;凡依赖视口的断言(断点类名、抽屉是否挂载、触摸目标尺寸)都必须在 spec 内声明视口,不依赖 project 隐式配置。
- **来源**:2026-09-21 验收 A-12 复跑,四个引擎测量结果对比。
### 5.12 Next.js 的 `<title>` 在路由 commit 之后 2–5 帧才落地
- **问题**:GA4 SPA pageview 用同步 `document.title` 上报,实测抓到空串或上一页标题,污染 GA4 报表的页面维度。
- **根因**:App Router 的 metadata 提交晚于路由 commit;生产实测 chromium-mobile 第 2 帧、桌面 chromium 第 3–5 帧才写入新标题。单帧 `requestAnimationFrame` 等待只是「碰巧在桌面通过」,移动端仍读到旧值。
- **方案**:`GoogleAnalytics.tsx` 改为带预算的帧循环(`MAX_TITLE_SETTLE_FRAMES = 10`):标题一旦变化立即发送,超过预算则兜底发送(绝不丢计数),新导航开始前先补发未送出的上一条 pageview。
- **来源**:2026-09-21 验收 A-17,`e2e/ga4-event-tracking.spec.ts` TC-GA4-004 实证 + `GoogleAnalytics.test.tsx` 竞态用例。
### 5.13 品牌审计失败要先判定「测试过期」还是「真实泄漏」
- **问题**:`p1-brand-visual-audit.spec.ts` 断言首页不含 `novalon` / 竞品字样而失败,初判为「文案改版后的过期测试」。
- **根因**:并非过期——CMS page copy 缺字段时代码回落到 `FALLBACK_FOUNDER_QUOTE`,而回落常量里写着未品牌化的团队名,属于真实对外可见的品牌泄漏。同时 `prisma/seed.ts` 也存了同一条文案,数据库行可能仍保留旧值。
- **方案**:先 `curl` 已构建 HTML 确认字面来源(是渲染值还是回落值),再改常量与 seed;对「疑似过期」断言必须给出证据链(现网渲染结果 + 期望口径来源)后才允许改测试,并在提交信息里留痕。
- **来源**:2026-09-21 验收 P1 品牌视觉审计,`home-content-v15.tsx` / `prisma/seed.ts` 修复。
### 5.14 视觉基线不能带上 `next dev` 的 devtools 指示器,且「装了守卫」必须被验证
- **问题**:83/115 视觉用例失败,且**页面高度完全不同的用例共享同一个差异簇** `{x:19-56, y:743-780}`(约 38px 圆盘)。
- **根因**:`next dev` 注入 `<nextjs-portal>`,其 shadow DOM 在视口左下角渲染 Next 徽标指示器;`next start` 没有它。更糟的是指示器的**形态随当次 dev 会话累积的告警数变化**——无告警时是 38px 圆盘,有告警时展开成红色 "Issues" 胶囊(本分支 dev 下 CSP 禁 `eval`,React 开发模式必然告警)。于是同一份代码在两次 dev 采集之间也会差一簇。因为该元素 `position: fixed`,全页截图按初始视口坐标落位,所以「不同高度页面出现同一坐标簇」成为它的判别特征。
- **踩过的坑**:第一版守卫把 `MutationObserver` 挂在 `document.documentElement` 上——init script 运行得比 `documentElement` 创建还早(Playwright 官方文档明确),`observe(null)` 直接抛 `TypeError`;这个报错只在 `pageerror` 里可见,测试本身照常「通过」,于是重跑 115 例全绿的基线里其实仍带着 "Issues" 胶囊。改成挂 `document` 后,用单页 `-actual.png` 左下角裁剪复核才确认剥离生效。
- **方案**:`page.addInitScript` 在页面脚本之前挂 `MutationObserver(document)` 移除 `nextjs-portal`,使快照与 `E2E_TARGET`、与 dev 会话告警数都无关;**新增守卫后必须用一张实际截图验证它生效**,不能只看「测试通过」。(该逻辑现已上移到 `e2e/fixtures.ts` 的 `context` 夹具,功能与视觉 project 共用,见 §5.16。)
- **来源**:2026-09-21 验收视觉回归,`node_modules/next/dist/next-devtools/dev-overlay/components/devtools-indicator` + dev 页面 `document.querySelectorAll('*')` 探测到 `nextjs-portal`(0×0 host、带 shadowRoot)+ 新旧基线同坐标裁剪比对。
### 5.15 `npm run test` 把视觉与写库用例并发跑,基线会被瞬时数据污染
- **问题**:`/news`、`/home` 快照在列表区多出一张「UJ-03 测试新闻 <时间戳>」卡片(日期即当天),而 SQLite 中查无此行;`curl` 却能在服务端 HTML 里读到它。
- **根因**:`test` 脚本单次调用同时跑功能 project 与视觉 project,`fullyParallel: true` 下 CMS 工作流用例「建条目 → `POST /api/cms/revalidate` → 删条目」与视觉截图并发。用例结束后数据行已清,但生产 server 的 ISR/预渲染缓存仍保留含测试条目的那一版页面(响应头 `x-nextjs-cache: HIT`),视觉用例随即截到它。
- **方案**:`npm run test` 拆为两阶段串行——`test:functional`(4 个功能 project)跑完再跑 `test:visual:all`(5 个视觉 project);`test:e2e` 指向功能阶段。CI 早已按 L4 功能 / L4.4 视觉分 stage,本地口径与之对齐。
- **来源**:2026-09-21 验收,`sqlite3 "file:prisma/dev.db?mode=ro"` 计数 38 行 / news 仅 2 条,与 `curl -sI localhost:3000/news` 响应头互证。
### 5.16 dev 指示器的 shadow DOM 会被 Playwright 穿透,`locator('footer')` 因此解析到 2 个元素
- **问题**:功能 E2E 68 例失败里 56 例是同一条 `strict mode violation: locator('footer, [data-testid="footer"]') resolved to 2 elements`,第二个元素是 `<footer class="error-overlay-footer" data-nextjs-error-overlay-footer="true">`(文案 "Was this helpful?")。
- **根因**:Playwright 的 CSS 引擎默认穿透 open shadow root,`nextjs-portal` shadow DOM 里的那个 footer 因此进入 `locator('footer')` 的结果集。它是否出现取决于当次 dev 会话有没有累积到告警(本项目 dev 下 CSP 禁 `eval`,必然告警),所以同一断言在 chromium 桌面 project 通过、换个 project 就失败——与 §5.14 同源,只是症状从像素变成严格模式。
- **方案**:在测试侧剥离,且必须全局生效。`e2e/fixtures.ts` 扩展 `context` 夹具,用 `addInitScript` 注入「`MutationObserver` 观察 `document` + 立即清一次」的脚本移除所有 `nextjs-portal`;22 个 `e2e/*.spec.ts` 的 `import { test }` 全部从 `@playwright/test` 改为 `./fixtures`(`export * from '@playwright/test'` 保证 `expect` 等仍可用)。观察对象只能是 `document`——`document.documentElement` 在文档脚本开始前为 `null`。
- **反方案(已回滚)**:先试的是在源头关掉——`next.config.mjs` 按 `NEXT_DEV_INDICATORS=off` 设 `devIndicators: false`。局部 157 例转绿一度让它看起来成立,但探针实测**设置该环境变量后 `nextjs-portal` 数量仍是 1、`locator('footer')` 仍解析到 2 个**:官方文档里 `false` 只隐藏「指示器」本身,compile / runtime 报错仍会呈现,而本项目 dev 下 CSP 禁 `eval` 的 React-dev 报错恰好把 overlay 常驻挂住。局部跑绿是 `reuseExistingServer`(dev 目标下为 true)复用了不带该变量的旧 dev server 造成的假象。附带纠正一条写错的依据:Playwright 官方文档写明 init script 运行于 `document.documentElement` **创建之前**,所以观察器只能挂 `document`——挂在 `documentElement` 上会抛 `TypeError`,而不是「静默没装上」。
- **来源**:Playwright 官方文档「How it works › Shadow DOM」(CSS 选择器默认穿透 open shadow root)+ `node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/devIndicators.md`(说明"只隐藏指示器")+ 本机两条实测:带 `NEXT_DEV_INDICATORS=off` 时 portals 仍为 1;改用 fixtures 后 p2/p3/website-acceptance/nav-dropdown 由 68 → 0 失败。
### 5.17 first visible ≠ 可交互:hydration 前派发的指针事件被丢弃且不会重放
- **问题**:nav-dropdown(hover 展开)、website-acceptance(汉堡菜单、表单校验)在**空载单跑**下也确定性失败——动作调用成功、状态永不变,然后 `expect` 轮询到 15s timeout。
- **根因**:客户端组件的 `onClick` / `onMouseEnter` 要等 hydration 才挂上,而 SSR HTML 一进 DOM 元素就已 visible。这段窗口内派发的指针事件按普通事件丢弃,hydration 后不重放。
- **实测**:`/contact` 的 `[data-testid="submit-button"]` 在 first visible 后 **1735ms** 才出现 `__reactProps$` 键;早点击得到 0 条 `[data-testid="error-message"]`,hydration 后同一动作得到 5 条。主导航「产品」按钮 first visible 时同样未 hydration。
- **方案**:`e2e/hydrated.ts` 提供 `expectHydrated(locator)`(`expect.poll` + React DOM 的 `__reactProps$` 前缀探针),在 hover / click 之前调用。只给「必须有客户端反应」的用例加,纯 SSR 渲染断言不加,避免把等待成本摊到全站。
- **来源**:本机 CDP 探针实测(在 first visible 后轮询 `Object.keys(el).some(k => k.startsWith('__reactProps$'))`,测得 1735ms;修复前后同一动作 0 → 5 条错误)+ Playwright 官方文档 `expect.poll` / 自动等待机制说明。探针脚本为一次性工具,不入库;复现方式为在 `nav-dropdown` / `website-acceptance` 里去掉 `expectHydrated` 后观察 timeout 失败。
### 5.18 StrictMode 双跑 + 「只跑一次」守卫,使 Cookie 同意条在 dev 目标下永不渲染——这才是 A-12 逃过全部门禁的原因
- **问题**:给 A-12 补 axe 对比度门禁时,新用例在 `expect(banner).toBeVisible()` 就失败。不是断言写错:dev 页面上同意条对首次访客也不出现(实测 4.5s 后 `[data-testid="cookie-consent-banner"]` 计数 0)。
- **根因**:`CookieConsent` 用 `consentCheckedRef` 保证 effect 只跑一次,而定时器分支返回 `() => clearTimeout(timer)`。React 18 StrictMode(`reactStrictMode: true`)在 dev 下按 effect → cleanup → effect 连跑:第一次的 cleanup 清掉了 2s 定时器,第二次被守卫拦下,于是**再没有人挂上定时器**。production 不双跑所以线上正常——dev 坏、prod 好,而 E2E 默认目标正是 dev。叠加 `storageState.json` 预置 `novalon-cookie-preferences`(连 prod 目标也 suppress 同意条),那条 3.31:1 同时穿过了 `check:contrast`、全站 axe 与 Lighthouse:A-11 说的「门禁双重失效」实际是三重的。
- **踩过的坑**:第一版用例用 `a[href="/privacy"]` + `.first()` 定位,命中的其实是 footer 里的同名链接,于是扫的是一个永远合规的对象;同意条需要专属 `data-testid="cookie-consent-banner"`,axe 用 `.include()` 限定作用域。
- **方案**:删除该 ref 守卫、让 effect 可重入(分支本身幂等:迁移过的第二次会走 `stored` 分支,只重复一次 `updateConsentDetailed`)。门禁改为:清 localStorage → 等条出现 → `AxeBuilder.include(banner).withRules(['color-contrast'])` → 按 **axe 节点数**归零,不再按 impact 过滤。
- **教训**:新增或加强门禁时必须同时验证它的**灵敏度**。把链接 class 临时改成 `text-text-hint`(#7C8CA5)再跑一次,确认它报 `实际 1` 并回滚——绿色在证明它能变红之前不算证据。
- **来源**:一次性 CDP 探针实测(修复前 `[data-testid="cookie-consent-banner"]` 计数 0 / 修复后 1,且 `/privacy` 链接 `CookieConsent.tsx:152` 位于条内)+ 上文变异复核。探针脚本为一次性工具、不入库;复现方式即 §「教训」所述:把条内链接 class 临时改成 `text-text-hint` 观察门禁报 `实际 1` 后回滚。
---
### 5.19 修好「同意条永不渲染」之后,视觉基线才暴露出与引擎速度相关的截图竞态
- **问题**:§5.18 的 StrictMode 守卫移除后,`npm run test` 的视觉阶段 115 例里 34 例失败,且**全部落在 `visual-firefox-desktop` 与 `visual-webkit-desktop`**(各 17 例),chromium 桌面 / 平板 / 移动三端全绿。产品代码一行未改。
- **根因**:Cookie 同意条由挂载后 2s 定时器渲染。firefox / webkit 两个 project 使用 `storageState.firefox.json`(`5a4d310` 为消除 `newContext` 阶段浏览器兼容噪声而设的**空** state),拿不到预置偏好 ⇒ 定时器到点就把条画进首屏;chromium project 用 `storageState.json`(已预置 `novalon-cookie-preferences`)⇒ 条永不出现。慢引擎"来得及拍到"、快引擎"赶不上",于是同一份代码在不同 project 下截出不同的图——竞态来自夹具,不是产品。
- **定位方法**:先算差异簇的包围盒而不是猜。结果是整宽横带 `{x:37-1243, y:726-781}`(视口底边上方 19–74px),与 §5.14 的左下角 38px 圆盘完全不同;webkit 的包围盒恰好是 chromium 的 1.99 倍(`deviceScaleFactor`),据此排除"引擎渲染差异"。再裁剪 `*-actual.png` 该区域直接看到同意条文案。
- **方案**:`visual-regression.spec.ts` 用 `page.addInitScript` 预置偏好(时间戳取常量,避免随采集日漂移),把基线口径钉成「用户已表达 Cookie 偏好」。不重采任何基线、不依赖各 project 的 storageState 是否等价;修完 115 例全绿且未使用 `--update-snapshots`。
- **教训**:修复一个「组件在测试环境永不渲染」的缺陷,会让所有以「它不渲染」为隐含前提的门禁同时失去确定性;跨引擎 project 的夹具状态必须等价,否则基线测的是夹具差异。
### 5.20 `-[var(--color-*)]/<alpha>` 是一个「静默不生成 CSS」的族,而「点亮死样式」属于设计决策而非机械替换
- **问题**:验收 R-3 顺带举了两个不生成 CSS 的写法(`hover:bg-[var(--color-brand)]/20`、`text-[var(--color-brand)]/30`)。实测它不是孤例,而是一个**族**:`src` 下 `<utility>-[var(--color-*)]/<alpha>` 共 **22 处 / 17 个唯一类**,编译产物里 **17/17 全部不存在**。
- **根因**:Tailwind v3 无法把 `/alpha` 修饰符套到纯 hex 的 CSS 变量上。`tailwind.config.js:12-15` 早已写明这一点,并因此把常用色改成 `rgb(var(--x-rgb) / <alpha-value>)`——但那次修复只覆盖 **theme 令牌**写法(`bg-brand/10` 可编译),**任意值**写法 `bg-[var(--color-brand)]/10` 不走 `<alpha-value>` 通道,依旧解析失败且不报错。
- **判定方法**:拿编译后的 CSS 做包含判定。dev 下全站共用一个 chunk,且 JIT 按**源文件扫描**决定是否产出(与组件是否被渲染无关),所以「源码里有这个类名、CSS 里没有对应规则」即为死样式。对照组证明检测器不误判:`.border-brand/30`、`.bg-[var(--color-brand)]`(无 alpha)、`.bg-accent-blue-soft` 都在,`bg-[var(--color-brand)]/40` 不在。
- **踩过的坑**:CSS 里 `:` 同样是转义字符(`.hover\:bg-...`)。第一次探测没把 `:` 放进转义集合,把**已经修好**的 `hover:bg-brand/20` 误报成「仍缺失」;补上 `:` 后确认 `.hover\:bg-brand\/20` 与 `.hover\:border-brand\/20` 均已产出。教训与 §5.14 同源:探针自身的缺陷会伪装成产品缺陷。**第二个坑更贵**:把死样式「点亮」等于凭空新增一条生效规则,必须重算配色——按评审建议换成 `hover:bg-brand/20` 后,半透明底与页面底色合成(`alpha*fg + (1-alpha)*pageBg`,注意是**替换**徽标自身底色而非叠加),浅色主题下 `text-brand-ink` 实测 **4.18:1 < AA 4.5:1**。而 `axe` 忽略 `:hover`、`check:contrast` 只断言声明的令牌配对,两道门禁都看不见这个回归,最终改用 12% 的 `bg-brand-soft`(浅色 4.80:1 / 深色 6.60:1;未 hover 的基线为 5.35 / 6.27)。
- **影响分级(按渲染证据,不按推测)**:抓 12 条路由的 HTML,17 个死类里只有 `via-[var(--color-brand)]/80`(`/privacy`、`/terms` 首屏渐变)真的出现在渲染结果中,其余都落在**没有任何路由渲染**的组件(仅被 barrel `index.ts` 再导出的死代码)。据此只修 2 处「活组件 + 仅 hover」:`NewsDetailClient.tsx:49` → `hover:bg-brand-soft`、`service-card.tsx:33` → `hover:border-brand/20` —— hover 态不进视觉基线,零基线冲击,且意图(悬停染红)无歧义。
- **未顺手修的原因**:把 `/privacy`、`/terms` 的 `via` 「修好」等于把 80% 半透明中点画进**已审批**的基线,在当前两停渐变中间挖出一道暗坑——这是设计意图问题,不是令牌化问题。另 3 处(`bg-[var(--color-bg-section)]/60`、`bg-[var(--color-brand-bg)]/50`、`hover:bg-[var(--color-brand-bg)]/30`)需要先在 `globals.css` + config 补 `--*-rgb` 通道才可能编译,属令牌系统扩展。
- **教训**:①「样式是否生效」的证据只能在**编译产物**里找,源码存在类名 ≠ 生效;②死样式的收口边界是设计意图与像素影响面——先量化「是否被路由渲染 / 是否仅 hover / 是否需要新增 rgb 通道」再决定点亮、删除还是留给人判断,不要为了令牌数好看而顺手改。
- **来源**:一次性 CSS 包含判定探针(编译 CSS + 12 条路由 HTML,含上述对照组;探针为一次性工具、不入库,复现方式是对照 `border-brand/30` 与 `bg-[var(--color-brand)]/40` 在 `/_next/static/chunks/src_app_*.css` 中的有无);`tailwind.config.js:12-15` 对本机理的既有记载;`ACCEPTANCE_REVIEW_2026-09-21` §R-3 原始举例。
---
### 5.21 `try { expect(...) } catch { console.log(...) }` 会让门禁永久无法失败,而错误前提的断言第一版就是假绿
- **问题**:2026-09-22 最终树全量运行的日志里出现 `Error checking header svg: expect(received).toBeGreaterThan(expected) / Expected: > 0 / Received: 0`,但同一时刻该用例报 **✓ 通过**(`p1-brand-visual-audit.spec.ts:419` 「Logo SVG 文件不应包含 "NOVALON" 英文文本」)。也就是说:**门禁断言真的失败了,测试结果却是绿的**。
- **根因(两层,独立成立)**:
1. 该 spec 有 3 处把断言写在 `try` 里、`catch` 只 `console.log`(`:501`、`:549`、`:750`),断言失败被降级成日志;另有 `:507-518` 的兜底——「没找到任何 Logo 元素」时只要页面存在任意 `img`/`svg` 就通过。
2. 被吞掉的断言 `:476` `expect(svgTextContent.length).toBeGreaterThan(0)` **前提本身就是错的**:纯 `<path>` 图标 SVG 没有 `<text>` 节点,`textContent` 合法为空。于是「SVG 里有中文品牌名」这条检查在现行 Logo 形态下永远为假,只能靠 catch 跳过、再由 `img` 分支(`/logo.svg`)判定通过。
- **判定方法(低成本,可复用)**:把「`allure-results/*attachment.txt` 或 stdout 里出现 `Error checking`」与「同一 test 的最终状态」配对——**日志里有断言失败 + 测试为 ✓ ⇒ 假绿**。这比对断言代码本身快得多,且能覆盖「断言写在循环/选择器分支里、实际从未执行」的情况。
- **影响**:`p1-brand-visual-audit.spec.ts` 正是 §7-4「品牌文案 vs `FORBIDDEN_TEXT=/novalon/i`」裁定的执行门禁(CONTEXT.md:195 裁定改文案不改测试)。门禁本身可被 DOM 结构变化静默旁路,意味着该裁定的留痕只在那次运行里有效,**不构成持续守卫**。这与验收 A-11「断言通过而实际失败」同类,只是失效点从阈值搬到了 catch。
- **处理(2026-09-22 已闭合)**:先等权威产物落盘,避免把刚建立的「②E2E 归零」变回混合树,再改 spec。共 10 处:删掉错误前提的 `expect(svgTextContent.length).toBeGreaterThan(0)`,改为「Logo 图形资产内出现的 `<text>` 只能是已批准的拉丁字标 `NOVALON`」(依据:本文件 :173 对首页 Hero 英文品牌名的既予豁免 + 8d3bd72 `unify calligraphy logo` 刻意保留该节点);去掉 5 处吞断言的 `catch`(`:346/:501/:549/:587/:749`);5 处 `expect(true).toBeTruthy()` 换成真实断言或**诚实 skip**(桌面视口不渲染移动端按钮时 `test.skip`,而不是伪造通过);给三个「循环 + 候选选择器」型用例加**计数断言**(`hovered>0`、`withAlt>0`、`checked>0`),使「一次都没校验成」必然变红;截图用例改为断言 PNG 字节数并挂报告附件。`npx playwright test p1-brand-visual-audit.spec.ts` 在 chromium / chromium-mobile / firefox / webkit 四个 project 下 **124 passed**(`/tmp/brand-fix1.log`)——同一个数字,但这次背后是真的断言。
- **教训**:①`catch` 里只记日志的 `expect` 等价于**没有断言**——e2e 里若确需对多个候选选择器容错,应显式收集每轮失败并在末尾统一 `expect.fail`,让「一个都没校验成」必然变红;②写内容型断言前先在真实 DOM 上量一次前提(图标 SVG 常无文本节点),否则第一版就永久假绿;③「全量 0 failed」只是必要条件,不是门禁可信的充分条件——须同时扫过日志里的软失败文本,否则放行条件②会在一个无法失败的 spec 上被打勾。
- **来源**:`/tmp/e2e-final3.log`(`Running 1080 tests`,同 test 的 ✓ 行与 `Error checking header svg` 相邻);`e2e/allure-results/*-attachment.txt` 三处同名日志;`e2e/p1-brand-visual-audit.spec.ts:419/476/501/507-518/549/750`;`ACCEPTANCE_REVIEW_2026-09-21` §7-4 与 A-11;`CONTEXT.md:195` 的品牌文案裁定。
---
### 5.22 验收口径必须是「可数的量」:10% 透明度水印数字被四道门禁同时漏过
- **问题**:放行条件⑤要求「A-12 关闭后两个引擎各复跑一次并留 axe **节点计数 = 0** 的证据」。按该口径重写扫描脚本(`docs/acceptance/2026-09-21-axe/axe-contrast-evidence.mjs`,现 `scripts/accessibility/axe-node-count.mjs`:sitemap 全站 29 URL × chromium/firefox × light/dark = 116 次页面扫描,内置反假绿熔断——读回 `data-theme` 与 `getComputedStyle(body).backgroundColor` 必须等于该主题令牌值,否则 `bgMismatch/themeMismatch` 直接判失败)后,首轮实测 **`passed=false`,4 个「引擎×主题」组合各 4 个 `color-contrast` serious 节点**,全部落在同一处:`/products/erp-upgrade` 的步骤大数字 `text-6xl font-bold text-brand-ink/10`(`erp-upgrade-content-v2.tsx:300`)。
- **根因(为什么四道门禁都看不见它)**:口径各异 ⇒ 没有任何一道覆盖「任意 alpha 组合 × 真实底色 × 节点计数」这条轴。
1. `check:contrast` 只断言 **REQUIRED_GROUPS 里声明的令牌配对**,`text-brand-ink/10` 不在册;
2. `check:headings` 与品牌审计 spec 只看文本内容,不看像素;
3. Lighthouse 的 URL 白名单只有 7 个入口页,`/products/erp-upgrade` **根本不在其中**(零覆盖);
4. `e2e/mobile-accessibility.spec.ts:67` 断言的是 critical/serious **规则条数** = 0,而规则数 ≠ 节点数(1 条规则命中 4 个节点仍记 1),且只跑一种主题、一台设备。
这与 §3.4 是**同一个契约的复发**:该处已记载「`aria-hidden` 不豁免对比度,axe 只看可见像素」,本例只是换了个页面、换了个令牌再次发生。
- **修复**:`text-brand-ink/10` → `text-brand-ink/80`,与站内同类元素的既有实现同档(`about-content-v4.tsx:172` 的步骤号正是 `text-brand-ink/80`)。修复后 dev 探针实测该页:`color-contrast` 违规节点 **0**、通过节点 118,计算色值 `rgba(196,30,58,.8)`(浅底 `rgb(255,255,255)`)/ `rgba(248,113,113,.8)`(深底 `rgb(10,14,20)`)。`aria-hidden="true"` 保留——它解决读屏冗余,与视觉对比度是两条正交契约。备选方案是 §3.4 的在册大字装饰令牌 `text-text-hint`(灰、≥3:1),但它会去掉该模块的品牌红触达,故未采用。
- **教训**:①验收口径要写成**可数的量**(节点数、URL 数、主题数),「规则命中数 = 0」「分数 ≥ 0.9」都是弱口径;②任何 `text-*/<alpha>` 都必须在真实底色上量一次合成值,令牌配对门禁天然看不见未登记的组合;③新增营销页必须同时进 Lighthouse URL 白名单与视觉回归路由表,否则它处于「有单测、无门禁」的盲区——`/products/erp-upgrade` 两处都不在(已列为待决项);④同一契约修复后要以**全站扫描**而不是局部复测来收尾,否则「A-12 已关闭」只是局部真。
- **来源**:`docs/lessons-learned.md` §3.4(同一契约的首次记录);`docs/acceptance/2026-09-21-axe/axe-evidence.json` 首轮 `summary.*.contrastNodes=4`;`config/test/lighthouserc.json:9-16` URL 列表;`scripts/utils/check-color-contrast.ts` REQUIRED_GROUPS 口径;`e2e/mobile-accessibility.spec.ts:67`;`e2e/visual-regression.spec.ts:26-39` 路由表。
---
### 5.23 「没测所以为 0」与「进程没被收所以端口被占」:两类会把验收证据变成假绿的自伤缺陷
- **缺陷 A:证据脚本在零路由时仍打印 PASSED = true。** 为闭合 §5.22 的口径缺口(sitemap 只有 29 条,而 A-12 的口径是全站),我加了清单生成器 `docs/acceptance/2026-09-21-axe/crawl-sitemap.mjs`(现 `scripts/accessibility/crawl-routes.mjs`,2026-09-23 已连 `axe-contrast-evidence.mjs` 一起门禁化并接进 `Jenkinsfile`;预渲染产物 `.html` ∪ sitemap ∪ 站内链接 BFS = 36 条公开路由),但 `axe-contrast-evidence.mjs:13`(现 `scripts/accessibility/axe-node-count.mjs`)解析 `<loc>` 的正则**硬编码了生产域名** `https://www.novalon\.cn([^<]*)`,而我生成的 loc 是 `http://localhost:3100/...` ⇒ 一条都没匹配上 ⇒ `routeCount=0 / rows=[] / summary={}`,而 `passed` 是对 `summary` 里各项「= 0」的 `every` 判断,空对象上恒真 ⇒ 输出 **PASSED = true**,并且已经把上一份有效的 29 页证据覆盖掉(有效版本已在覆盖前另存为 `axe-evidence-sitemap29.json`)。
- **修复**:①解析改为 origin-agnostic(`new URL(loc).pathname`);②`urls.length === 0` 直接 `exit 2` 并打出「扫描等于没测」;③`passed` 增加覆盖前置 `rows.length === urls.length * 4`(页数 × 引擎 × 主题),使「计数为 0」只可能来自「真的测过且为 0」。
- **教训**:任何以「计数 = 0」为通过条件的门禁,都必须同时断言**分母**(跑了多少页 / 多少条路由 / 多少个用例)。这与 §5.21 的 `catch` 吞断言、§5.22 的规则数 ≠ 节点数是同一族:**空集合上的 `every` 永真**。自己新写的探针要先做正/负对照(本次负对照:喂 localhost 清单必须报错退出而不是报绿)。
- **缺陷 B:门禁脚本自起的服务器没被回收,毒化下一轮 E2E。** 验收报告 §9 已预警 `check-heading-hierarchy.ts` / `accessibility-test.js` 起 `npm run preview` 后留下 PPID=1 的 `next-server` 占 :3000 达 39 分钟。本轮第一方复现并付了代价:chain1 末尾(GA4 生产目标复跑 + 三项静态门禁复跑)在 :3000 留下一个 `next-server (v16.3.0)`,chain2 的完整 `npm run test` 因此经由 `reuseExistingServer: !CI` **静默复用了它**(`e2e/playwright.config.ts:134`),1080 例跑出 **53.6 分钟、805 passed / 263 failed**,其中失败主体是 **205 个 `page.goto` 超时**(不是断言失败);同一时刻隔离复跑那 2 例仍 4 passed。两小时后回看,这批数字与「产品有 263 例回归」毫无关系。
- **根因有两层**:`process.exit()` **不会执行 `finally`**,所以原来写在 `finally` 里的 `server.kill()` 在正常路径上根本不运行;即便运行,它杀的也只是 `npm` 包装进程,真正 LISTEN 的 `next-server` 孙进程被 init 收养。
- **修复**:两处 `spawn(..., shell: true)` 加 `detached: true` 使其成为进程组组长,收服改用 `process.kill(-pid, 'SIGTERM')`(杀整组),并把 `stopServer()` 显式提到两处 `process.exit()` 之前;`scripts/utils/check-heading-hierarchy.ts`、`scripts/accessibility-test.js` 同步。串行证据链另加**端口预检/后置检**(`PREFLIGHT_LISTENERS` / `POST_*_LISTENERS`),非 0 时该步产物不得引用。
- **教训(管理归零)**:`reuseExistingServer: true` + 任何会残留监听进程的步骤 = 后续证据失去「测的是当前代码」这一前提。共享机器上跑证据链,**每一步前后都要打印端口占用数**;跑完整套件前若 :3000 非空闲,必须先确认那个服务是不是自己刚起的、构建是否比当前树更新。
- **来源**:`docs/acceptance/2026-09-21-axe/axe-evidence-sitemap29.json`(有效的 29 页版)与被覆盖后 `routeCount=0` 的产物对比;`/tmp/e2e-final6.log`(53.6m / 805 / 263 / 205×`page.goto` 超时);`/tmp/e2e-isolate-rerun.log`(4 passed);`lsof -iTCP:3000` 实测 pid 61088 `next-server (v16.3.0)`;`e2e/playwright.config.ts:130-134`;验收报告 §9「门禁脚本的进程回收缺陷」。
---
### 5.24 「清单只取 sitemap」让边界页长期无人测量;扩目录当场挖出 404 水印与缺失的兜底页
**背景**:放行条件⑤ 要求「全站对比度关闭后,两个引擎各复跑一次并留 axe 节点计数 = 0」。前几轮的路由清单直接取自 `/sitemap.xml`(29 条),而 `src/app/sitemap.ts` 只列**希望被收录**的页面 —— 404、错误兜底、被注释掉的 `/cases` 天然不在其中。§5.22 已经证明过一次「新页没进清单 ⇒ 门禁测不到」,这次把清单来源补成 **sitemap ∪ 预渲染产物 ∪ 站内链接 BFS**(`docs/acceptance/2026-09-21-axe/crawl-sitemap.mjs`,现 `scripts/accessibility/crawl-routes.mjs`),得到 35 条可达路由。
**扩目录后第一次扫描就不再是 0**(chain3,`axe-evidence.json` `routeCount=35 / rows=140 / passed=false`):
| 路由 | 双引擎 × 双主题 | 报错 | 根因 |
|---|---|---|---|
| `/_not-found` | 4/4 各 1 节点 | `color-contrast`(h1) | `src/app/not-found-content.tsx:14` 的「404」用 `text-brand-ink` + **`opacity-20`**,有效 alpha 只有 20%,任何配色都过不了 AA。与 §5.22 的 `/products/erp-upgrade` 水印数字同族,只是那次用 `-[var(--color-brand-ink)]/10`(Tailwind 根本不产出 CSS,反而"躲过"了 axe),这次用 `opacity-*` 是真渲染了 |
| `/_global-error` | 4/4 各 1 节点 | `html-has-lang`;深色下 `bgMatchesToken=false` | 项目**没有** `src/app/global-error.tsx`,根布局抛错时 Next.js 用内置文档壳顶替 root layout:`<html id="__next_error__">` 无 `lang`,且本站主题走 `html[data-theme='dark']` 属性(`globals.css:396` 明确说明不是 `.dark` class),内置壳读不到 ⇒ 深色落到浏览器默认画布 `rgb(10,10,10)`。官方文档 `error.md` 同样写明「global-error 与内置 500 页自带文档、不含全局样式,OS 主题开关要在自己的组件里做」 |
**修复**:404 标题改 `text-brand-ink/80`(沿用 §5.22 先例:120px 属大号文本,AA 需 3:1,/80 在浅色 `#FFFFFF` 与深色翻转通道 `#F87171` 下均达标且能编译);新增 `src/app/global-error.tsx` 自带 `<html lang="zh-CN">` + `<title>`(错误边界是 Client Component,不能用 `metadata` 导出)+ 与 `globals.css` 同值的 OS 主题内联样式(CSP `style-src 'unsafe-inline'` 允许)。注意本版本 Next 的 props 是 **`retry`**(`node_modules/next/dist/docs/.../error.md` 的示例即 `retry: () => void`),不是旧文档里的 `reset` —— 写之前必须读随包文档,否则又是一个"按训练记忆编造 API"。补 3 例单测断言 `lang`、`retry` 接线、digest 展示。
**同轮还归零了三例「隔离必过、满负载必挂」的 E2E**(chain3:`1048 passed / 4 failed`,端口预检为 0,所以不是 §5.14/§5.23 那类孤儿服务复用;单跑 33 例全过):
1. `p4-performance-a11y.spec.ts:41` 把 `await page.waitForTimeout(1500)` 写在计时区间**内** ⇒ 5000ms 预算实际只剩 3500ms 给导航,dev 服务首次访问要现场编译,必然破线(实测 /about 6069ms)。修复:`startTime` 前加一次 `waitUntil: 'commit'` 预热、把 settle 等待移到计时之外。修后同一断言下首页 **107ms**、关于 232ms。
2. `p3-compatibility.spec.ts:114` 先 `isVisible()` 再 `boundingBox()` 是**两次独立解析**,节点被替换时第二次返回 null。修复:`expect.poll(() => boundingBox()).not.toBeNull({timeout: 10000})`。
3. `uj-11-home-conversion.spec.ts:172`(firefox)`scrollIntoViewIfNeeded` 撞 `Element is not attached to the DOM`;`p2-functional-e2e.spec.ts:253` 点页头 mega dropdown 里的 `/services/*` 链接,高负载上下拉在 click 前收起、点击落到底下的 `/services` 触发器。修复:滚动前先 `expect(...).toBeVisible({timeout:15000})`;正文链接改为限定 `main a[href*="/services/"]` + `waitForURL`,并打印分母(`main 区 /services/* 链接数=`)以免"没找到所以绿灯"。
**教训(口径)**:
- **门禁覆盖 = 清单来源**。`sitemap.xml` 是"给搜索引擎看的",不是"给用户可达的"。要证明「全站节点计数 = 0」,清单必须至少并上预渲染产物(`dist/standalone/dist/server/app/**/*.html`)与站内链接 BFS,否则 404 / 错误页 / 孤儿页永远在测量之外。
- **`opacity-*` 与 `text-*/<alpha>` 是两条不同的暗化通道**,前者一定会渲染(因此一定会被 axe 看见),后者在 Tailwind v3 + 纯 hex 变量下可能静默不产出。审计水印类文本时两条都要查,只看类名里的 `/NN` 会漏。
- **负载竞态不要用 `retries` 掩盖**:这三例的共性是把"等稳定"写成固定 `waitForTimeout`,而把"读一次"写成裸 API 调用。改成显式的 auto-retry 断言 + 预热,既保留断言强度又消除抖动;顺带让性能断言终于测的是页面加载而不是编译器。
- **修完一批还会冒出下一批,直到触到产品根因**。chain4 修掉那三例后(隔离复跑同样全过),同一轮又出现三例新失败:移动端 LCP 2736ms(同一个"编译计入计时"成因的第四个实例)、`website-acceptance.spec.ts:121` 与 `mobile.spec.ts:101` 找不到 `[data-testid="mobile-navigation"]`。后者不是竞态而是**真产品缺陷**:`header.tsx:184` 的 `onClick={() => setIsOpen(!isOpen)}` 读的是渲染闭包里的 `isOpen`,与抽屉 `AnimatePresence` 的退出动画(0.2s + 0.25s)叠加时,高负载下连点会算成"没变化"⇒ 用户按汉堡按钮打不开抽屉。改成函数式 `setIsOpen((prev) => !prev)` 后 chain6 功能 1052 / 0 failed、视觉 115 / 0 failed。教训:**两个 spec 在同一控件上失败**是"共同上游缺陷"的强信号,不要各自加等待哄过去;仓里另两处同型 `set(!x)`(`admin/login/page.tsx:80`、`admin/notifications/page.tsx:167`)不在失败链路上,按范围裁定另计。
- **扩目录的第二类噪声:框架内置文档会被当成"本站页面"扫进来**。`_global-error.html` 是 Next 为根错误路径预渲染的**内置 500 文档**(实测 `<html id="__next_error__">`,无 `lang`、零应用标记,深色下 `pageBg=rgb(10,10,10)` 即浏览器默认画布),而本站的 `src/app/global-error.tsx` 编译产物在 `dist/.../chunks/ssr/src_app_global-error_tsx_*.js`(文案可 grep 到),二者不是一回事。按名字硬编码排除就是"为了让计数归零而缩清单",所以按**内容**判定:`/<html[^>]*id="__next_error__"/` 命中才跳过,并打印 `skippedInternalDocs=<n>`;框架一旦把本站组件预渲染到该路径,条件不再命中、路由自动回到清单。对照 `/_not-found`:它的 HTML 里就是本站的 `text-brand-ink` h1,属本站资产,改 `/80` 后 `contrastNodes` 由 1 → 0(chain4 实测)——"同前缀不等于同类"。
**来源**:`docs/acceptance/2026-09-21-axe/axe-evidence.json`(`routeCount=35`、`passed=false`、8 条 bad rows)、`/tmp/e2e-final7.log`(chain3 4 failed 与逐页 load time)、`/tmp/repro-4.log`(隔离复跑 33 例全过)、`node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/error.md`、`src/app/globals.css:396-421`。
### 5.25 门禁里「恒不成立的断言」比没有断言更危险:3 条 Lighthouse 死审计 + 一次 2 秒 type-check 的自证
**背景**:chain6 的 `npm run lighthouse` 以 `EXIT=0` 通过,但同一份日志每轮都稳定打印 21 条
`"…" is not a known audit. expected: >=1 found: 0`。因为是 `warn` 级,它永不判红,于是「4 类目 + 5 项 CWV + 57 项 a11y 断言全过」这句结论里混进了 3 项**从来没测过任何东西**的条目。
**取证(两个独立信源,缺一不可)**:
| 信源 | 做法 | 结果 |
|---|---|---|
| 运行时报告 | 把 chain6 全部 21 份 `*.report.json` 的 `audits` 键取并集(175 个 id),再拿 `lighthouserc.json` 里 62 个非 `categories:` 断言键去比对 | 只有 `autocomplete-valid` / `presentation-role-conflict` / `svg-img-alt` 三条不在并集里;同批的 `color-contrast`、`heading-order`、`target-size`、`aria-conditional-attr`、`skip-link` 全在 ⇒ 不是"页面恰好没问题",是审计项不存在 |
| 审计注册表 | `@lhci/cli@0.15.1` 实际用的是它自带的 `lighthouse@12.6.1`(**不是**顶层 `lighthouse@13.4.1`,这层版本裂差就是本地包搜不到该审计的原因),其 `core/audits/accessibility/` 共 64 个审计文件 | 三者均无对应文件 |
**为什么不能"删掉了事"**:用与 ⑤ 门禁同款的 `runOnly: {type:'tag', values:['wcag2a','wcag2aa','wcag21a','wcag21aa']}` 做正/负对照(`docs/acceptance/2026-09-21-axe/probe-axe-rule-coverage.mjs`,每条规则各造一个必然违规的最小节点):`autocomplete-valid`、`svg-img-alt` 在 axe 通道里确会被执行并抓到违规(各 1 node)⇒ 覆盖并未丢失;但 `presentation-role-conflict` 属 `best-practice` tag,**当前 axe 门禁的 tag 集合根本不跑它** ⇒ 删掉死断言就等于悄悄拆掉一道门。处理:`lighthouserc.json` 删 3 条 + `axe-contrast-evidence.mjs` 增加规则级 `runOnly: {type:'rules', values:EXTRA_RULES}` 第二次运行,并按页断言 `extraRulesChecked === 3`(`extraRulePagesUnderCovered` 计数)+ 节点数 0 —— 覆盖从"看起来有"变成"确实有且可数"。**2026-09-23 门禁化补充**:脚本已平移为 `scripts/accessibility/axe-node-count.mjs`(清单生成器为 `scripts/accessibility/crawl-routes.mjs`)并接进 `Jenkinsfile`,判定从「打印 PASSED」改为退出码;规则级通道另加一份写死的三条下限清单 `CANONICAL_EXTRA_RULES` 与绝对分母 `Σ = 路由 × 4 组合 × 3 规则`——只从运行列表里删规则会当场判红,不再靠"运行列表本身就是断言对象"这种自我豁免。
**同轮的另一次自证**:chain6 `EXIT_TYPECHECK=0` 只花 2 秒,对一个 200+ 文件的 Next 项目不合理。先怀疑空跑,再用正/负对照定性:临时放一个 `const probe: number = "not a number"` ⇒ `tsc` 报 `error TS2322`(冷缓存 11.1s),删除后 2.9s 干净;`tsconfig.compilerOptions.incremental=true` 命中 `.tsbuildinfo` 才是快的原因 ⇒ 门禁有效,结论保留。**"很快"和"很慢"都只是怀疑的起点,不是结论;能造一个必然失败的正对照,就不要靠推断给门禁背书。**
**顺带纠一个数**:`AGENTS.md` §5 记的「lint 实测 105 条 warning」已过期,chain4/chain6 均为 **106**(56 `no-console`/25 `no-explicit-any`/12 `react-hooks/set-state-in-effect`/10 `no-img-element`/其余 3 条零散,含 1 条 `Unused eslint-disable directive` 死抑制),已按实测改文并注明日期。文档里的数字一旦不再被复算,就会从"证据"退化成"传说"。
**来源**:`lighthouse-reports/*.report.json`(`lighthouseVersion 12.6.1`)、`node_modules/@lhci/cli/node_modules/lighthouse/core/audits/accessibility/`、`docs/acceptance/2026-09-21-gates/lighthouse.txt`(chain4+chain6 合计 42 条 warning)、`docs/acceptance/2026-09-21-axe/probe-axe-rule-coverage.mjs`、`/tmp/lint-final4.log` 与 `/tmp/lint-final6.log`。
### 5.26 文档会写出不存在的机制:本轮四处「读起来很像真的」的假事实
**背景**:2026-09-23 做文档—代码一致性回扫(本轮只改文档,不改代码),逐条 grep 取证后发现四类同族缺陷。它们的共同点是**句式完整、带文件名和行号,但指向的东西不存在或早已换掉**——比空洞的文档更危险,因为它会被当成依据。
| 文档断言 | 实测 | 定性 |
|---|---|---|
| `docs/testing.md`:Playwright `webServer.env` 注入 `NEXT_DEV_INDICATORS=off`,`next.config.mjs` 据此设 `devIndicators:false` | 两个字符串在 `next.config.mjs` 与 `e2e/playwright.config.ts` 中**都不存在**(后者 `webServer.env` 只有 `CMS_REVALIDATE_SECRET`);真实现是 `e2e/fixtures.ts` 移除 `<nextjs-portal>`。`NEXT_DEV_INDICATORS` 唯一出现处是 lessons-learned §5.16 的**反方案历史记录** | 把"当年试过的反方案"抄成了"现行机制" |
| `CLAUDE.md`:`npm run preview` = "Serve dist/ on port 3000 (**npx serve**)" | `package.json` 里 `preview` 与 `start` 同为 `next start -p 3000`;`serve` 不在依赖里 | export 时代的描述残留到 standalone 时代 |
| `AGENTS.md` §8 / `CLAUDE.md` Build Output:「静态导出为主」「images unoptimized = 静态导出限制」 | `next.config.mjs:4` 为 `output: 'standalone'`;unoptimized 的真实理由写在同文件 `:8-14` 的注释里(Nginx+CDN 直服,无 Node 接管 `/_next/image`) | 约束的**结论**仍对,**因果**已换 |
| `package.json`:`check:axe` / `check:axe:routes` → `scripts/accessibility/{axe-node-count,crawl-routes}.mjs` | 记录当刻 `scripts/accessibility/` **目录不存在**,可运行版仍在 `docs/acceptance/2026-09-21-axe/`(`axe-contrast-evidence.mjs` / `crawl-sitemap.mjs`)。**同日已闭合**:两脚本平移为 `scripts/accessibility/{axe-node-count,crawl-routes}.mjs`,判定改为退出码,并接进 `Jenkinsfile`「♿♿ 全站 axe 节点计数」阶段(详见 §5.25 末与 `docs/testing.md` 的 ⑤ 节) | 反向漂移:脚本先声明、实现还没搬。当时两条未被 `check:a11y` / `test:all` 引用,所以门禁没坏,但**文档一旦把 `check:axe` 列为已通过即为假绿** |
**同轮的口径缺陷**:`AGENTS.md` §5 把「E2E 测试:`npm run test` 全部通过」当门禁,而该命令的 webServer 是 `npm run dev`,`ga4-event-tracking.spec.ts` 的 4 个 `@critical` 用例 × 4 project = **16 个实例是 skipped**(GA ID 只在 `.env.production`)。`skipped-tests-final-tree.json` 里这一组 `count:16`,占当轮 28 个 skip 的主体。「全绿」因此并不等于「全断言」。
**教训(管理归零)**:
1. 文档里每个带**文件名 / 脚本名 / 环境变量名 / 配置项名**的句子,都要能被一条 grep 或一次 `node -e "require('./package.json')"` 证伪;写不出取证命令的句子降级为"待核实"。
2. 反方案与历史记录**不得**被后一轮编辑改写成正方案(§5.16 是历史,`docs/testing.md` 把它写成了现行机制)。做法:历史只留在 `docs/lessons-learned.md`,机制描述只留在 `CLAUDE.md` / `docs/testing.md`,且引用同一处行号。
3. 「skipped 不算失败」≠「skipped 算覆盖」。任何按标签聚合的门禁(`@critical` / `@smoke`)在报告里都要连带披露 **skipped 明细与它属于哪个 E2E 目标**。
4. `package.json` 里指向不存在文件的 script 是**代码—代码**矛盾,比文档过期更早该修;本轮只在文档侧标注,不动 `package.json`(越界即需授权)。
**来源**:`package.json`(`scripts.*` 全量核对)、`next.config.mjs:4-14`、`e2e/playwright.config.ts:130-139`、`e2e/fixtures.ts:5-13`、`e2e/ga4-event-tracking.spec.ts:118-124`、`src/components/analytics/GoogleAnalytics.tsx:81,119`、`.env.production`、`node_modules/next/dist/server/next.js`(`getServer()` 的 standalone 警告)、`node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/output.md`、`docs/acceptance/2026-09-21-gates/{skipped-tests-final-tree.json,ga4-production-run.txt}`、`docs/acceptance/2026-09-21-axe/axe-evidence.json`。
### 5.27 门禁会在「错误的产物」上给出可信的绿:standalone 缺静态资源 + 子代理测试把门跑红
**背景**:2026-09-23 验收复核。两条同族事故都发生在「门禁已经接上、脚本也确实执行、退出码也确实被判定」之后 —— 也就是 §5.25/§5.26 那类「有没有跑」的问题都排除了,仍然出错。
**(a) 产物没装配,浏览器门禁对着裸 HTML 出数。** `next build` 在 `output: 'standalone'` 下**不**把 `dist/static` 与 `public` 放进 `dist/standalone`。镜像用 `Dockerfile` 的 `COPY` 补齐,CI 的 axe 阶段也自己做了拷贝(注释里写明「缺了它们对比度会『意外达标』」),但**本地跑法一步都没有**:`npm run lighthouse` 与手工 `check:axe` 直接起未装配的 `server.js` ⇒ `/_next/static/**` 全 404,页面落在默认黑白底上。后果分两级:
- `check:axe` **诚实报红** —— 因为它带 `bgMismatch` 判据(底色必须等于当前主题令牌值)。
- `npm run lighthouse` 在同一条件下 **exit 0、perf 99-100、a11y 全绿** —— 因为它没有任何「样式是否加载」的前置断言。
同一份缺陷,一个抓到、一个放行。修复是新增 `postbuild` 脚本按 `Dockerfile:53-57` 同构装配;修复后对比度失败节点从 10 个(a11y 92-97)变为 **0 个(a11y 9/9 = 100)**。
**(b) 补测试的子代理把门禁本身跑红了。** 为闭合覆盖率缺口新增的 `src/components/sections/hero-particle-field-engine.test.tsx`,在一个「模拟无 rAF 的老宿主」用例的 `finally` 里写:
```js
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: savedRaf });
```
`defineProperty` 的属性描述符里 **`writable` 缺省为 `false`**(这不是 `Object.assign`/普通赋值的语义),于是全局 `requestAnimationFrame` 从「可写数据属性」变成「只读数据属性」;随后 `afterEach` 中 `rafSpy.mockRestore()` 走赋值还原,抛 `TypeError: Cannot assign to read only property`。表现极具迷惑性:
- 输出仍是 `Test Suites: 1 failed` + `Tests: 1652 passed, 1652 total` —— 那 25 个用例**根本没跑**,但汇总行读起来像全绿。
- `npm run test:coverage > f 2>&1; echo exit=$?` 才看到 **EXIT=1**。管道里 `tail` 之后拿到的 0 是管道的退出码,不是 jest 的。
定位路径(可复用):① 单独跑该套件 → `0.956s / Tests: 0 total`,证明不是跨文件污染而是套件内部;② 用 `-t "用例名片段"` 逐例筛选 → 只有那一例失败、其余 24 例 skipped 仍绿,锁定到单个 `it`;③ 用 `jsdom` 裸实例对照 `pretendToBeVisual` 下该属性的描述符,确认「可写」才是常态,从而指向 `defineProperty` 的默认值。
**教训(管理归零)**:
1. **浏览器侧门禁必须断言自己的前置条件**。`bgMismatch` 这类「样式真的加载了吗」的哨兵,价值不低于它检查的那条规则;`lhci` 那类只断言分数的门禁,缺这个哨兵就会在错误产物上报绿。装配步骤要落在 `postbuild` 这种**不可绕过的钩子**里,而不是写在文档里靠人照抄。
2. **子代理交付的测试必须逐档隔离复跑**,且以 **EXIT 码**为准,不看 `Tests: N passed` 汇总行。`Test Suites: 1 failed` 与 `Tests: 1652 passed` 可以同时成立。
3. 凡在测试里改写**全局对象**属性(`window.*`、`navigator.*`、`Math.*`),还原一律走 `Reflect.getOwnPropertyDescriptor` + 原样 `defineProperty`,或干脆用 `jest.spyOn` 让框架负责还原;手写 `defineProperty` 时必须显式给出 `writable`/`get`/`set` 中与原描述符对应的每一项。
4. 复验端口必须换新端口。本轮两次踩到「上一版 server 仍占着端口」,`pkill -f "PORT=3101 node ..."` 匹配不到(`pkill` 看不到 env 前缀),结果拿到修复**前**的假 404。做法:记录 PID 并按 PID 收服,复验换端口。
**来源**:`package.json`(`postbuild`)、`Dockerfile:5-9,53-57`、`Jenkinsfile:350-355`、`scripts/accessibility/axe-node-count.mjs`(`bgMismatch` 判据)、`config/test/lighthouserc.json`、`src/components/sections/hero-particle-field-engine.test.tsx:364-382`、`docs/acceptance/2026-09-23-gates/final-verdict.md` §1/§3(N-9、N-12)。
### 5.28 「探针看不见」不等于「没有问题」:一条 CSP 违规对两个审计一显一隐,而自造探针的正向对照自己就是假的
**背景**:2026-09-23 收尾复测中,`/contact` 是 9 个 URL 里唯一 Lighthouse best-practices 非满分的页(96),失败项只有 `inspector-issues`。定位过程中连踩三个方法学坑,每一个都足以把结论做成假的。
**坑一:同一个缺陷,两个审计看见的程度不同。** 同一份报告里 `errors-in-console` 是 **score 1 / items 0**(判为干净),而 `inspector-issues` 是 **score 0 / items 1**(`{"issueType":"Content security policy"}`)。CSP 违规走 DevTools 的 issue 通道,**不进控制台**,所以任何"看控制台"的门禁对它天然失明。更糟的是 `inspector-issues` 的 `subItems.items` 是空数组——**它不告诉你是哪条指令、哪个资源被拦**,光凭报告无法定因。
**坑二:自建探针采到「0 违规」,但探针在这一类上是聋的。** 用 Playwright 挂 `securitypolicyviolation`(`document` + `window` 双挂、`addInitScript` 注册)跑修复后的页面得到 0 条——这个 0 当时**不能**当结论,因为同一脚本里的正向对照也采不到:`page.evaluate(() => (0,eval)('1+1'))` 和 `addScriptTag({content: "(0,eval)(...)"})` 注入的代码经 CDP 进入,**Chromium 不对调试器注入的代码施加 `'unsafe-eval'` 判定**,既不抛错也不报违规。校准实验同时证明监听器**对子资源类是有效的**(`connect-src`、`script-src-elem` 两条违规被 document/window 都采到)。⇒ **正向对照必须在同一违规类别上成立**;跨类别采到事件,不等于这一类可采。最终结论因此建立在**发现问题的同一仪器**(`inspector-issues`)的前后 A/B 上,而不是自造探针的 0。
**坑三:真凶是第三方库的"能力探测",不是本站代码。** 根因是 Zod 4.4.3 的 `allowsEval`(`node_modules/zod/v4/core/util.js:145-162`):用 `new Function("")` 试探能否 eval,`try/catch` 吞掉异常,但浏览器仍就地点报一次违规。库在源码注释里把这件事写得很明白:*"strict CSPs report the caught `new Function` as a `securitypolicyviolation` even though the throw is swallowed"*。命中面与观测严丝合缝:全仓只有 1 个**活的**客户端模块在浏览器侧求值 zod ⇒ 9 个 URL 里只有它报。**排除法**同样重要:`https://www.google.com/g/collect`(不在 `connect-src` 白名单)在 gtag.js 里出现 17 次,看起来极像元凶,但真实浏览器加载的三条 GA4 hit 全部落在已放行的 `www.google-analytics.com` 并返回 **204**——「源码里出现的域名」≠「实际请求」,`ad_storage:'denied'` 压制了 Ads 侧目的地。修法用库认可的开关 `z.config({ jitless: true })`;它之所以**零行为变化**,是因为 CSP 下该探测本就返回 false、依赖它的 JIT 快路径(`schemas.js:971-972`)修复前后同样关闭——这一条是读源码确认的,不是猜的。
**教训(管理归零)**:
1. 报告「0 违规 / 0 问题」之前,必须先证明**同类**探针能采到非零;否则该 0 只能记作「未证」,不得记作「已验」。
2. 修一个由第三方库探测噪声引起的问题,**必须用发现它的同一仪器做前后 A/B** 来证明因果;换仪器(自造探针、curl、静态 grep)只能作旁证。
3. 收紧 CSP 后要**点名验证每一个第三方 hit 目的地**:从真实浏览器读 `requestfailed` 与状态码,而不是从库源码里数域名。GA4 这类"被吞掉失败"的链路,静默丢数据不会报错。
4. 一个缺陷若同时存在多个观测面(console / issue / 网络),门禁至少要有一个观测面覆盖它。本轮的 CSP 违规只有 `inspector-issues` 看得见,而它恰恰不在 `lhci` 的断言清单里——是人工读报告才抓到的。
**来源**:`node_modules/zod/v4/core/util.js:145-162`、`node_modules/zod/v4/core/schemas.js:970-972`、`node_modules/zod/v4/core/core.d.ts:66-70`(`jitless` 为公开带类型配置)、`lighthouse-reports/contact-2026_09_23_04_54_06.report.json`(修复前)、`src/app/(marketing)/contact/contact-content-v3.tsx:32`、`src/components/analytics/GoogleAnalytics.tsx:97`、`next.config.mjs:31`、`docs/acceptance/2026-09-23-gates/final-verdict.md` §3(N-11、N-13)。
### 5.29 一条恒红的用例比一条恒绿的更诚实,但仍会被人读成「UI 坏了」
**背景**:2026-09-23 收尾时对最终树补跑视觉回归**只读比对**(`npm run test:visual:all -- --update-snapshots=none`,规范模式 dev server,5 project × 25 例)。结果 **EXIT=1,21 失败 / 104 通过**。第一读是「视觉层判红 ⇒ UI 可能坏了」,逐条读日志后完全反转:**没有任何一例是像素不符**,21 例全是测试装配自身的缺陷,且其中 20 例**从未绿过**。
**坑一:宽松选择器 + `.first()` 命中反垃圾蜜罐。** `visual-regression.spec.ts:148` 写的是 `input[type="text"], input[type="email"]`,而 `/contact` 表单里 DOM 顺序最靠前的那个 `type="text"` 是 `<input type="text" tabindex="-1" name="website" aria-hidden="true" aria-label="honeypot">` —— 一个**按设计隐藏**的机器人陷阱。于是断言变成「期望这个隐藏字段可见」⇒ `Received: hidden`,5/5 project 恒定失败,`input-default.png` 也因此从未被创建。**一般规则**:任何表单页上用裸类型选择器 + `.first()` 都在赌DOM 顺序;反垃圾/占位/预加载字段专门就是"存在但不可见"的,天然赢这个赌。写选择器时排除 `[aria-hidden="true"]`,或直接给被测控件加 `data-testid`(同档 `:154` 创始人语录用例已经是这么做的)。
**坑二:`element(s) not found` 与「UI 回归」是两件事。** `:130` 卡片用例在 `/products` 上找 `[class*="card"]` —— 该页没有任何 class 含这个子串(Tailwind 的类名是形状描述,不是语义标签)。这类失败长得像"组件不见了",实为**断言对象根本不存在**;把它记成 UI 缺陷会直接误导修复方向。
**坑三:页面清单与基线集合可以长期不一致。** `VISUAL_TEST_PAGES` 列了 `/products/erp-upgrade` 与 `/about/brand`,而这两个名字在**全部 5 个 project 目录**下都没有 PNG ⇒ 只读模式判红、默认模式(`updateSnapshots` 缺省为 `'all'`)**会静默创建**。最糟的中间态正是"列了但从不比对":看起来覆盖面 16 页,实际 14 页。
**坑四(同族复发,我自己的 harness)**:这一轮用后台命令跑,包装体是 `{ npm run ...; echo "VISUAL_EXIT=$?"; date; } > log 2>&1`。**命令组的退出码取自最后一条命令(`date`)**,于是框架通知我「completed (exit code 0)」,而真实门禁是 EXIT=1。唯一救回来的是我在日志里显式写了 `VISUAL_EXIT=$?`。**规则**:包装门禁命令时末位必须是 `exit $rc`;否则**判据只能取日志里的结果行,绝不能取通知里的 exit code**。这与 §5.27(管道吞退出码)、§5.28(探针的 0 未经同类对照)是同一族错误的第三种形态。
**教训(管理归零)**:
1. 门禁判红后**先分类再定性**:把失败按「断言层 / 参照层 / 渲染层」分开数,`0 例像素不符`这一条就把"UI 回归"整个假设否掉了。本轮因此能同时给出两个不矛盾的结论——**现存参照层面全绿(含 `/contact` 全页,等于独立确认了 N-11 的 `jitless` 改动无视觉外溢)**,装配层面 21 例坏测试待修。
2. **不做无法复验的门禁改动**:修选择器只会把「断言失败」换成「参照缺失」,仍红,而写参照需要授权 ⇒ 于是只记录精确修法与判据,不动脚本。这与"禁止半截实现"不冲突:交不出绿的时候,交**可执行的修法 + 归零判据**(复跑后失败数须从 21 归零,而不是换一个错误类别)。
3. 恒红用例其实比恒绿用例**便宜**:它不需要人看图就知道有问题。真正危险的是本轮 Lighthouse 那类情形(§5.28)——CSS 全 404 还 exit 0。
**来源**:`docs/acceptance/2026-09-23-gates/visual-readonly-finaltree.log`、`e2e/visual-regression.spec.ts:27-42,130,148`、`src/app/(marketing)/contact/contact-content-v3.tsx`(蜜罐字段)、`e2e/playwright.config.ts:21-25`(`snapshotDir` / `outputDir` 决定只读比对确实不写基线)、`docs/acceptance/2026-09-23-gates/final-verdict.md` N-15。
### 5.30 覆盖率门禁的 `collectCoverageFrom` 划到哪,缺陷就活在哪儿
**背景**:2026-09-23 收尾阶段对 `src/app/**` 做了一次专项审查(subagent 扫描 + 主代理逐条读源码复核)。选它的理由很朴素:`config/test/jest.config.js` 的 `collectCoverageFrom` 只覆盖 `src/components/**`、`src/hooks/**`、`src/lib/**/*.ts`,**不含 `src/app/**`** ⇒ 全部 route handler 与页面都在棘轮之外,改坏了也不会有任何门禁变红。结果这一处找出 **3 个 P1**。
**三个缺陷是同一个形状的三个副本。** 项目里已经有正确的守卫抽象 `requirePermission()`(`src/lib/permissions.ts:71-86`,失败会真的 return response),但另有 **7 个 handler 复制粘贴** `authenticateRequest` + 内联角色数组检查。复制的那份里:`admin/stats` 掉了角色检查,只要"有会话"就返回**跨全部模型**的最近内容标题(含草稿);`auth/refresh` 补了账号状态校验(B-7),但**每个请求真正走的是另一条路**(`checkUserPermission` 从不读 `User` 行)⇒ 停用账号后旧令牌还能用满 24h;`cms/draft/enable` 为 B-6 写了 `isInternalRedirect()`,但把它定义成**文件内私有函数**,于是姊妹路由 `cms/draft/disable` 原样 `new URL(body.redirect)` 向外 307。**一个修好的缺陷因为修法不可复用,在旁边复活了。**
**教训(管理归零)**:
1. **安全抽象必须可导入**。给某条路由打补丁时,若校验逻辑是新写的私有函数,同一目录树里的姊妹路由 100% 不会受益。要么提到 `src/lib/**`,要么当场改完所有调用点。
2. **"某个已修缺陷的同类还在别处"是可查的**:`grep` 那个修复引入的函数名,命中数 < 应有命中数就是线索。本轮 `isInternalRedirect` 命中 1 处(应为 2),一眼可见。
3. **修复必须带 RED 实测**,且**先看它能不能变红**。三处修复各做了一次"临时移除 guard ⇒ 相应用例判红 ⇒ 恢复转绿"的探针:`disable` 的 4 条载荷在修复前实测收到 `307`(证明开放重定向真实可利用,而非理论风险),`permissions` 的 3 条用例在删掉状态校验后 `3 failed, 20 passed`。**没做这一步,"我修好了"和"我改了段看起来像修好的代码"无法区分。**
4. **改共享鉴权函数会打爆测试桩,这是信号不是障碍**:在 `checkUserPermission` 前置一次 `prisma.user.findUnique` 后,恰好 4 个既有断言变红——因为 `permissions.test.ts` 的 prisma 桩里没有 `user`。正确处置是**补桩 + 加新用例**,而不是把校验挪到一个不查库的位置。
5. **数值口径要绑定被执行的那批文件**:修后 `test:unit` 是 133 套件 / 1691 例,而文档里的 1677 来自 `test:coverage`(套件集不同)。**两个数不可相减**,否则会把"配置差异"读成"本轮净增 14 例"。
**来源**:`config/test/jest.config.js`(`collectCoverageFrom`)、`src/lib/permissions.ts:71-86`、`src/app/api/auth/refresh/route.ts:23-25`、`src/app/api/cms/draft/{enable,disable}/route.ts`、`src/app/api/admin/stats/route.ts:9-10,49-59`、`src/lib/auth.ts:7`、`docs/acceptance/2026-09-23-gates/final-verdict.md` N-17..N-21。
### 5.31 「谁来测门禁」:安全头检查里三条永远不会成立的断言,和一个声明测产物却实则打线上的脚本
**背景**:2026-09-23 验收尾声,本轮已经反复出现"门禁在错误的对象上给出可信的绿"(N-9 CSS 全 404、N-13 CSP 看不见、N-17 裸 `return` 伪装通过、N-15 恒红坏用例)。于是把同一套问题**对准门禁脚本本身**——因为 `scripts/**` 按构造没有任何自动化保护。派 subagent 扫 `scripts/**` + `Jenkinsfile`,主代理逐条读源码复核,命中四处恒不成立。
**四个坑(全在 `scripts/utils/check-security-headers.ts`,它是 `test:all` 的一环)**:
1. **算完就丢**:`checkCookies` 老老实实算了 `hasHttpOnly` / `hasSecure` / `hasSameSite` 三个标志,最后一行 `status: 'pass' as const`——**写死的字面量**。于是 `cookieFailed` 恒为 0。
2. **算了但没人看**:汇总判定 `hasFailures = headerChecks.some(c => c.status === 'fail')`,**压根不看 cookieChecks**。第 1 条即使改对,也仍然进不了退出码。这两条是"死断言"最省事的形态:**数据被完整地采集、展示、然后被判定逻辑遗忘**。
3. **两个分支完全相同的三元**:`return val ? 'warn' as const : 'warn' as const;`。它不会报错,只会永远返回 warn。
4. **最重要的判据反而最宽松**:缺 CSP 只 `warn`,而同文件里 `X-Content-Type-Options` 缺失判 `fail`。**同类判据的严重度不一致,往往就是漏判的位置**。
**外加一条结构性问题**:脚本 `parseArgs()` 的默认值是**线上域名**,`package.json` 调用时不带 `--url`,而 `Jenkinsfile` 在那一行前面打印的是「检查**本分支构建产物**的安全响应头」。**声明与行为相反**——CI 验的是已部署站点,改 `next.config.mjs` 里的头不会让 CI 变红。这条不是我推出来的,是**正向对照逼出来的**:把修好的门禁第一次指向本地 standalone 产物,它红了,唯一红项是 HSTS——因为 **HSTS 由 Nginx/CDN 在边缘注入**,Node 产物根本没有。也就是说这个门禁**结构上就不可能在分支产物上通过**,所以它的默认值只能指向线上,所以那个打印永远是假的。
**教训(管理归零)**:
1. **每个门禁修完,至少跑一次"它必须变红"的输入**。本轮做法是拿 `python -m http.server` 起一个没有任何安全头的本地服务:负向 ⇒ `EXIT=1 / 4 项 fail`;再指真实产物:正向 ⇒ `EXIT=0 / 0 失败`。**只有负向能证明第 1、2、4 条真的被修好了**——否则"我改了判定逻辑"和"判定逻辑仍然看不见任何东西"在输出上一模一样。
2. **正向对照和负向对照缺一不可,且顺序常常反直觉**。本轮先做负向(以为稳了),是**正向**暴露出 HSTS/边缘注入这个更深的问题。凡"改完在干净对象上仍判红",先怀疑门禁的**作用对象假设**(它以为自己在测什么),而不是先怀疑干净对象脏了。
3. **审计一个门禁,先看它的输入从哪来**:默认 URL 指向线上、默认端口指向 :3000(可能被别的服务占着)、默认目录为空时 `violations=0` 直接通过。**空输入 = 空转通过**是这类脚本最常见的形状;`crawl-routes.mjs` 那种 `paths.length === 0 → exit 2` 的写法才是对的。
4. **脚本存在 ≠ 脚本在跑**。`lighthouse-runner.js`(连 `module.exports` 都没有)、`coverage-trend.js`(读的字段在另一个文件里,必抛)等三个脚本没被任何 npm script / Jenkinsfile 引用。要么接入要么删掉——留着会让人以为有保护。
**来源**:`scripts/utils/check-security-headers.ts:60-68`(原 `status: 'pass' as const`)、`:137`(原同分支三元)、`:284`(原 `hasFailures` 只看 headerChecks)、`:34`(默认线上 URL)、`package.json:48,56`、`Jenkinsfile:293-294`、`next.config.mjs`(HSTS 不在应用头里)、`scripts/accessibility/crawl-routes.mjs`(正确的空输入写法)、`docs/acceptance/2026-09-23-gates/final-verdict.md` N-22/N-23/N-24。
## 6. 代码组织
### 6.1 组件版本膨胀
- **问题**:组件目录存在多个版本迭代(detail-v2/, detail-v3/),造成混淆和冗余。
- **根因**:多次重构未清理旧版本,缺乏组件生命周期管理。
- **方案**:重构完成后立即删除旧版本目录;使用 `_archive/` 归档历史版本,并排除在 TypeScript 编译之外。
- **来源**:`CLAUDE.md` Archiving Convention。
### 6.2 文档与代码不同步
- **问题**:`docs/` 目录中部分文档(如组件指南、API 文档)与实际代码不一致。
- **根因**:文档更新未纳入代码审查流程。
- **方案**:文档变更必须与代码变更在同一 PR 中审查;`CONTEXT.md` 作为共享语言文档,与领域模型同步更新。
- **来源**:`AGENTS.md` 文档同步要求。
---
## 7. App Router 路由行为
### 7.1 `loading.tsx` 会吞掉子动态路由的 `notFound()`
- **问题**:`/services/[id]`、`/products/[id]`、`/solutions/[id]`、`/news/[slug]` 等动态路由对未知 slug 调用 `notFound()` 后,仍然返回 HTTP 200(软 404)。
- **根因**:`(marketing)/loading.tsx` 在路由组层级创建了 Next.js Loading/Suspense 边界,该边界会捕获子页面抛出的 `NEXT_NOT_FOUND` 错误,导致 `not-found.tsx` 无法被渲染,HTTP 状态也保持为 200。
- **方案**:移除 `(marketing)/loading.tsx`;如需加载状态,改为在页面内部使用 `<Suspense>` 包裹特定慢加载区块,而不是在路由组层级全局添加 `loading.tsx`。
- **验证**:`curl -I http://localhost:3000/services/unknown` 返回 `404 Not Found`;`e2e/p5-edge-cases.spec.ts` 404 相关用例全部通过。
- **来源**:2026-07-25 dogfood 修复,`dogfood-output/report.md`。
---
## 更新记录
| 日期 | 更新内容 | 来源 |
|------|---------|------|
| 2026-07-07 | 初始创建,从 project_memory.md 和 docs/ 中提取 | 现有文档 + 记忆文件 |
| 2026-07-25 | 增加 App Router `loading.tsx` 与 `notFound()` 交互的经验教训 | dogfood 修复 |
| 2026-07-26 | 增加 E2E 文本断言同步、视觉回归基线更新两条经验教训 | dogfood 后续验证 |
| 2026-07-27 | 增加 Playwright storageState 路径、Firefox 全页导航竞态两条经验教训 | 全量 E2E 回归失败排查 |
| 2026-09-21 | 增加 §3.4(`aria-hidden` 不豁免对比度)与 §5.10–§5.13(触摸目标 AA/AAA 档位、mobile project 视口、`<title>` 帧延迟、品牌审计判过期需证据)| 2026-09-21 验收复核(A-12 / A-17 / P1 品牌审计)|
| 2026-09-21 | 增加 §5.14–§5.15(视觉基线不得绑定 dev/prod server、视觉与写库用例并发的缓存污染)| 2026-09-21 验收视觉回归 83 例失败根因 |
| 2026-09-21 | 增加 §5.16–§5.18(dev 指示器 shadow DOM 穿透污染严格定位、hydration 前指针事件被丢、StrictMode 双跑使 Cookie 同意条在 dev 下永不渲染)| 2026-09-21 验收功能 E2E 68 例失败与 A-11/A-12 门禁三重失效根因 |
| 2026-09-22 | 增加 §5.19(同意条 2s 定时器 × 跨引擎空 storageState ⇒ 视觉基线竞态,34 例只失败在 firefox/webkit project);§5.16 的方案由 `devIndicators:false` 改写为 `e2e/fixtures.ts` 全局夹具并记录该反方案为何无效 | 2026-09-21 验收复跑:功能 1052 通过 / 视觉 34 例竞态定位 |
| 2026-09-22 | 增加 §5.21(品牌审计 spec 的 catch 吞断言 + 错误前提,及其闭合:10 处改写 + 4 project 124 通过)与 §5.22(`/products/erp-upgrade` 的 `text-brand-ink/10` 水印数字被四道门禁同时漏过 ⇒ 双引擎双主题 axe 节点计数首轮 4/4/4/4,修复为 `text-brand-ink/80` 后归零;口径教训:节点数 ≠ 规则数,新页必须进 Lighthouse URL 表与视觉回归路由表)| 2026-09-21 验收放行条件②⑤复跑与门禁可信度审查 |
| 2026-09-22 | 增加 §5.23(两类自伤缺陷:证据脚本在 `routeCount=0` 的空集合上判 `every` 为真而打印 PASSED=true ⇒ 计数型门禁必须同时断言分母;门禁脚本 `process.exit()` 跳过 `finally` 且只 kill 包装进程 ⇒ `next-server` 孤儿占住 :3000,被 `reuseExistingServer` 静默复用,把 1080 例套件拖成 53.6 分钟 / 805 通过 / 263 失败(205 个是 `page.goto` 超时)⇒ 证据链每步前后打印端口占用数)| 2026-09-22 最终树串行证据链 chain2 失败复盘 |
| 2026-09-22 | 增加 §5.24(⑤ 的路由清单由 sitemap 29 扩成 sitemap ∪ 预渲染产物 ∪ BFS = 35,扩目录首轮即 `passed=false`:`/_not-found` 的 h1 用 `opacity-20` 破 AA、`/_global-error` 因缺 `global-error.tsx` 由 Next 内置壳顶替而无 `lang` 且深色丢令牌;另归零三例「隔离必过 / 满负载必挂」的 E2E —— p4 把 `waitForTimeout` 计入加载耗时、p3 两段式 `isVisible`→`boundingBox`、uj-11 与 p2 的 hover 下拉点击竞态。口径教训:门禁覆盖 = 清单来源,sitemap 只覆盖"想被收录的页")| 2026-09-22 chain3 全站 axe 扩目录扫描 + 4 例 E2E 隔离复跑 |
| 2026-09-22 | 增加 §5.21(`p1-brand-visual-audit.spec.ts` 三处 `catch` 吞断言 + `:476` 对纯 `<path>` 图标 SVG 的错误前提,实测「断言失败但用例 ✓」;给出「日志软失败 × 用例状态」配对的低成本检假绿法);§5.20 补记第二个坑:把死样式点亮后 `hover:bg-brand/20` 浅色合成 4.18:1 破 AA,而 axe / `check:contrast` 均看不见,最终改 `bg-brand-soft`,并把修正后的类名与行号写回 | 2026-09-22 最终树全量运行日志复核 |
| 2026-09-22 | 增加 §5.25(Lighthouse 门禁里 3 条恒不成立的死断言 `autocomplete-valid`/`presentation-role-conflict`/`svg-img-alt`:以 21 份报告的 175 个审计 id 并集 + `@lhci/cli` 自带 `lighthouse@12.6.1` 的 64 个 a11y 审计文件双源定性;删断言前先做 tag 覆盖探针,发现 `presentation-role-conflict` 属 `best-practice` tag、现有 axe 门禁根本不跑 ⇒ 处理为「删死断言 + 在 axe harness 加规则级 `runOnly` 并断言每页 `extraRulesChecked===3`」;另记录一次 2 秒 `type-check` 的正/负对照自证与 `AGENTS.md` lint 计数 105→106 的纠偏)| 2026-09-22 chain6 Lighthouse 日志复核 + chain7 复验 |
| 2026-09-23 | 增加 §5.26(文档同步轮:四处「句式完整但指向不存在之物」的假事实——`NEXT_DEV_INDICATORS=off` 反方案被写成了现行机制、`npm run preview` 仍写作 `npx serve`、"images unoptimized ⇒ 静态导出限制"的因果过期、`package.json` 的 `check:axe*` 曾指向当时缺失的 `scripts/accessibility/`(同日已平移落地并接进 CI ♿♿ 阶段);外加 skipped≠覆盖的 E2E 目标口径缺陷与「带文件名的句子必须可被一条 grep 证伪」的归零清单)| 2026-09-23 文档—代码一致性回扫(只改文档;同一轮内 `scripts/accessibility/` 由并发作业补上,故本节的每条断言都注记了复核时刻)|
| 2026-09-23 | 增加 §5.27(两条「门禁在错误对象上给出可信绿」的同族事故:① standalone 产物不含 `dist/static`,本地 `npm run lighthouse` 与手工 `check:axe` 在全站 CSS 404 的裸 HTML 上出数 —— axe 因 `bgMismatch` 哨兵诚实报红,Lighthouse 无此前置断言故 exit 0 且 perf 99-100,修复为 `postbuild` 不可绕过钩子,修后对比度失败节点 10→0、a11y 92-97→9/9 全 100;② 补覆盖率的子代理测试用 `Object.defineProperty` 还原全局 `requestAnimationFrame` 时漏写 `writable`(该描述符缺省即 `false`),使属性变只读、`afterEach` 的 `mockRestore()` 抛错 ⇒ 套件 failed to run 而汇总行仍打印 `Tests: 1652 passed`。给出「单独跑套件看 `Tests: 0 total`、再用 `-t` 逐例收窄」的定位路径,与「子代理产物必须逐档隔离复跑且以 EXIT 码为准」的归零条款)| 2026-09-23 系统性复核收尾(N-9 / N-12,证据见 `docs/acceptance/2026-09-23-gates/final-verdict.md`)|
| 2026-09-23 | 增加 §5.28(`/contact` 的 CSP 违规定位三坑:① 同一缺陷对 `errors-in-console`(1/0 items)与 `inspector-issues`(0/1 item)一显一隐,issue 通道不进控制台;② 自造 Playwright 探针采到「0」但正向对照同为 0——CDP 注入的代码不受 `'unsafe-eval'` 判定,而 `connect-src`/`script-src-elem` 类经校准证明确可采,故**正向对照须同类**、未经对照的 0 记为「未证」;③ 真凶为 Zod 4.4.3 `allowsEval` 的 `new Function` 探测(库源码注释自述该现象),以带类型的公开开关 `z.config({jitless:true})` 修掉并经 `inspector-issues` 同仪器 A/B 确认 BP 96→100,同时排除「`www.google.com/g/collect` 被拦」的假定因——GA4 实测三条 hit 全落在已放行的 `www.google-analytics.com` 且返回 204)| 2026-09-23 收尾复测 N-11/N-13(`docs/acceptance/2026-09-23-gates/final-verdict.md` §3、`lighthouse-reports/contact-2026_09_23_04_54_06.report.json` 与修复后重跑)|
| 2026-09-23 | 增加 §5.29(视觉回归只读比对 21 例恒红的四类装配坑:① 宽松选择器 `input[type=text],input[type=email]` + `.first()` 命中按设计隐藏的**反垃圾蜜罐** ⇒ 期望隐藏字段可见,5/5 project 必失败且基线从未创建;② `[class*="card"]` 在 /products 零匹配,`element(s) not found` 被误读成 UI 回归;③ `VISUAL_TEST_PAGES` 列了 `/products/erp-upgrade`、`/about/brand` 但 5 个 project 全无 PNG ⇒ 只读判红、默认模式静默创建;④ 后台命令组 `{ cmd; echo X=$?; date; }` 的退出码取自 `date`,框架报 exit 0 而真实门禁 EXIT=1。管理归零:失败先按断言/参照/渲染三层分类,`0 例像素不符`即可否掉 UI 假设;不能复验的门禁脚本只记录精确修法不动手 | 2026-09-23 视觉回归只读比对(EXIT=1,21 failed / 104 passed)+ N-15 取证 |
| 2026-09-23 | 增加 §5.30(覆盖率门禁 `collectCoverageFrom` 不含 `src/app/**` ⇒ route handler 全在棘轮外,专项审查找出 3 个 P1:`admin/stats` 只验会话即返回跨模型草稿标题、`checkUserPermission` 从不读 User 行使停用账号旧令牌 24h 内仍可用、B-6 的 `isInternalRedirect` 定义成私有函数致姊妹路由 `draft/disable` 开放重定向复活。管理归零:安全抽象必须可导入、修完要 grep 函数名看命中数是否等于应有数、每处修复做「临时移除 guard ⇒ 用例判红 ⇒ 恢复转绿」的 RED 实测、改共享鉴权函数打爆测试桩是信号不是障碍 | 2026-09-23 `src/app/**` 盲区专项审查(N-17..N-21)+ 三项修复与 RED 探针 |