- 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 产物装配与部署形态。
88 KiB
Lessons Learned(项目经验教训)
记录跨任务、跨阶段的工程经验教训,避免重复踩坑。按类别组织,定期更新。
目录
1. 技术选型与依赖
1.1 外部字体服务导致白屏
- 问题:依赖 Google Fonts 等外部字体服务,网络加载失败时页面白屏。
- 根因:字体加载阻塞首次渲染(FOUT/FOIT 未妥善处理)。
- 方案:所有字体使用本地文件(
src/app/fonts/),禁止外部字体 CDN。 - 来源:
2026-03-04部署事故,project_memory.mdHard 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.mdHard Constraints。
2. 构建与部署
2.1 直接复制参考网站代码
- 问题:从参考网站直接复制 HTML/CSS/JS 到项目中,导致样式冲突和技术债务。
- 根因:参考网站的样式体系(Bootstrap/其他框架)与项目 Tailwind 体系冲突。
- 方案:仅参考设计理念和布局,所有代码手动基于 Tailwind 实现。
- 来源:
project_memory.mdLessons Learned。
2.2 静态导出限制
- 问题:
output: 'export'模式下,next/image需要unoptimized,某些 API 路由不可用。 - 根因:Next.js 静态导出对 Server Components 和 API Routes 的限制。
- 方案:移除
output: 'export',改用standalone或混合模式;图片使用unoptimized或<img>标签。 - 来源:
CLAUDE.mdBuild 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.mdLessons Learned;后续推广为全局规则:所有光晕效果不透明度降低 30-50%。
3.2 未使用的大文件残留
- 问题:项目中残留 4.2MB 的 AoyagiReisho 书法字体文件,增加页面加载时间。
- 根因:早期设计尝试引入书法字体,切换方案后未清理。
- 方案:定期检查
public/fonts/和src/app/fonts/中未使用的字体文件,及时删除。 - 来源:
project_memory.mdLessons 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"、CSSopacity)可让装饰文字豁免对比度」。实测 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 测试的文本断言未同步更新,导致测试假阴性(功能正常但测试报错)。
- 方案:
- 将 UI 文案断言改为稳定标识(如
data-testid、角色/aria 属性)或语义化选择器,减少对文案的强依赖。 - 若必须使用文本,在修改文案时同步搜索并更新
e2e/中的对应断言。 - 建立「文案变更检查清单」,将 E2E 文本断言纳入审查范围。
- 将 UI 文案断言改为稳定标识(如
- 来源:2026-07-26 dogfood 后续验证,
e2e/nav-dropdown.spec.ts修复。
5.4 视觉回归基线需在 UI 变更后主动更新
- 问题:dogfood 修复后(列表卡片链接、案例筛选、联系表单反馈、新闻占位图等),视觉回归测试大面积失败。
- 根因:上述修复属于有意的 UI/交互变更,导致已有快照与当前渲染不一致;若不更新基线,后续所有构建都会报告假阳性。
- 方案:
- 任何涉及视觉/布局的修复完成后,运行
npx playwright test visual-regression.spec.ts --update-snapshots更新全项目视觉基线。 - 更新前通过浏览器截图/人工复核确认差异符合预期,避免将未发现的回归写入基线。
- 将快照变更作为独立提交或 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。 - 方案:
- 配置文件中的相对路径应基于配置文件自身目录,使用
path.resolve(__dirname, 'storageState.json')。 - 修改
storageState、快照目录、报告目录等路径时,必须同时验证npm run test与从项目根目录直接运行npx playwright test --config=e2e/playwright.config.ts两种方式。 - 将
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 评估极易进入等待导航的竞态。 - 方案:
- 将涉及两次独立全页导航的测试拆分为两个独立 test case,减少单次测试内的导航次数。
- 在 Firefox 等容易触发竞态的浏览器中,对必须触发的全页导航链接点击,改用
page.evaluate(() => element.click())绕过 locator 的导航状态检查。 - 优先使用
data-testid或语义选择器定位,点击后通过page.waitForURL()显式等待目标 URL,而非依赖 locator 的隐式等待。
- 来源:2026-07-27
e2e/p2-functional-e2e.spec.tsFooter 链接测试修复。
5.7 封版阶段依赖升级需独立评估,避免 --force 一次性修复
- 问题:
npm audit fix --force无差别升级依赖到最新版本,导致@lhci/cli被降级到使用 git+ssh 拉取 Lighthouse 的古老版本,且eslint-config-next@16与当前eslint@8不兼容。 - 根因:
--force会执行 major version 升级,引入 breaking change 风险。 - 方案:
- 封版阶段不执行
--force修复,仅执行向后兼容的npm audit fix。 - major version 升级应作为独立专项任务,在封版前或上线后安排。
- 修复前备份
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)。 - 方案:
- 新增/修改 API 后同步审查性能测试脚本,确保请求体格式、认证/限流机制与接口契约一致。
- 将对依赖外部服务的接口(如
formsubmit.co)的压测排除在压力测试之外。 - 重写后的脚本仅对本地静态页面进行 GET 压力测试,避免外部依赖干扰。
- 来源:2026-08-12 封版验收阶段 5,stress-test.js 修复。
5.9 测试摘要文件需在迭代中保护,避免被失败运行覆盖
- 问题:k6 测试运行时预览服务器被停止,后续重新运行写入的摘要文件包含无效数据(0% 通过率),覆盖了之前成功的运行结果。
- 根因:k6 的
handleSummary输出路径与--summary-export路径不一致,且无版本保护机制。 - 方案:
- 使用
--summary-export指定稳定路径(如tests/performance/),与脚本内handleSummary路径统一。 - 每次运行前备份前一次摘要文件。
- 服务器重启后重新运行性能测试前,确认端口可用性。
- 使用
- 来源: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-mobileproject 在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.tsTC-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-portalshadow 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首屏渐变)真的出现在渲染结果中,其余都落在没有任何路由渲染的组件(仅被 barrelindex.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" 英文文本」)。也就是说:门禁断言真的失败了,测试结果却是绿的。 - 根因(两层,独立成立):
- 该 spec 有 3 处把断言写在
try里、catch只console.log(:501、:549、:750),断言失败被降级成日志;另有:507-518的兜底——「没找到任何 Logo 元素」时只要页面存在任意img/svg就通过。 - 被吞掉的断言
:476expect(svgTextContent.length).toBeGreaterThan(0)前提本身就是错的:纯<path>图标 SVG 没有<text>节点,textContent合法为空。于是「SVG 里有中文品牌名」这条检查在现行 Logo 形态下永远为假,只能靠 catch 跳过、再由img分支(/logo.svg)判定通过。
- 该 spec 有 3 处把断言写在
- 判定方法(低成本,可复用):把「
allure-results/*attachment.txt或 stdout 里出现Error checking」与「同一 test 的最终状态」配对——日志里有断言失败 + 测试为 ✓ ⇒ 假绿。这比对断言代码本身快得多,且能覆盖「断言写在循环/选择器分支里、实际从未执行」的情况。 - 影响:
p1-brand-visual-audit.spec.ts正是 §7-4「品牌文案 vsFORBIDDEN_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 英文品牌名的既予豁免 +8d3bd72unify 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-contrastserious 节点,全部落在同一处:/products/erp-upgrade的步骤大数字text-6xl font-bold text-brand-ink/10(erp-upgrade-content-v2.tsx:300)。 - 根因(为什么四道门禁都看不见它):口径各异 ⇒ 没有任何一道覆盖「任意 alpha 组合 × 真实底色 × 节点计数」这条轴。
check:contrast只断言 REQUIRED_GROUPS 里声明的令牌配对,text-brand-ink/10不在册;check:headings与品牌审计 spec 只看文本内容,不看像素;- Lighthouse 的 URL 白名单只有 7 个入口页,
/products/erp-upgrade根本不在其中(零覆盖); 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-16URL 列表;scripts/utils/check-color-contrast.tsREQUIRED_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 61088next-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 例全过):
p4-performance-a11y.spec.ts:41把await page.waitForTimeout(1500)写在计时区间内 ⇒ 5000ms 预算实际只剩 3500ms 给导航,dev 服务首次访问要现场编译,必然破线(实测 /about 6069ms)。修复:startTime前加一次waitUntil: 'commit'预热、把 settle 等待移到计时之外。修后同一断言下首页 107ms、关于 232ms。p3-compatibility.spec.ts:114先isVisible()再boundingBox()是两次独立解析,节点被替换时第二次返回 null。修复:expect.poll(() => boundingBox()).not.toBeNull({timeout: 10000})。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-inkh1,属本站资产,改/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 的主体。「全绿」因此并不等于「全断言」。
教训(管理归零):
- 文档里每个带文件名 / 脚本名 / 环境变量名 / 配置项名的句子,都要能被一条 grep 或一次
node -e "require('./package.json')"证伪;写不出取证命令的句子降级为"待核实"。 - 反方案与历史记录不得被后一轮编辑改写成正方案(§5.16 是历史,
docs/testing.md把它写成了现行机制)。做法:历史只留在docs/lessons-learned.md,机制描述只留在CLAUDE.md/docs/testing.md,且引用同一处行号。 - 「skipped 不算失败」≠「skipped 算覆盖」。任何按标签聚合的门禁(
@critical/@smoke)在报告里都要连带披露 skipped 明细与它属于哪个 E2E 目标。 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 的默认值。
教训(管理归零):
- 浏览器侧门禁必须断言自己的前置条件。
bgMismatch这类「样式真的加载了吗」的哨兵,价值不低于它检查的那条规则;lhci那类只断言分数的门禁,缺这个哨兵就会在错误产物上报绿。装配步骤要落在postbuild这种不可绕过的钩子里,而不是写在文档里靠人照抄。 - 子代理交付的测试必须逐档隔离复跑,且以 EXIT 码为准,不看
Tests: N passed汇总行。Test Suites: 1 failed与Tests: 1652 passed可以同时成立。 - 凡在测试里改写全局对象属性(
window.*、navigator.*、Math.*),还原一律走Reflect.getOwnPropertyDescriptor+ 原样defineProperty,或干脆用jest.spyOn让框架负责还原;手写defineProperty时必须显式给出writable/get/set中与原描述符对应的每一项。 - 复验端口必须换新端口。本轮两次踩到「上一版 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)修复前后同样关闭——这一条是读源码确认的,不是猜的。
教训(管理归零):
- 报告「0 违规 / 0 问题」之前,必须先证明同类探针能采到非零;否则该 0 只能记作「未证」,不得记作「已验」。
- 修一个由第三方库探测噪声引起的问题,必须用发现它的同一仪器做前后 A/B 来证明因果;换仪器(自造探针、curl、静态 grep)只能作旁证。
- 收紧 CSP 后要点名验证每一个第三方 hit 目的地:从真实浏览器读
requestfailed与状态码,而不是从库源码里数域名。GA4 这类"被吞掉失败"的链路,静默丢数据不会报错。 - 一个缺陷若同时存在多个观测面(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 未经同类对照)是同一族错误的第三种形态。
教训(管理归零):
- 门禁判红后先分类再定性:把失败按「断言层 / 参照层 / 渲染层」分开数,
0 例像素不符这一条就把"UI 回归"整个假设否掉了。本轮因此能同时给出两个不矛盾的结论——现存参照层面全绿(含/contact全页,等于独立确认了 N-11 的jitless改动无视觉外溢),装配层面 21 例坏测试待修。 - 不做无法复验的门禁改动:修选择器只会把「断言失败」换成「参照缺失」,仍红,而写参照需要授权 ⇒ 于是只记录精确修法与判据,不动脚本。这与"禁止半截实现"不冲突:交不出绿的时候,交可执行的修法 + 归零判据(复跑后失败数须从 21 归零,而不是换一个错误类别)。
- 恒红用例其实比恒绿用例便宜:它不需要人看图就知道有问题。真正危险的是本轮 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。一个修好的缺陷因为修法不可复用,在旁边复活了。
教训(管理归零):
- 安全抽象必须可导入。给某条路由打补丁时,若校验逻辑是新写的私有函数,同一目录树里的姊妹路由 100% 不会受益。要么提到
src/lib/**,要么当场改完所有调用点。 - "某个已修缺陷的同类还在别处"是可查的:
grep那个修复引入的函数名,命中数 < 应有命中数就是线索。本轮isInternalRedirect命中 1 处(应为 2),一眼可见。 - 修复必须带 RED 实测,且先看它能不能变红。三处修复各做了一次"临时移除 guard ⇒ 相应用例判红 ⇒ 恢复转绿"的探针:
disable的 4 条载荷在修复前实测收到307(证明开放重定向真实可利用,而非理论风险),permissions的 3 条用例在删掉状态校验后3 failed, 20 passed。没做这一步,"我修好了"和"我改了段看起来像修好的代码"无法区分。 - 改共享鉴权函数会打爆测试桩,这是信号不是障碍:在
checkUserPermission前置一次prisma.user.findUnique后,恰好 4 个既有断言变红——因为permissions.test.ts的 prisma 桩里没有user。正确处置是补桩 + 加新用例,而不是把校验挪到一个不查库的位置。 - 数值口径要绑定被执行的那批文件:修后
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 的一环):
- 算完就丢:
checkCookies老老实实算了hasHttpOnly/hasSecure/hasSameSite三个标志,最后一行status: 'pass' as const——写死的字面量。于是cookieFailed恒为 0。 - 算了但没人看:汇总判定
hasFailures = headerChecks.some(c => c.status === 'fail'),压根不看 cookieChecks。第 1 条即使改对,也仍然进不了退出码。这两条是"死断言"最省事的形态:数据被完整地采集、展示、然后被判定逻辑遗忘。 - 两个分支完全相同的三元:
return val ? 'warn' as const : 'warn' as const;。它不会报错,只会永远返回 warn。 - 最重要的判据反而最宽松:缺 CSP 只
warn,而同文件里X-Content-Type-Options缺失判fail。同类判据的严重度不一致,往往就是漏判的位置。
外加一条结构性问题:脚本 parseArgs() 的默认值是线上域名,package.json 调用时不带 --url,而 Jenkinsfile 在那一行前面打印的是「检查本分支构建产物的安全响应头」。声明与行为相反——CI 验的是已部署站点,改 next.config.mjs 里的头不会让 CI 变红。这条不是我推出来的,是正向对照逼出来的:把修好的门禁第一次指向本地 standalone 产物,它红了,唯一红项是 HSTS——因为 HSTS 由 Nginx/CDN 在边缘注入,Node 产物根本没有。也就是说这个门禁结构上就不可能在分支产物上通过,所以它的默认值只能指向线上,所以那个打印永远是假的。
教训(管理归零):
- 每个门禁修完,至少跑一次"它必须变红"的输入。本轮做法是拿
python -m http.server起一个没有任何安全头的本地服务:负向 ⇒EXIT=1 / 4 项 fail;再指真实产物:正向 ⇒EXIT=0 / 0 失败。只有负向能证明第 1、2、4 条真的被修好了——否则"我改了判定逻辑"和"判定逻辑仍然看不见任何东西"在输出上一模一样。 - 正向对照和负向对照缺一不可,且顺序常常反直觉。本轮先做负向(以为稳了),是正向暴露出 HSTS/边缘注入这个更深的问题。凡"改完在干净对象上仍判红",先怀疑门禁的作用对象假设(它以为自己在测什么),而不是先怀疑干净对象脏了。
- 审计一个门禁,先看它的输入从哪来:默认 URL 指向线上、默认端口指向 :3000(可能被别的服务占着)、默认目录为空时
violations=0直接通过。空输入 = 空转通过是这类脚本最常见的形状;crawl-routes.mjs那种paths.length === 0 → exit 2的写法才是对的。 - 脚本存在 ≠ 脚本在跑。
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.mdArchiving 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.ts404 相关用例全部通过。 - 来源: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 探针 |