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

88 KiB
Raw Blame History

Lessons Learned(项目经验教训)

记录跨任务、跨阶段的工程经验教训,避免重复踩坑。按类别组织,定期更新。

目录


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 失败。
  • 问题:给 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 里写:

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 探针