本文是 matery 主题自动化测试体系的完整教程,与其余 4 篇功能测试文章(内容 tag / 交互视觉 / 布局页面 / Markdown 语法)配套——那 4 篇是"测试靶场",本文是"测试引擎"。权威依据:
tools/tests/release-test.sh(137 行纯编排)+tools/tests/layers/manifest(13 层唯一权威清单)、docs/superpowers/specs/2026-08-06-layout-test-cases.md。阅读方式:每个命令都先给 输出(真实终端的样貌),再给 对应命令(产出该输出的那一行)。看到输出想复现就抄下面的命令;想查参数直接跳「全部参数」表。
什么时候看这篇?
| 你是谁 | 你该看什么 | 你能得到什么 |
|---|---|---|
| 普通用户(想知道某个功能是否正常) | 附录「测试 ↔ 功能对照表」 | 按功能名找到对应测试文件,确认"这个功能有自动验证" |
| 贡献者(想新增/修改测试用例) | §12「如何新增测试用例」 | 从零到挂入门禁的完整步骤(含代码模板) |
| 维护者(发版前跑门禁) | §2「release-test.sh 用法」 | 本地迭代 / 发版门禁 / 视觉断言的命令 |
| 排查问题(门禁报红) | §5「外部依赖重试」+ §6「第三方拦截」+ ⚠️ 已知坑 | 区分"环境噪声"和"真 bug" |
1. 测试金字塔总览
用途:一眼看清"13 层门禁各自验证什么、依赖什么",决定跑哪一档。
学习路径:看层清单 → 看三种典型输出 → 查层表 → 记住执行顺序的坑
1.1 最小用法
输出(manifest 的原文——这就是金字塔的全部 13 层,行序 = 执行顺序):
# 门禁层清单 —— 唯一权威(行序 = 执行顺序)
# 格式:ID<TAB>脚本文件<TAB>入口函数<TAB>描述
# 消费者:tools/tests/release-test.sh(source + 调用)、tools/tests/gen-tc-index.js(层清单)
L10 L10_smoke.sh run_L10 发版前 SW 冒烟(远程测试域;--local 跳过)
L01 L01_build.sh run_L01 构建验证
L02 L02_urls.sh run_L02 URL 与链接完整性
L03_05 L03_05_static.sh run_L03_05 静态资源与页面渲染(L3-L6)
L07 L07_core.sh run_L07 核心功能
L08 L08_enhanced.sh run_L08 增强功能
L09 L09_sw.sh run_L09 Service Worker
L11 L11_integration.sh run_L11 Playwright 集成
L12 L12_local.sh run_L12 本地化资源 + 多语言一致性
L2i L2i_seo.sh run_L2i SEO 结构化数据
L13 L13_units.sh run_L13 全量单测门禁
L13a L13a_cov.sh run_L13a 覆盖率阈值
L14 L14_visual.sh run_L14 视觉断言对应命令:
cat tools/tests/layers/manifest把上表按"依赖成本"摞起来,就是金字塔的形状:
┌──────────────┐
│ L2 生产 URL │ 发版后:20 页面 HTTP 200 + 功能标记(curl)
│ L9/L10 SW │ 发版前:Vercel 测试域 SW 冒烟(playwright)
├──────────────┤
│ L11 Playwright 集成 │ 真实浏览器:特效/词云/懒加载/阅读模式等 18 项
├──────────────┤
│ L14 视觉断言 │ visual-assert 精确数值:12 套件(生产阻塞,本地默认跳过)
├──────────────┤
│ L13/L13a 单测 │ node 直跑:15 个 .test.js + search 集成 + 4 个 shell 单测 + 教程结构门禁;L13a 再扫 unit/** 覆盖率
├──────────────┤
│ L1 构建验证 │ hexo g Success + 产物完整(index.html > 1000B)
└──────────────┘1.2 常用参数
① 最上层(构建)的输出现场
输出:
🔨 L1: 构建验证
✅ 构建成功 (hexo g Success)
✅ public/index.html 存在 (123456 字节)对应命令:
tools/tests/release-test.sh # L1 由其内部调用 cicd.sh -d 产出② 中间层(单测)的输出现场——每个文件一行 ✅
输出:
🧪 L13: 全量单元/集成测试门禁(node tools/tests/*.test.js)
✅ 单测 unit-toc.test.js
✅ 单测 unit-search.test.js
✅ 单测 unit-css-architecture.test.sh(CSS 架构)对应命令:
node tools/tests/unit/unit-toc.test.js # 单跑其中一个,看它自己的输出③ 浏览器层(L11)的输出现场——输出带提取到的 PASS 计数
输出:
✅ 词云/去jQuery/防闪烁集成测试(PASS=8)
✅ 内容宽度统一测试(PASS=5)对应命令:
node tools/tests/wordcloud.test.js④ 最底层(视觉断言)的输出现场——按套件汇总
输出:
🎯 L14: 视觉断言(visual-assert)
✅ L14 layout-checks:18 项全部通过
✅ L14 dark-mode:4 项全部通过
⏱️ L14 总计:12 文件通过, 0 文件失败(299s)对应命令:
node tools/visual-assert.js run --local tools/visual-assert/dark-mode.json1.3 全部参数
| 层 | 名称 | 运行方式 | 依赖 |
|---|---|---|---|
| L1 | 构建验证 | hexo generate + 产物检查 | 无 |
| L13 | 全量单测门禁 | node tools/tests/*.test.js + shell 单测 | 无 chromium |
| L11 | Playwright 集成 | 真实浏览器 + 本地 server | chromium + server |
| L2/L2b/L2x/L2y/L2i | 生产 URL 断言 | curl + grep | 生产/本地 server |
| L3-L8 | 功能标记检查 | curl + 静态断言 | 生产/本地 server |
| L9 | 静态资源 + SW 冒烟 | curl + playwright | 生产域名 |
| L13a | 覆盖率统计 | node --experimental-test-coverage 扫 unit/** | 无 |
| L14 | 视觉断言 | node tools/visual-assert.js run(12 套件) | chromium + server |
| L10 | SW 冒烟(测试域) | playwright | Vercel 测试域 blog2.17lai.site |
1.4 边界与坑
- 执行顺序不是文档顺序:
manifest行序 = 执行序,L10 最先跑(在 L1 之前)。旧文档常把 L1 写在第一行——那是文档习惯,不是执行事实。 - 层脚本本身不能直接执行:
layers/*.sh只定义run_LXX(),依赖release-test.sh先source lib/assert.sh(pass/fail是全局计数器)。要单独验证一层,跑它内部对应的测试文件,而不是bash layers/L13_units.sh。 - 层脚本缺失即硬失败:
manifest里写了某文件但layers/下没有 →❌ 层脚本缺失:xxx.sh直接 exit 1,不会静默跳过。 - L13 与 L13a 有重叠:L13 是"点名跑"(失败时名字清晰),L13a 是"通配扫
unit/**并卡覆盖率",同一文件可能被跑两次,这是刻意的双保险。
1.5 小结
层清单只在一个文件里:tools/tests/layers/manifest。想知道"有哪几层、按什么顺序跑",永远看它,不看文档也不看聊天记录。
2. release-test.sh 用法
用途:发版前/后跑全量门禁;本地迭代只跑能跑的那部分。
学习路径:跑一次看骨架 → 按场景挑参数 → 查参数表 → 记住基址与并发限制
2.1 最小用法
输出(一次完整门禁的骨架——首尾固定,中间按层展开):
=============================================
发版全量自动化测试 (target: https://blog.17lai.site)
=============================================
🔍 [preflight] 环境预检...
✅ 构建产物有效 (123456 字节)
✅ playwright 可用(NODE_PATH=/home/andy/.local/share/mise/installs/npm-playwright/1.56.0/lib/node_modules)
✅ _config.yml 未被污染
✅ 网络基址可达(HTTP 200)
✅ [preflight] 环境就绪
🔒 [preflight] 已获取门禁锁(PID 12345)
🌐 L10: 发版前 SW 冒烟(Vercel 测试域 blog2.17lai.site)
...
测试结果: PASS=<N> FAIL=0
=============================================
🎉 全部通过!对应命令:
tools/tests/release-test.sh2.2 常用参数
① --local(本地迭代最常用)
输出(汇总段明确告知跳过了什么):
⏭️ 已跳过 L10(远程 SW 冒烟)与 L11(playwright 集成)—— 本地迭代模式;
发版门禁(不带 --local)会跑全量,L11 覆盖内容宽度/灯箱/懒加载/阅读模式等
测试结果: PASS=<N> FAIL=0
🎉 全部通过!对应命令:
tools/tests/release-test.sh --local② --skip-build(public/ 已就绪,只想复跑断言)
输出(L1 两条都变成"跳过",不再调 docker):
🔨 L1: 构建验证
✅ 跳过构建(--skip-build)
✅ 跳过产物验证(--skip-build)对应命令:
tools/tests/release-test.sh --skip-build③ --skip-l10(只跳过远程 SW 冒烟,保留 L11 浏览器集成)
输出:
🌐 L10: 发版前 SW 冒烟(Vercel 测试域 blog2.17lai.site)
⏭️ 跳过 L10(--local/--skip-l10:本地迭代不验证远程 SW)对应命令:
tools/tests/release-test.sh --skip-l10④ --prepare(环境没就绪时一键拉起)
输出:
🔧 [preflight] 自动就绪环境...
✅ 容器已在运行
✅ 产物有效 (123456 字节),跳过重建
🔍 [preflight] 复检环境...
✅ [preflight] 环境就绪对应命令:
tools/tests/release-test.sh --prepare⑤ RUN_L14=1(本地也跑视觉断言)
输出(原本"skip",现在真跑):
🎯 L14: 视觉断言(visual-assert)
✅ L14 layout-checks:18 项全部通过
⏱️ L14 总计:12 文件通过, 0 文件失败(299s)对应命令:
RUN_L14=1 tools/tests/release-test.sh --local⑥ SKIP_L14=1(强制跳过,优先级高于 RUN_L14)
输出:
🎯 L14: 视觉断言(visual-assert)
⏭️ L14 视觉断言(SKIP_L14=1 → 强制跳过)对应命令:
SKIP_L14=1 RUN_L14=1 tools/tests/release-test.sh2.3 全部参数
| 参数 | 作用 |
|---|---|
--skip-build | 跳过 L1 构建 + 产物验证(已构建时用) |
--local | 目标改本地基址(端口自适应探测,见下方基址段),跳过 L10(远程 SW 冒烟 5min+)与 L11(playwright 串行 >800s) |
--skip-l10 | 仅跳过 L10,保留 L11 playwright 集成 |
--prepare | 自动就绪环境:容器未跑则启动、产物无效则重建、最后复检 |
RUN_L14=1 | 本地模式下启用 L14 视觉断言(默认本地跳过) |
SKIP_L14=1 | 强制跳过 L14(即使 RUN_L14=1) |
基址:生产固定 https://blog.17lai.site(443,绝无端口);本地 LOCAL_BASE 走端口自适应——探测顺序 BLOG_PORT 环境变量 → TEST_BASE 环境变量 → 根 _config.yml 的 server.port(docker 4000)→ 4100,取第一个 HTTP 200;成功后导出 TEST_BASE/TEST_URL,36 个测试文件自动跟随。
执行顺序(
manifest行序):L10 → L01 → L02 → L03_05 → L07 → L08 → L09 → L11 → L12 → L2i → L13 → L13a → L14 → TC 索引校验 → 汇总。注意 L10 最先跑(在 L01 之前)——旧文档常写反。
发版铁律:
tools/cicd.sh -r发布后必须跑tools/tests/release-test.sh验证生产环境。
2.4 边界与坑
① 并发会被直接拒绝
输出:
❌ [preflight] 门禁已在运行(PID 12345),拒绝并发启动
锁文件:/tmp/opencode/gate.lock
💡 如确认无其他门禁在跑,可手动删除:rm /tmp/opencode/gate.lock对应命令:
tools/tests/release-test.sh --local # 另开一个终端重复执行即可复现并发会互相 kill 本地 server——这是硬拒绝,不是警告。陈旧锁(持锁进程已死)自动接管并打印
⚠️ [preflight] 陈旧锁(PID xxxx 已死),自动接管。
② 环境没就绪时秒级失败,并给出修复命令
输出:
❌ 本地服务器不可达(已尝试端口: 4000 4100)
💡 修复命令(任选其一):
1. docker 生成: docker compose -f docker-compose.test.yml exec -T hexo bash -c 'tools/cicd.sh -d'
2. 本机预览: tools/test.sh server
3. 恢复服务: docker compose -f docker-compose.test.yml exec -T hexo bash -c 'pm2 restart all'对应命令:
tools/tests/release-test.sh --local③ 失败汇总的形状
输出:
测试结果: PASS=<N> FAIL=2
=============================================
❌ 存在失败项,请检查!对应命令:
tools/tests/release-test.sh # exit code 1- 只
--local不传RUN_L14时 L14 必跳——别把"没跑"误读成"通过"。 --local会同时关掉 L10 和 L11,不是只关 L10;只想关 L10 用--skip-l10。
2.5 小结
三个高频组合:本地迭代 --local、复跑断言 --skip-build、发版 无参数(必须全绿才算发版完成)。
3. L1-L14 各层覆盖内容
用途:知道每一层"断言什么",报红时能立刻定位是哪个功能坏了。
学习路径:看四层典型输出 → 查覆盖表 → 看 L11 清单 → 记住未挂层/重复项
3.1 最小用法
输出(L1 是最小的一层——只有两条断言):
🔨 L1: 构建验证
✅ 构建成功 (hexo g Success)
✅ public/index.html 存在 (123456 字节)对应命令:
tools/tests/release-test.sh # 不带 --skip-build 即跑 L13.2 常用参数
① L2:URL 可达性——每条路径一行
输出:
✅ /archives/ → 200
✅ /categories/ → 200
✅ /tags/ → 200对应命令:
curl -s -o /dev/null -w '%{http_code}\n' https://blog.17lai.site/archives/② L7:核心功能——功能名 + 实测值
输出:
✅ 搜索入口
✅ TOC 折叠配置(<实际配置值>,0=全部折叠)
✅ 暗色模式 CSS
✅ infinite-scroll 状态元素 + data-loading 文案 (<i18n 文案>)对应命令:
curl -s https://blog.17lai.site/ | grep -o 'id="searchInput"' | head -1③ L13:单测门禁——每文件一行
输出:
✅ 单测 unit-wiki-helper.test.js
✅ 单测 search-integration.test.js(PASS=<N> FAIL=0)
✅ 教程文章结构门禁 check-tutorial-articles.js对应命令:
node tools/tests/unit/unit-wiki-helper.test.js④ L14:视觉断言——按套件汇总
输出:
✅ L14 homepage:<N> 项全部通过
⚠️ L14 nav-scrolled 第 1 次未通过(疑似负载抖动),重试一次…
✅ L14 nav-scrolled:<N> 项全部通过
⏱️ L14 总计:12 文件通过, 0 文件失败(312s)对应命令:
node tools/visual-assert.js run --local tools/visual-assert/nav-scrolled.json3.3 全部参数
| 层 | 覆盖内容 | 断言示例 |
|---|---|---|
| L1 | 构建验证 | hexo g Success + public/index.html > 1000B |
| L2 | 20 个 layout 页面 URL 可达 | /posts/fc764afd/、/archives/、/categories/、/tags/、/about/、/friends/、/galleries/、/bb/、/musics/、/movies/、/msg/ + 4 篇测试文章 → HTTP 200 |
| L2b | 多语言验证 | en 文章真实翻译 / 回落提示条 / 评论 path 归一化 / 默认语言无前缀 |
| L2x | 统一语言回落 | MATERY_LANG 注入 / 回落页 / 独立页多语言(含 gallery 子页)/ 聚合页多语言 |
| L2y | 加密解密刷新 | wiki 侧栏无元数据/分区键泄漏(structure/i18n/encrypt_)/ 加密容器完整(hbeForm + storage-key) |
| L2i | SEO 结构化数据 | og:image / JSON-LD / fetchpriority |
| L3 | 评论区检查 | msg/friends/bb 页含 #comments |
| L4 | href=/ 样式错误 | 页面 href=/ 引用数应为 0 |
| L5 | 版本号一致性 | config vs sw.tmp.js 3 处一致 |
| L6 | 关键资源检查 | 404 页面 / 关键 JS 版本 |
| L7 | 核心功能覆盖 | 相册灯箱 / 加密相册 / 搜索 / JS 模块合并 / TOC 折叠 / 暗色 / 评论 / infinite-scroll / wiki 代码块 / wiki 组件样式(main 含 post-container / .wiki-content 折行 / admonition .note 规则精确限定) |
| L8 | 增强功能覆盖 | tag 插件渲染(note/timeline/tabs/label/githubCard/mermaid)/ 加密文章 / Feed / 静态资源 / 分析 |
| L9 | 静态资源 + SW 冒烟 | main.css > 50KB / utils.js > 5KB / 生产域名 7 页无 SW 错误 |
| L10 | SW 冒烟(测试域) | blog2.17lai.site 7 页无 SW 错误 + 交互验证(搜索/打赏/灯箱/TOC/暗色) |
| L11 | Playwright 集成(18 项) | 见下表 |
| L12 | 本地化资源检查 | mermaid / aplayer 资源存在 |
| L13 | 全量单测门禁 | 15 个 *.test.js + search 集成 + 4 个 *.test.sh + 1 个教程结构门禁(详见 §4) |
| L13a | 覆盖率统计 | unit/**/*.test.js 覆盖率扫描(line ≥85 / branch ≥77 / funcs ≥70) |
| L14 | 视觉断言(12 套件) | 见 §9 |
评论库版本基准(2026-09-24 升级,配置见
userConfig/_config.tmp.ymllibs 段):walinev=3.15.2(启用)/ twikoov=2.0.8(禁用)/ valinev=1.5.3+ avv=5.19.2(禁用)/ gitalkv=1.8.0(废弃);skillmatery-libs/references/{waline,twikoo,valine,gitalk}.md同步记录版本与升级沿革。
L11 Playwright 集成清单(真实浏览器)
| 测试文件 | 覆盖 |
|---|---|
effects-integration.test.js | 页面特效(sakura/snowflake/fireworks 加载) |
videobg-integration.test.js | 视频背景(门槛/亮暗池/webm 契约/禁用态) |
wordcloud.test.js | 词云 / 去 jQuery / TOC 防闪烁 / 明暗切换 |
i18n.test.js | 多语言补全(MATERY_I18N 注入 + 按钮文案 + en 翻译) |
musics-integration.test.js | 在线音乐 APlayer(多域名 failover) |
lazyload-integration.test.js | 懒加载 handleCard 三分发(51 断言) |
floating-panel-integration.test.js | 悬浮面板(开关/字体缩放/持久化) |
content-width.test.js | 内容宽度统一(4K/FHD/2K/单栏封顶) |
readmode-integration.test.js | 阅读模式 / 代码全屏 / scroll-down-bar |
wiki-styles.test.js | Wiki 页 tag 组件样式(note / ::: 容器色 + 居中 + 宽度一致性) |
code-widget.test.js | codeWidget 委托幂等(二次调用后单次点击仍生效) |
core-events-integration.test.js | 事件总线 / 进度条联动 |
wiki-prism-integration.test.js | Wiki 代码块 prism + line-numbers |
misc-interactions.test.js | 打赏弹窗 / 繁简转换(G14/G15) |
lang-client-strategy.test.js | 客户端语言策略(全站跟随/爬虫跳过/面板回显,7 断言) |
rich-content-integration.test.js | 富内容渲染(公式 MathJax+KaTeX/mermaid/markmap/打字机/分享/Live2D,11 断言) |
wiki-integration.test.js | 多语言 wiki 可达 / 侧栏章节 / 语言切换器(18 断言) |
infinite-scroll-integration.test.js | Infinite Scroll(Home/Archive 真实分页 + 标签页单页栅格) |
lightbox.test.js | 灯箱(相册/画廊图片放大交互) |
gallery-hover.test.js | 图库/卡片 hover 动画(保护 classList 多类名修复) |
3.4 边界与坑
- L11 本地必跳:
--local会整段跳过(⏭️ 跳过 L11 playwright 集成(--local:…)),所以"本地全绿"不等于"浏览器层没坏"。 - L11 的通过判据不统一:有的文件看
PASS=N FAIL=0,有的看0 失败,lazyload干脆没有汇总行(判据是"没有 FAIL/失败/❌")。新增文件时必须对齐既有判据,否则会假绿。 - L14 失败自动重试一次:
⚠️ L14 xxx 第 1 次未通过(疑似负载抖动),重试一次…,重试通过会记入 flaky 列表(见 §11),不改变 PASS/FAIL 计数。 - 未挂层文件已清零:
wiki-integration.test.js、infinite-scroll-integration.test.js早期存在但没进任何层,现已分别于 2026-09-27 / L11 段接入,有断言兜底。唯一仍未挂层的是tools/tests/unit-fontawesome.test.py(L13a的通配只收.test.js),需手动跑python3 tools/tests/unit-fontawesome.test.py。
3.5 小结
层数不等于覆盖度:L1/L13 是"能不能跑起来",L2/L7/L8 是"产物里有没有",L11/L14 是"浏览器里对不对"。三层缺一层,故障就会从缺的那层漏出去。
4. 单测清单(tools/tests/)
用途:查"哪个文件测什么",以及为什么有些文件跑两遍。
学习路径:跑一个文件看输出格式 → 认三种汇总行 → 查文件清单 → 记住 mock 的坑
4.1 最小用法
输出(单个测文件自报家门,末尾一定有一行汇总供门禁匹配):
✅ TOC 折叠配置解析(collapseDepth=0 视为全部折叠)
✅ collapseDepth=6 视为全部展开
✅ 未配置时取默认值
结果: PASS=3 FAIL=0对应命令:
node tools/tests/unit/unit-toc.test.js4.2 常用参数
① 三种汇总行格式(门禁按正则匹配,格式不能改)
输出:
结果: PASS=3 FAIL=0结果: 5 通过, 0 失败unit-i18n-core: 10 pass / 0 fail对应命令:
grep -n 'console.log' tools/tests/unit/unit-toc.test.js | tail -2② shell 单测的汇总行最短
输出:
结果: PASS对应命令:
bash tools/tests/unit/unit-version.test.sh③ L13a 覆盖率扫描(自带阈值,低于阈值直接 exit 1)
输出:
ℹ all files | 86.66 | 78.98 | 72.73 |门禁里被转成:
✅ node:test 单元测试 + 覆盖率达标(86.66/78.98/72.73)对应命令:
node --test --experimental-test-coverage \
--test-coverage-lines=85 --test-coverage-branches=77 --test-coverage-functions=70 \
"tools/tests/unit/**/*.test.js"4.3 全部参数
L13 门禁直跑清单(L13_units.sh 里逐文件点名,失败时名字清晰):
# node 单测(纯单测/静态,无浏览器依赖)
unit-toc.test.js # TOC 折叠配置(collapseDepth 0||6 falsy 陷阱)
unit-search.test.js # 搜索三引擎
unit-multilang.test.js # 多语言配置
unit-encrypt2.test.js # 加密扩展(片段 + wiki)
unit-wiki-helper.test.js # wiki helper(语言解析/侧栏渲染/canonical 回落,10 用例)
helpers.test.js # 字数统计三 helper(wordcount/min2read/totalcount;加密 origin/wiki 去重/回落/缓存重置,52 断言)
utils.test.js # 通用工具函数
lang-fallback.test.js # 回落链 4 级(13 断言)
echarts-charts.test.js # ECharts 图表
banner-bg.test.js # banner 与背景偏好联动
homepage.test.js # 首页结构
imgsize-unit.test.js # 图片尺寸注入脚本(normalizeImgUrl 协议相对 URL 补全 + 本地/网络图片尺寸读取)
infinite-scroll-aos.test.js # 无限滚动 + AOS 联动
unit-standalone.test.js # 独立页排除法判定 + 语言前缀豁免(22 断言)
unit-i18n-helper.test.js # t() helper(fallback_lang + 大小写容错,5 用例)
# 带外部依赖的集成(retry_match 包装,判据 PASS=N FAIL=0)
search-integration.test.js # 搜索集成(Algolia 分页需真实 API)
# shell 单测
unit-css-architecture.test.sh # CSS 架构(编译产物关键选择器)
unit-version.test.sh # 版本递增进位(防 12.99→12.100)
unit-retry.test.sh # 退避重试库
unit-deploy-target.test.sh # 部署目标切换(hexo/hexoback 双激活校验)L13a 通配覆盖(unit/**/*.test.js,含上面部分文件):
unit-i18n-core.test.js # i18n 核心回落语义(resolveI18n 四级链 + cfg 防御,10 用例)
unit-sidebar-i18n.test.js # 侧栏 i18n 查表 + 回退链(当前→fallback→default→key,5 用例)
unit-sidebar-keys.test.js # 门禁校验器 sidebar-keys.js 行为(4 wiki × 9 语言覆盖,4 用例)
unit-encrypt.test.js # 自研加密体系(AES-GCM + PBKDF2 闭环,9 用例)
unit-markmap-prune.test.js # markmap 无脑图页面资源拆除(6 用例)
unit-updated.test.js # 「更新于」取 git 最后提交时间(4 用例)未挂层(需手动跑):
unit-fontawesome.test.py # FontAwesome 精简脚本(L13a 通配只收 .test.js,不含 .py)4.4 边界与坑
mock 必须模拟真实 Hexo 对象(Query 而非普通数组)
site.posts/site.pages是 Hexo 的 Query(类数组对象),.filter()返回的仍是 Query 而非数组,且不能for...of——必须先.toArray()取真数组。helpers.test.js(字数统计)最初用普通数组 mock,单测全绿;真实构建却wikiPages is not iterable→ 全部 HTML 输出 0 字节。补上toArray()后才覆盖到真实路径。
输出格式不能随手改
输出(门禁匹配不到汇总行时的真实下场):
⏳ 搜索集成 无输出(疑似超时),退避 1s 重试...
❌ 搜索集成 业务断言失败(真 bug)对应命令:
tools/tests/release-test.sh --local # 改动末尾 console.log 后复跑即可复现教训:mock 越接近真实对象,单测才越能拦住构建级故障;汇总行越稳定,门禁才越能认出"通过"。
4.5 小结
一个测试文件只有一个契约:末尾输出可被正则匹配的汇总行 + 失败时 exit 非 0。其余输出随便写,这两条不能动。
5. 外部依赖重试与降级(lib/retry.sh)
用途:把"外部服务抖动"和"自己的 bug"分开,前者不拦发版。
学习路径:看一次重试输出 → 看降级/失败两种结局 → 查分类表 → 知道何时不该包
5.1 最小用法
输出(首次即通过——最理想,只有一行):
✅ L2 搜索 通过对应命令:
source tools/tests/lib/retry.sh
retry_run "L2 搜索" "node tools/tests/search-integration.test.js"5.2 常用参数
① retry_run:按 exit code 判定(3 次退避 1s/4s/9s + 抖动)
输出(抖动场景——重试后通过,并记入 flaky):
⏳ L2 搜索 第 1 次失败,退避 1s 重试...
⚠️ L2 搜索 第 2 次通过(前 1 次 flaky,需关注)对应命令:
retry_run "L2 搜索" "node tools/tests/search-integration.test.js"② retry_match:按输出模式判定(L11 段主用)
输出(匹配 PASS=.*FAIL=0 才算通过):
⏳ L2d 视频 第 1 次未通过,退避 1s 重试...
✅ L2d 视频 通过对应命令:
retry_match "L2d 视频" "timeout 180 node tools/tests/videobg-integration.test.js" "0 失败|fail 0|失败, 0"③ 全失败 → 外部不可用(降级 warn,返回 0)
输出:
⚠️ L2d 视频 外部依赖不可用(降级 warn,非产品缺陷)
📄 日志: /tmp/opencode/test-L2d_视频.log对应命令:
retry_match "L2d 视频" "timeout 180 node tools/tests/videobg-integration.test.js" "0 失败"④ 全失败 → 业务断言失败(真 bug,返回 1)
输出:
❌ L2d 视频 业务断言失败(真 bug)
📄 日志: /tmp/opencode/test-L2d_视频.log对应命令:
retry_match "L2d 视频" "timeout 180 node tools/tests/videobg-integration.test.js" "0 失败"5.3 全部参数
失败分类(全失败时按日志签名区分):
| 签名 | 判定 | 结果 |
|---|---|---|
ERR_NAME_NOT_RESOLVED / ECONNREFUSED / ETIMEDOUT / 429 / 50x 等 | 外部不可用 | ⚠️ 降级 warn(返回 0,不拦发版) |
| 无外部签名 | 业务断言失败 | ❌ fail(返回 1,真 bug) |
5.4 边界与坑
- 重试次数/退避是固定的:3 次、1s/4s/9s + 抖动。不要为了"让它绿"而调大——
unit-retry.test.sh会校验退避库行为。 - 不要给纯静态断言套
retry_match:本地文件读不到不是网络抖动,重试只会把真 bug 拖成 3 倍耗时。 retry_match的模式必须锚定失败数为 0:只匹配PASS=会把"4/24 通过"判成绿(2026-10-07 已因此收紧判据)。- 日志落在
/tmp/opencode/:报红先看📄 日志:那一行给出的文件,比翻终端滚动区快。
5.5 小结
retry_run 看退出码,retry_match 看输出;两者都只在"全失败"时才分类——中间成功就是 flaky,不算失败但会被看见。
6. 第三方拦截 + ERR_ABORTED 排除(lib/browser-env.js)
用途:让 playwright 测试只对"页面自身的错误"负责,第三方 CDN 的噪声不算账。
学习路径:看一次断言输出 → 看两个 API → 查机制表 → 记住 abort 的坑
6.1 最小用法
输出(用共享环境后,测试只需断言两个数组为空):
✅ 页面无 JS 异常
✅ 无同源资源失败对应命令:
node tools/tests/content-width.test.js # 任一 playwright 测试的断言现场6.2 常用参数
① interceptThirdParty(page):拦截已知污染源
输出(不拦截时,页面自身的 unhandled rejection 会混进 JS 异常):
❌ JS errors [ 'Failed to parse JSON from webpushr' ]对应命令:
node tools/tests/effects-integration.test.js② collectPageErrors(page, BASE):只收 JS 异常 + 同源资源失败
输出(第三方图床 404 不计入,同源 404 才计入):
jsErrors: []
sameOriginFailures: []对应命令:
NODE_PATH=$(npm root) node tools/tests/lightbox.test.js6.3 全部参数
| 机制 | 说明 |
|---|---|
interceptThirdParty | 拦截已知污染源(如 webpushr 返回非 JSON → 页面 unhandled rejection),返回合法 JSON 而非 abort(abort 会产生 net::ERR_FAILED 反成新噪声) |
collectPageErrors | 仅收集 JS 异常 + 同源(localhost/生产域)资源失败;第三方 CDN/图床失败为环境噪声不计入 |
ERR_ABORTED 排除 | 导航中断(多次 goto/离开页面取消在途请求)非资源错误,直接跳过 |
6.4 边界与坑
- 别用
route.abort()拦第三方:abort 会把请求变成net::ERR_FAILED,反而新增一条同源失败——所以用 fulfill 返回合法 JSON。 - 别把基线域写死成生产域名:
collectPageErrors的第二参数是当前BASE,本地跑却传生产域时,本地资源失败会被当成第三方漏掉。 - 多次
goto的测试必须容忍ERR_ABORTED:否则翻页测试会稳定假红。
6.5 小结
一句话:同源算账,第三方不计;abort 制造噪声,fulfill 消除噪声。
7. 压缩开关验证(cicd.sh)
用途:确认当前构建产物到底是压缩的还是可读的——调试要可读,发版要压缩。
学习路径:看开关原文 → 看两种产物的头部 → 查模式表 → 记住别手工改
7.1 最小用法
输出(开关只有 3 行,用同一个注释标记串起来):
js:
enable: true # minify-switch
css:
enable: true # minify-switch
html:
enable: true # minify-switch对应命令:
grep -n 'minify-switch' _config.yml7.2 常用参数
① -d / --debug:关压缩,产物可读
输出(public/css/main.css 开头——多行、带缩进):
.anchorjs-link {
text-decoration: none !important;
transition: opacity 0.2s ease-in-out;
}
.markdown-body h1:hover > .anchorjs-link,
h2:hover > .anchorjs-link,对应命令:
tools/cicd.sh -d
head -c 200 public/css/main.css② -r/-c/-t:开压缩,单行无注释
输出(同样位置变成一行、体积显著变小):
.anchorjs-link{text-decoration:none!important;transition:opacity .2s ease-in-out}.markdown-body h1:hover>.anchorjs-link,...对应命令:
tools/cicd.sh -t
head -c 200 public/css/main.css7.3 全部参数
| 模式 | minify 值 | 说明 |
|---|---|---|
-d / --debug | false | 不压缩(本地调试,产物可读) |
-r / -c / -t | true | 压缩(hexo-minify 在 generate 时恒压缩,故需改其 enable) |
7.4 边界与坑
构建后 cicd.sh 会还原
_config.yml(仅当改动只涉及 minify-switch 标记行时),避免工作区脏。
- 别手工改
_config.yml的 minify 来测压缩:下次cicd.sh会按模式覆盖,测完的手改会变成"怎么回事又变了"。走参数。 preview: false时本地hexo s不压缩:想验证压缩效果必须走npm run build或cicd.sh -t,不能看本地预览。- 压缩开关只有 3 行:
js/css/html。漏改一行会出现"JS 压了 CSS 没压"的半压缩产物。
7.5 小结
压缩与否由构建模式决定,不由配置文件决定;验证方式就是 head -c 200 public/css/main.css 看第一行是不是单行。
8. 环境就绪、单实例锁与基址自适应
用途:门禁跑之前的"体检"——不健康就秒级失败并告诉你怎么修。
学习路径:看一次成功体检 → 看一次失败体检 → 查检查项表 → 记住锁
8.1 最小用法
输出(健康环境的完整体检,5 行内结束):
🔍 [preflight] 环境预检...
✅ 构建产物有效 (123456 字节)
✅ playwright 可用(NODE_PATH=/home/andy/.local/share/mise/installs/npm-playwright/1.56.0/lib/node_modules)
✅ _config.yml 未被污染
✅ 网络基址可达(HTTP 200)
✅ [preflight] 环境就绪对应命令:
tools/tests/release-test.sh --local8.2 常用参数
① 产物缺失/过小
输出:
❌ 构建产物不存在(public/index.html)
💡 修复命令:docker compose -f docker-compose.test.yml exec -T hexo bash -c 'tools/cicd.sh -d'对应命令:
tools/tests/release-test.sh --skip-build # 跳过构建但产物不存在时即可见② _config.yml 被构建污染
输出(自动恢复,不会拦你):
⚠️ themes/matery/_config.yml 被构建污染(有未提交修改)
🔧 自动恢复:git -C themes/matery checkout -- _config.yml
✅ _config.yml 已自动恢复对应命令:
tools/tests/release-test.sh --local③ 本地端口自适应(不写死 4000)
输出:
✓ 本地基址: http://localhost:4100(端口 4100)
✅ 已导出 TEST_BASE=http://localhost:4100 TEST_URL=http://localhost:4100对应命令:
tools/tests/release-test.sh --local8.3 全部参数
| 检查项 | 不通过表现 |
|---|---|
| 本地 server 可达(HTTP 200) | 打印修复命令后退出 |
构建产物有效(public/index.html > 1000B) | 打印重建命令 |
| playwright 可用 | 跳过依赖浏览器的层 |
themes/matery/_config.yml 未被构建污染 | 自动 git checkout 精确恢复 |
| 网络基址可达(仅发版模式) | 提示基址不可达 |
# 未就绪时打印的 3 条修复命令(任选其一)
docker compose -f docker-compose.test.yml exec -T hexo bash -c 'tools/cicd.sh -d'
tools/test.sh server
docker compose -f docker-compose.test.yml exec -T hexo bash -c 'pm2 restart all'--prepare 自动就绪:容器未跑则启动、产物无效则重建、最后复检。
单实例锁:门禁启动即在 ${TMPDIR:-/tmp}/opencode/gate.lock 取锁,并发运行被直接拒绝(打印持锁 PID);陈旧锁自动接管,trap EXIT 释放。禁止并发门禁——并发会互相 kill 本地 server。
基址与端口自适应:见 §2 的基址段(生产 443 固定 / 本地端口探测)——36 个测试文件靠导出的 TEST_BASE/TEST_URL 自动跟随。
8.4 边界与坑
preflight在网络基址不可达时重试 3 次(❌ 网络基址不可达(HTTP 000,期望 200/301,已重试 3 次)),随后给出💡 检查代理/DNS:curl -sI https://blog.17lai.site——先修网络,别改断言。- 本地模式不做网络基址检查(只查本地 200),所以本地全绿不代表生产可达。
- 锁文件残留:非正常退出后可能留下
gate.lock,脚本会校验 PID 是否存活并自动接管;只有"锁里有活着的 PID"才算真并发。 - 别用
rm -rf public代替--prepare:产物没了--prepare会重建,手删只会让下一次门禁在 L1 前就失败。
8.5 小结
preflight 的职责是"要么秒级告诉我怎么修,要么放行",绝不允许跑到第 15 分钟才发现 server 没起。
9. L14 视觉断言层
用途:用 playwright 读取真实 computed style,把"位置/尺寸/颜色"变成数值断言——CSS 改动后的硬门槛。
学习路径:跑单套件 → 看重试与失败输出 → 查套件清单 → 记住 make 目标的坑
9.1 最小用法
输出(单套件通过时只有一行汇总):
结果: 18 通过, 0 失败对应命令:
node tools/visual-assert.js run --local tools/visual-assert/layout-checks.json9.2 常用参数
① --local:断言本地基址(生产模式则不带)
输出:
✅ L14 layout-checks:18 项全部通过对应命令:
node tools/visual-assert.js run --local tools/visual-assert/layout-checks.json② 失败明细(只打前 3 条 ❌,避免刷屏)
输出:
❌ L14 footer:<M> 失败(共 <N> 项)|明细:❌ footer-margin 期望 24px 实际 16px ❌ footer-padding ...对应命令:
node tools/visual-assert.js run --local tools/visual-assert/footer.json③ 单文件超时(默认 600s,可用 L14_TIMEOUT 覆盖)
输出:
❌ L14 post-layout:超时或异常(exit 124)对应命令:
L14_TIMEOUT=900 node tools/visual-assert.js run --local tools/visual-assert/layout-checks.json9.3 全部参数
tools/visual-assert/*.json 共 14 套件,门禁 L14 跑其中 12 套:
layout-checks reward-modal search-modal dark-mode homepage about
footer 404 i18n tags nav-scrolled toc-expanded刻意不激活(需手动跑):mobile-menu(与 layout-checks 的 mobile-menu-functional 重复)、post-layout(与 layout-checks 部分重叠)。
| 属性 | 值 |
|---|---|
| 语义 | 生产阻塞(失败即 FAIL);本地默认跳过 |
| 本地启用 | RUN_L14=1 tools/tests/release-test.sh |
| 强制跳过 | SKIP_L14=1 |
| 失败处理 | 某套件失败自动重试一次(负载抖动),仍失败才 FAIL,并打印细节 |
| 耗时 | 约 299s(重) |
⚠️
make visual-assert陷阱:该 make 目标依赖build,build 会原地改写themes/matery/_config.yml占位符 → 触发 L5 版本一致性 4 项失败。正确用法:node tools/visual-assert.js run --local [tools/visual-assert/<suite>.json],或交给 L14 层跑。
9.4 边界与坑
- 必须
--local:不带时断言的是生产基址,本地改动永远测不到。 - playwright 目录找不到就整层 skip:
⏭️ L14 visual-assert(playwright 不可用)——看到 skip 别当成通过。 - 本地 server 不可达也 skip:
⏭️ L14 视觉断言(localhost:4000 不可达 → HTTP 000)。 - 别用
make visual-assert做验证:污染_config.yml的代价是后面 L5 四项全红,排查方向会被彻底带偏。
9.5 小结
L14 是唯一"能证明像素没错"的层;它慢、它重、它默认不在本地跑——正因如此,CSS 改动后才必须手动补上这一层。
10. TC 索引与 git hooks
用途:让"测试文件清单"和"用例文档"永不脱节。
学习路径:看扫描输出 → 看生成/校验两条命令 → 查机制表 → 记住 hook 要启用一次
10.1 最小用法
输出(生成时打印扫描统计,末尾写文件):
📊 扫描结果:13 层 / 42 测试文件 / 3 lib 文件 / 284 断言 / 406 用例名
✅ TC 索引已写入(13 层 / 284 断言)对应命令:
node tools/tests/gen-tc-index.js10.2 常用参数
① 校验(门禁调用的就是这条)
输出(一致时一行):
✅ TC 索引与测试文件一致对应命令:
node tools/tests/gen-tc-index.js --check② 不一致时(exit 1)
输出(门禁汇总段的那条 ❌):
❌ TC 清单不一致(运行 node tools/tests/gen-tc-index.js 更新)对应命令:
make tc-index-check③ 提交时自动重生成(hook)
输出(pre-commit 自动 stage,提交照常成功):
🔧 检测到 tools/tests/ 变更,重新生成 TC 清单...
✅ TC 索引已写入(13 层 / 284 断言)对应命令:
git config core.hooksPath .githooks # 新克隆执行一次10.3 全部参数
测试的权威来源是 tools/tests/**;生成的 TC 表位于 docs/superpowers/specs/2026-08-06-layout-test-cases.md 的 <!-- GENERATED:START --> 与 <!-- GENERATED:END --> 之间。
| 机制 | 说明 |
|---|---|
node tools/tests/gen-tc-index.js / make tc-index | 重新生成 TC 清单 |
make tc-index-check | 校验清单是否落后(不一致 → exit 1) |
.githooks/pre-commit | 提交涉及 tools/tests/ 时自动重生成并 stage 该文档 → 提交陈旧索引在结构上不可能 |
门禁 --check | 汇总前跑 gen-tc-index.js --check(覆盖 --no-verify 等旁路) |
首次克隆需启用一次:git config core.hooksPath .githooks。门禁报"TC 清单不一致"时,跑 make tc-index 再提交(旁路 git commit --no-verify)。
扫描统计里的具体数字(层/文件/断言/用例名)随测试增长而变,以实际输出为准,文档不写死。
10.4 边界与坑
make tc-index会改文档:跑完记得一起提交,否则下一次--check照样红。--no-verify会绕过 hook 但绕不过门禁:门禁里那条--check是最后一道网。- GENERATED 区间外的手写内容不会被覆盖:可以放心在表外补说明,生成器只重写标记之间。
10.5 小结
测试文件是源,TC 文档是镜像;hook 保证镜像实时,门禁 --check 保证镜像可信。
11. flake 可见化与基线参考值
用途:把"重试才通过"的项目显式列出来——不拦发版,但不许悄悄消失。
学习路径:看一次 flaky 汇总 → 看三档基线 → 查基线表 → 记住数字会变
11.1 最小用法
输出(干净的一轮——没有 flaky 段):
测试结果: PASS=<N> FAIL=0
=============================================
🎉 全部通过!对应命令:
tools/tests/release-test.sh11.2 常用参数
① 有 flaky 时(多一行 ⚠️,PASS/FAIL 不变)
输出:
⚠️ 本次门禁 flaky: 1 项(L14 nav-scrolled)
测试结果: PASS=<N> FAIL=0
🎉 全部通过!对应命令:
tools/tests/release-test.sh② 三档基线
输出(本地档的固定标志):
⏭️ 已跳过 L10(远程 SW 冒烟)与 L11(playwright 集成)—— 本地迭代模式;
测试结果: PASS=<N> FAIL=0
🎉 全部通过!对应命令:
tools/tests/release-test.sh --local11.3 全部参数
| 基线 | 预期 | 命令 |
|---|---|---|
| 本地 | 全部通过(0 失败) | tools/tests/release-test.sh --local |
| 本地 + L14 | 全部通过(0 失败,含视觉断言) | RUN_L14=1 tools/tests/release-test.sh |
| 生产 | 全部通过(0 失败) | tools/tests/release-test.sh(发版后) |
具体 PASS 条数随版本增长(每新增一条断言即变化),以实际输出为准,不写死数字。
3 个 lib:
assert.sh(82 行)、retry.sh(114 行,负责 flake 追踪)、preflight.sh(230 行)。browser-env.js为 playwright 共享辅助(见 §6)。
11.4 边界与坑
- flaky 不是通过:它只说明"重试后过了",同一项连续两轮出现 flaky 就该当 bug 查。
- 不同档位 PASS 数不可比:
--local少跑 L10/L11/L14,PASS 自然低;拿数字对比毫无意义,只看FAIL=0。 - 基线要重跑不要引用:发版后的生产门禁必须现场跑一次,旧日志里的 PASS 不构成证据。
11.5 小结
看门禁结果只看两件事:FAIL=0 和有没有 flaky 段。前者是门禁,后者是技术债提醒。
12. 如何新增一个测试用例
用途:从"写一个测试文件"到"挂进门禁并被 TC 索引收录"的完整路径。
学习路径:看新测试自己的输出 → 看挂入后的门禁输出 → 用模板 → 记住 mock 与判据
12.1 最小用法
输出(新文件写完,先自己跑通——这一行就是门禁要匹配的契约):
✅ should render expected element
结果: PASS=1 FAIL=0对应命令:
node tools/tests/my-feature.test.js门禁里复用同一契约(run_test 用法):
run_test "my-feature" "node tools/tests/my-feature.test.js"run_test 是 release-test.sh 内的辅助函数:跑命令、捕获输出、按 PASS=N FAIL=0 判据决定该层是否通过。
12.2 常用参数
① 归 L13(纯单测/静态,无浏览器)
输出(挂入后门禁多一行 ✅):
✅ 单测 my-feature.test.js对应命令:
# 在 tools/tests/layers/L13_units.sh 的 for 清单里追加文件名即可(unit/ 前缀自动判断)
my-feature.test.js \② 归 L11(需 chromium + server)
输出(判据是 PASS=N FAIL=0):
✅ my-interaction 通过对应命令:
retry_match "my-interaction" "node tools/tests/my-interaction.test.js" "PASS=.*FAIL=0" # 写进 layers/L11_integration.sh③ 本地验证(只跑 L13 + curl 断言,最快闭环)
输出:
测试结果: PASS=<N> FAIL=0
🎉 全部通过!对应命令:
tools/tests/release-test.sh --local④ 同步 TC 索引
输出:
✅ TC 索引与测试文件一致对应命令:
make tc-index12.3 全部参数
步骤
- 确定测试类型:
- 纯单测 / 静态断言(无浏览器)→ 归 L13(node 直跑)
- playwright 集成(需 chromium + server)→ 归 L11
- 创建测试文件
tools/tests/xxx.test.js(node:test 或 playwright) - 外部依赖测试:用
retry_run/retry_match包装(lib/retry.sh),全失败自动分类降级 - playwright 测试:用
lib/browser-env.js的interceptThirdParty+collectPageErrors统一错误归属 - 注册到对应层脚本(
tools/tests/layers/L11_integration.sh或L13_units.sh),输出格式对齐(PASS=N FAIL=0或N 通过 / 0 失败) - 本地验证:
tools/tests/release-test.sh --local(跑 L13 + curl 断言);发版门禁跑全量 - 同步 TC 索引:新增/改动后必须让 TC 索引同步(
.githooks/pre-commit自动完成;或手动make tc-index)——否则门禁--check报"TC 清单不一致"
代码模板
L13 单测模板(node:test,无浏览器依赖):
// tools/tests/my-feature.test.js
const { describe, it } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const PUBLIC = path.resolve(__dirname, '../../public');
describe('my-feature', () => {
it('should render expected element', () => {
const html = fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
assert.ok(html.includes('expected-marker'), 'missing expected marker');
});
});
// 末尾输出(release-test.sh 通过此行判定)
const pass = 1, fail = 0;
console.log(`\n结果: PASS=${pass} FAIL=${fail}`);
process.exit(fail > 0 ? 1 : 0);L11 playwright 模板(需 chromium + server):
// tools/tests/my-interaction.test.js
const { chromium } = require('playwright');
const { interceptThirdParty, collectPageErrors } = require('./lib/browser-env');
const BASE = process.env.TEST_BASE || 'http://localhost:4000';
let browser, page;
let pass = 0, fail = 0;
async function setup() {
browser = await chromium.launch();
page = await browser.newPage();
await interceptThirdParty(page);
}
async function testFeature() {
const env = collectPageErrors(page, BASE);
await page.goto(`${BASE}/posts/xxxxx/`, { waitUntil: 'networkidle' });
const el = await page.$('.my-feature');
if (el) { pass++; } else { fail++; console.error('FAIL: .my-feature not found'); }
// 检查无 JS 异常
if (env.jsErrors.length > 0) { fail++; console.error('FAIL: JS errors', env.jsErrors); }
else { pass++; }
}
(async () => {
await setup();
await testFeature();
await browser.close();
console.log(`\n结果: PASS=${pass} FAIL=${fail}`);
process.exit(fail > 0 ? 1 : 0);
})();挂入门禁:在哪里加一行
L13(node 单测):在 tools/tests/layers/L13_units.sh 的 for t in \ 清单里追加一行文件名(目录前缀会自动判断 unit/ 或根目录)。
L11(playwright 集成):在 tools/tests/layers/L11_integration.sh 追加:
retry_match "my-interaction" "node tools/tests/my-interaction.test.js" "PASS=.*FAIL=0"
retry_match包装外部依赖测试——网络抖动时自动重试 3 次,全失败降级为 warn 而非 fail(见 §5)。
完整示例:新增一个「侧边栏折叠」测试
- 需求:验证文章页侧边栏在窄屏下自动折叠
- 类型:需浏览器 → L11 playwright
- 创建
tools/tests/sidebar-collapse.test.js(用上面 L11 模板) - 挂入:在
layers/L11_integration.sh加retry_match行 - 验证:
tools/tests/release-test.sh --local?——不行,本地跳过 L11,改为直接node tools/tests/sidebar-collapse.test.js,或--skip-l10保留 L11 - TC 同步:
make tc-index(或等 pre-commit hook 自动完成)
断言输出约定
release-test.sh 按输出模式判定通过/失败,测试文件末尾必须输出可匹配的汇总行:
console.log(`\n结果: PASS=${pass} FAIL=${fail}`);
process.exit(fail > 0 ? 1 : 0);测试用例权威清单
docs/superpowers/specs/2026-08-06-layout-test-cases.md(全量 layout 测试用例)+ docs/testing/TESTING-PYRAMID-GUIDE.md(测试方法论)——新增功能后同步更新用例清单与 release-test.sh 覆盖。
12.4 边界与坑
mock 必须贴合真实 Hexo 对象(同 §4.4):普通数组 mock 会让单测全绿而构建 0 字节,toArray() 缺失测不出来。
新增后最容易忘的一步:
输出(漏了 TC 同步,门禁最后一条会红):
❌ TC 清单不一致(运行 node tools/tests/gen-tc-index.js 更新)对应命令:
node tools/tests/gen-tc-index.js --check别把 playwright 测试塞进 L13:无浏览器环境下必失败,会拖垮整层。
12.5 小结
新增一个测试 = 一个文件 + 一处挂载 + 一次 TC 同步;三件事少做一件,门禁就会以"不一致"或"必失败"的方式提醒你。
13. 性能验证(LCP 基线,手工)
用途:LCP 不在门禁里,但必须有可复现的对照基线。
学习路径:看基线结论 → 看复现命令 → 查手段与门槛 → 记住 noscript 陷阱
13.1 最小用法
输出(基线文档给出的移动端结论——这就是"优化前"的对照值):
**移动端 LCP 4.6 s / 5.3 s 均远超 2.5 s 门槛**,与 Search Console「40 个 URL 的 LCP > 2.5s」的字段结论方向一致。对应命令:
grep -n '移动端 LCP' docs/analysis/LCP-V2-BASELINE-2026-09-18.md13.2 常用参数
① 看基线表结构(页面 / LCP / FCP / TBT / CLS / 性能分 / LCP 元素)
输出:
| 模式 | 页面 | LCP | FCP | TBT | CLS | 性能分 | LCP 元素 |对应命令:
grep -n '| 模式 | 页面 | LCP' docs/analysis/LCP-V2-BASELINE-2026-09-18.md② 看运行间方差(决定验收口径)
输出:
- mobile LCP 两组分别为 **4.6/5.3 s**(§2.2)与 **3.7/3.6 s**(本组)——**运行间方差约 ±0.9 s**,与 mobile 节流下的固有噪声一致。**B 验收必须用多组采样的中位数,勿用单次值**。对应命令:
grep -n '运行间方差' docs/analysis/LCP-V2-BASELINE-2026-09-18.md13.3 全部参数
- 基线文档:
docs/analysis/LCP-V2-BASELINE-2026-09-18.md(资源计数 / Lighthouse desktop+mobile / LCP 元素身份 / §5 复现命令 / §7 优化实施) - 优化四手段(2026-09-18~20):关键 CSS preload · highlight 按 layout 条件加载 · markmap 本地化+异步 · 文章页首屏图 preload
- 门槛:mobile LCP ≤ 2.5s(Search Console「40 个 URL 超标」为观测口径)
- 触发条件:改动 head 内资源加载顺序/条件(CSS/JS/图片)后必须复测
13.4 边界与坑
- 陷阱:noscript 不剥离会让资源计数虚高 4 条/页(基线文档 §1.3),任何复测前必须先剥离。
- 单次采样不可作为验收值:mobile 节流下运行间方差约 ±0.9 s,必须多组取中位数。
- LCP 元素身份不稳定:首页 LCP 元素在运行间会漂移,比较时对齐"元素身份 + 数值"两项,别只看数字。
- LCP 不进自动化门禁是刻意的:它需要 Lighthouse 真机采样 + 网络条件控制,断言型测试表达不了。
13.5 小结
性能验证 = 基线文档 + 复现命令 + 中位数口径;三者缺一,得到的"优化结果"都不可信。
⚠️ 已知坑
坑 5:改配置后必须 clean 构建
输出(旧缓存下的典型症状:L1 绿,功能断言红):
🔨 L1: 构建验证
✅ 构建成功 (hexo g Success)
...
❌ 搜索入口对应命令:
npm run clean && npm run build # 或 tools/tests/release-test.sh --prepare原因:本地预览使用旧缓存产物,测试断言的是旧版行为。
坑 6:source/_data/* 增量缓存误判
输出(L2 断言页面对不上):
❌ /friends/ → 200 但页面无友链内容对应命令:
rm -f db.json && npm run build # 容器环境用 tools/cicd.sh -d(内置 clean)原因:Hexo 增量缓存误判「已缓存未变化」。
附:测试 ↔ 功能对照表
用途:想知道"某个功能有没有被自动测试覆盖"?在这里按功能名查找。
| 功能 | 测试文件 | 层 | 验证什么 |
|---|---|---|---|
| 页面特效(樱花/雪花/烟花) | effects-integration.test.js | L11 | 特效脚本按门控加载,不报错 |
| 视频背景 | videobg-integration.test.js | L11 | 门槛/明暗池/webm 契约/禁用态 |
| 词云 | wordcloud.test.js | L11 | 词云渲染 / 去 jQuery / TOC 防闪烁 / 明暗切换 |
| 多语言 | i18n.test.js | L11 | MATERY_I18N 注入 + 按钮文案 + en 翻译 |
| 音乐 APlayer | musics-integration.test.js | L11 | 多域名 failover |
| 懒加载 | lazyload-integration.test.js | L11 | handleCard 三分发(51 断言) |
| 悬浮面板 | floating-panel-integration.test.js | L11 | 开关 / 字体缩放 / 持久化 |
| 内容宽度 | content-width.test.js | L11 | 4K/FHD/2K/单栏封顶 |
| 阅读模式 | readmode-integration.test.js | L11 | 全屏进入/退出 / 悬浮球 / 代码全屏 / 展开 |
| Wiki 样式 | wiki-styles.test.js | L11 | note / ::: 容器色 + 居中 + 宽度一致 |
| codeWidget 幂等 | code-widget.test.js | L11 | 二次调用后单次点击仍生效 |
| 事件总线 | core-events-integration.test.js | L11 | 事件总线 / 进度条联动 |
| Wiki prism | wiki-prism-integration.test.js | L11 | 代码块 prism + line-numbers |
| 打赏 / 繁简 | misc-interactions.test.js | L11 | 打赏弹窗 / 繁简转换 |
| 客户端语言策略 | lang-client-strategy.test.js | L11 | 全站跟随 / 爬虫跳过 / 面板回显 |
| 富内容渲染 | rich-content-integration.test.js | L11 | 公式 / mermaid / markmap / 打字机 / 分享 / Live2D |
| Wiki 多语言 | wiki-integration.test.js | L11 | 多语言 wiki 可达 / 侧栏 / 语言切换 |
| 灯箱 | lightbox.test.js | L11 | 相册/画廊图片放大交互 |
| 卡片 hover | gallery-hover.test.js | L11 | 图库/卡片 hover 动画 |
| TOC 折叠 | unit-toc.test.js | L13 | collapseDepth 0||6 falsy 陷阱 |
| 搜索 | unit-search.test.js | L13 | 三引擎配置 |
| 多语言配置 | unit-multilang.test.js | L13 | 语言配置解析 |
| 加密扩展 | unit-encrypt2.test.js | L13 | 片段 + wiki 加密 |
| wiki helper | unit-wiki-helper.test.js | L13 | 语言解析 / 侧栏渲染 / canonical 回落 |
| 侧栏 i18n | unit-sidebar-i18n.test.js | L13a | 查表 + 回退链 |
| 侧栏 key | unit-sidebar-keys.test.js | L13a | 门禁校验器 4 wiki × 9 语言 |
| 回落链 | lang-fallback.test.js | L13 | 4 级回落(13 断言) |
| i18n 核心 | unit-i18n-core.test.js | L13a | resolveI18n 四级链 + cfg 防御 |
| i18n helper | unit-i18n-helper.test.js | L13 | t() helper fallback |
| ECharts | echarts-charts.test.js | L13 | 图表渲染 |
| Banner 背景 | banner-bg.test.js | L13 | banner 与背景偏好联动 |
| 首页结构 | homepage.test.js | L13 | 首页 DOM 结构 |
| 图片尺寸 | imgsize-unit.test.js | L13 | normalizeImgUrl + 尺寸读取逻辑 |
| 独立页 | unit-standalone.test.js | L13 | 排除法判定 + 语言前缀豁免 |
| markmap 拆除 | unit-markmap-prune.test.js | L13a | 无脑图页面拆插件注入(6 用例) |
| 「更新于」 | unit-updated.test.js | L13a | 取 git 最后提交时间(4 用例) |
| 加密体系 | unit-encrypt.test.js | L13a | AES-GCM + PBKDF2 闭环(9 用例) |
| CSS 架构 | unit-css-architecture.test.sh | L13 | 编译产物关键选择器 |
| 版本号 | unit-version.test.sh | L13 | 递增进位 |
| 重试库 | unit-retry.test.sh | L13 | 退避重试 |
| 部署目标 | unit-deploy-target.test.sh | L13 | hexo/hoback 双激活 |
附:三重定位自查清单
新增/修改一篇「hexo主题功能测试」系列文章时,检查以下三项:
| # | 检查项 | 通过标准 |
|---|---|---|
| ① | 可验证 | 每个功能项有明确的输入 → 期望输出(自动化断言标记或人工核对标准) |
| ② | 有效果 | 每个功能项描述了渲染后的实际效果(文字描述、示例渲染、或截图) |
| ③ | 会使用 | 每个功能项有语法/配置步骤 + 注意事项(参数顺序、前置条件、常见坑) |
快速判断:如果一个功能"只列了名字没说怎么验证"→ 缺 ①;"只列语法不给效果"→ 缺 ②;"没说怎么用或有歧义"→ 缺 ③。
附:测试体系速查表
| 命令 | 用途 |
|---|---|
tools/tests/release-test.sh | 发版全量门禁(L1-L14) |
tools/tests/release-test.sh --local | 本地迭代(端口自适应,跳 L10/L11/L14) |
tools/tests/release-test.sh --prepare | 自动就绪环境(容器 + 构建 + 复检) |
RUN_L14=1 tools/tests/release-test.sh --local | 本地启用 L14 视觉断言 |
make tc-index / make tc-index-check | 生成 / 校验 TC 清单(.githooks/pre-commit 自动同步) |
tools/tests/run-tests.sh | 已于 2026-09-13 删除(与 L11 功能重叠);等价能力见 tools/tests/release-test.sh |
node tools/tests/gen-tc-index.js | 生成/校验 TC 清单(make tc-index) |
make test-pwa | Vercel 测试域 SW 冒烟(7 页)+ 交互验证 |
make visual-compare / visual-assert | 视觉回归(playwright 数值断言);⚠️ make visual-assert 含 build 依赖会污染 _config.yml,优先 node tools/visual-assert.js run --local |
tools/visual-regression.sh report | 视觉对比报告(20 页面 × 4 断点) |
参考&致谢
系列教程
Hexo 主题功能测试系列
- Hexo主题功能测试-布局与页面篇 —— 页面布局、导航、侧边栏、卡片等结构功能
- Hexo主题功能测试-交互视觉篇 —— 代码高亮、图片灯箱、动画、明暗切换等交互
- Hexo主题功能测试-内容tag篇 —— note / tabs / timeline / label / button 等 tag 插件
- Hexo主题功能测试-Markdown语法篇 —— Markdown 渲染与扩展语法
- Hexo主题功能测试-自动化测试篇 —— 主题功能的自动化测试方案

