# 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` 或 `` 标签。 - **来源**:`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 `` / `::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 的 `` 在路由 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 探针 |