Storybook 沙箱 Docgen 基线测试:基于真实构建产物的组件文档快照校验机制
导读
docgen-harness 是 Storybook 仓库内一套面向组件文档(docgen)生成器的测试框架,而 sandbox-baselines(沙箱基线)是其"整条链路"维度的核心防线:对每一个启用了实验性 Docgen Server 的沙箱模板,build-storybook 构建后每个组件都会落盘一份 DocgenPayload 快照,这套工具把这些快照以"每个组件一个 JSON"的形式固化到仓库中,并提供精确到组件字段的比对与重录流程。本文基于仓库内 code/lib/docgen-harness/src/sandbox-baselines/README.md 展开,结合同目录源码与测试,说明基线数据从哪来、覆盖哪些模板、如何更新、失败如何分级解读,帮助贡献者在改动文档提供方(provider)、预设链(preset chain)或索引逻辑时第一时间看到可审查的差异,而不是靠人工在构建产物里"找变化"。
一、沙箱基线要解决什么问题:单组件 fixture 之外的另一半
在进入实操之前,先理解这套机制在测试体系中的位置。
docgen-harness 中紧邻 sandbox-baselines 的 fixture 套件(src/angular/__testfixtures__)用来证明"提取器"(extractor)能正确处理各种刻意编写的组件写法——它们输入的是针对测试编写的组件源码,验证的是提取算法本身。
而 sandbox baselines 覆盖的是另一面:对于一个完整的沙箱工程,文档提供方在真实环境里到底产出了什么。这个产出是经过完整路径解析的:
- 真实 preset 链(real preset chain);
- 真实的 story 索引(story index);
- 真实的 Compodoc 运行(对 Angular 而言)。
从 README 的表述看,组件重名(name collisions)、无法解析的 import、tsconfig 覆盖缺口(tsconfig coverage gaps),这类问题只会在"整个沙箱"的尺度上暴露出来,任何单组件 fixture 都无法复现。因此,基线机制本质上是对 fixture 单元测试的互补:前者验证"提取器能不能读懂组件",后者验证"提供方/预设/索引在真实工程中能否把组件找全并正确文档化"。
二、数据从哪来:一次构建,每组件一份快照
2.1 产物目录约定
沙箱基线不是对内存中对象的录制,而是读取 build-storybook 的真实静态产物:
- 当沙箱 main 配置同时开启
features.experimentalDocgenServer与features.componentsManifest时,build-storybook会按组件把 docgen 快照写入storybook-static/services/core/docgen/目录,一个组件一份 JSON。
在 read-static-docgen.ts 中,该相对目录被定义为常量 DOCGEN_SNAPSHOT_DIR = join('services', 'core', 'docgen'),实际路径为 storybook-static/services/core/docgen/。readStaticDocgen() 会:
- 读取该目录下所有
.json文件; - 对每个文件按
{ components: Record<string, DocgenPayload> }结构解析; - 逐条把组件 payload 转成基线条目;
- 按组件 id 做 key 排序后返回(见 read-static-docgen.ts),保证重录时 diff 可读。
2.2 只保留可移植字段:把引擎私有数据挡在仓库外
DocgenPayload 带有索引签名,例如 Angular 提供方会把原始 Compodoc 条目挂在 payload 上——在普通沙箱里这份原始文本可达 约 117KB。基线机制采用 allow-list(白名单)而非 exclude-list,只保留契约内的可移植字段(见 read-static-docgen.ts):
const PORTABLE_FIELDS = [
'id',
'name',
'path',
'description',
'summary',
'jsDocTags',
'argTypes',
'subcomponents',
'error',
] as const satisfies readonly (keyof DocgenPayload)[];
基线条目类型 SandboxBaseline 即 Pick<DocgenPayload, ...>(仅含上表字段)。这样,依赖引擎的扩展数据(大量 sourceCode 等)不会进入仓库,基线只守护真正跨引擎的文档契约。
以 Angular 沙箱中的一个枚举组件基线为例(示例基线文件),其 argTypes 携带了 description、table.category、table.type.summary、type.name、type.value(枚举取值列表)等结构化信息——这些正是后续比对时"一个 arg 消失"或"默认值丢失"可以精确报警的前提。
2.3 路径归一化:让基线与机器无关
沙箱在不同开发机与不同 CI 上路径各不相同,因此基线录制时会把错误消息等文本中的绝对沙箱路径统一改写为 <sandbox> 令牌。实现上 normalizePaths 同时处理 原生拼写与 POSIX 拼写(\ 与 / 两种写法都做替换),并在 normalizeDeep 中对字符串、数组、对象递归完成归一化——从而一份在某平台录制的基线可以在另一平台正常校验。
2.4 通过全局引用的组件会被跳过
沙箱中的共享模板故事(monorepo 的共享 template stories)往往通过 globalThis.__TEMPLATE_COMPONENTS__.* 引用组件,此时解析器既没有 import 可跟随,也没有可扫描的文件,这类组件按构造必然报错(error payload),它们反映的是"模板故事装载机制",与 docgen 本身无关。
isGloballyReferenced 通过 payload.name.startsWith('globalThis') 识别并跳过这类条目(见 read-static-docgen.ts)。跳过它们的量化效果:
- Angular 沙箱原始记录组件 111 个 → 37 个;
- 其中 30 个被正常文档化,7 个是声明在故事文件中的 Angular 类(inline class)。
2.5 空录制的保护
若 docgen 目录不存在、或读不到任何快照、或所有组件都属全局引用,readStaticDocgen 会直接抛错("No docgen snapshots at ..." / "refusing to record an empty run"),绝不静默记录一个空集——见 read-static-docgen.ts。
三、覆盖哪些模板:由模板定义本身推导,无需人工同步
基线覆盖范围不是在 harness 里维护一份清单,而是从模板定义本身读取——凡是 main 配置同时打开 experimentalDocgenServer 与 componentsManifest 的沙箱模板都会被纳入。
在 sandbox-templates.ts 中两个特性被定义为常量:
const DOCGEN_SERVER_FEATURES = ['experimentalDocgenServer', 'componentsManifest'] as const;
而 docgenServerTemplates() 遍历全部模板,用 enablesDocgenServer 过滤出两个特性均为 true 的模板集合。也就是说:为某个模板打开这两个 flag,就足以把它纳入基线覆盖,且没有任何副本需要保持同步。
反过来也有一条硬性约束:一个已打上 flag 却还没有任何基线记录的模板,校验运行会失败而不是悄悄跳过——见 run.ts 中"no baselines committed ... Record them with ..."的错误提示。
当前仓库 __baselines__ 目录下实际固化了两套沙箱基线(可当作该机制的现状示例):
angular-vite-docgen-server-ts(37 个组件级 JSON,含example-button、example-header、example-page及大量stories-frameworks-angular-vite-*条目);vue3-vite-docgen-server-ts(含defineModel、defineSlots、Option API、TS 引用类型等 vue 组件元数据场景)。
四、如何更新基线:verify 与 update 两条命令
4.1 工作流命令
从 README 与 run.ts 的文件头注释可得到完整流程:
# 1. 构建沙箱(产出 docgen 快照)
yarn task build --template <template> --start-from auto
# 2. 进入 docgen-harness 包
cd code/lib/docgen-harness
# 3. 校验每一个 server-docgen 模板
yarn baselines:sandbox
# 4. 审阅 diff 后重录
yarn baselines:sandbox --update
其中 baselines:sandbox 脚本定义于 package.json:
"baselines:sandbox": "node --import jiti/register ./src/sandbox-baselines/run.ts"
4.2 命令行选项
入口通过 zod schema 校验参数(见 options.ts),支持三个选项:
| 选项 | 类型 | 说明 |
|---|---|---|
--template <key> |
string | 只处理某一个模板;缺省时处理所有开启 server-docgen 的模板 |
--sandbox <dir> |
string | 指向一个非默认位置的沙箱目录 |
--update / -u |
boolean | 重录模式(否则为校验模式) |
实现上 parseBaselineRunOptions 对空字符串的 --template= 做了显式拒绝——调用方点名了模板却收到空值,会被当作"未指定"而把运行范围悄悄放大到全部模板,因此必须报错而不是当作缺省。
沙箱目录与基线目录的命名约定见 run.ts:二者都把模板 key 中的 / 替换为 -,分别落到沙箱目录 SANDBOX_DIRECTORY/<key 改写> 与 __baselines__/<key 改写>。
CI 会在逐个构建完每个被覆盖的沙箱后执行校验形态(verify)的命令。
4.3 原子化的写入策略
重录时 write 采用"先写暂存、再整体替换、旧版备份"的三段式策略,避免已提交基线出现"只换了一半"的中间态:
- 新集合先写入同级目录
<baselineDir>.staging; - 旧基线目录整体改名(
renameSync)为.backup而非直接删除; - 再把 staging 改名为正式目录;若换名本身失败,会把 backup 恢复回来;
finally中清理 staging 与 backup。
此外,每条基线以 key 排序后的 JSON 落盘: stable-stringify.ts 递归地对对象键做 localeCompare 排序后再 JSON.stringify(缩进 2),使"重录产生的 diff 反映内容变化,而不是生产者恰好输出的键顺序";配套测试也专门验证"忽略键顺序,因为生产者可能在不改变语义的情况下改变顺序"(见 compare-baselines.test.ts)。
五、如何阅读一次失败:精确匹配门禁与两级严重度
5.1 门禁语义:精确匹配,不做"更好就放行"
基线的比对门禁是精确匹配:候选产物与已提交基线有任何差异都会导致运行失败——即使是明确无误的改进(unambiguous improvement)也一样。
原因在于(README 的原话精神):沙箱基线是"整条提供方链路产出的录像",链路上每一步移动都值得 reviewer 的目光审视,因此不存在"当前值或更好即放行"的豁免通道。该语义对应 run.ts:有 findings 就报错退出并提示"一旦理解 diff,用 --update 重录"。
5.2 两级严重度:regression 与 change
README 明确指出:severity 告诉你看到的是哪一类失败,而不是是否阻塞。其语义在 compare-baselines.ts 中被建模为 BaselineFinding.severity 的联合类型:
regression —— docgen 被明确证实的变差,这类结果要的是修复而不是重录。对应场景(见 compare-baselines.ts 及测试 compare-baselines.test.ts):
- 组件或某个 arg 消失(
component-removed、argtypes的 lost-arg); - 组件不再被文档化(
docgen-lost:原本记录为"已文档化"的组件现在报错或产不出 argTypes); - 记录的默认值(default)消失。
change —— 其余一切差异,属于中性漂移,审阅 diff 后用 --update 采纳即可:
- 新增组件、组件由未文档变为已文档(
docgen-gained); - 新增 arg、某个类型被更精确地解析、某段 prose 被改写;
- 其它字段级差异(
field-changed)。
在 compare-baselines.ts 中,判断 argTypes 差异是否为 regression 借助了 compareArgTypes(来自 ../compare/argtypes.ts),并把 lost-arg、lost-default 映射为回归措辞;compare-baselines.test.ts 中还特别覆盖了两个边界:新增 arg 被当作 change 而不是需要修复的失败(L115-L122),以及类型获得更高保真度被判为 change 而非 regression(L126-L136)——因为把改进称为 regression,会误导 reviewer 去找一个并不存在的 bug。
5.3 输出格式:回归在前
formatFindings 按 ['regression', 'change'] 顺序分组输出,回归永远排在前面;同组内每行以 - <component> [<kind>] <message> 呈现,例如"was documented with N arg(s), now errors: ..."。配套测试 it('lists regressions before changes') 也验证了这一排序约定。相比笼统的"something moved",每条 finding 都指明组件、差异类别与前后内容摘要,重录时能直接生成可审查的 diff。
六、运行流程的实现骨架(源码印证)
整个 CLI 的入口逻辑集中在 run.ts 的 main() 中:
const { template, sandboxDir, update } = parseBaselineRunOptions(process.argv.slice(2));
const templates = template ? [template] : docgenServerTemplates();
- 若
docgenServerTemplates()返回空集,会打印"没有任何沙箱模板启用 server docgen……"并让process.exitCode = 1——注释明确道出缘由:在此处静默会"看起来像全数通过,实际什么都没查"; - 逐个模板执行
runTemplate,其输出先汇报读到的组件总数与其中已文档化的数量(read N component(s) from ... (M documented)); runTemplate在 update 模式下直接调用write()重录;在校验模式下读取已提交基线并与候选比对,无差异输出baselines match.,有差异输出格式化的 findings 并返回 false;- 任何模板失败都会让整个进程以非零码退出。
校验/重录两条路径、三级模块(读静态产物 → 比对 → 稳定序列化落盘)、两级严重度与按字段报告的 finding 结构,构成了一个"构建产物级、可读 diff、可自动回归检测"的闭环。
七、何时应该重录、何时应该修复
结合 README 与 run.ts 的错误提示,可提炼出明确的处置原则:
- 输出中若含
regression(组件消失、文档化丢失、arg 或默认值消失)——先别急着--update,这通常意味着提供方/索引/preset 链路出了真问题,优先修复代码; - 输出只有
change(新增组件、新增 arg、类型保真度提升、描述文字变化)——在 diff 里逐条确认符合预期后,用yarn baselines:sandbox --template <key> --update采纳; - 新模板想纳入覆盖——在模板定义的
mainConfig.features里同时打开experimentalDocgenServer与componentsManifest,然后先构建、再以--update录制首版基线,此后该模板自动进入 CI 校验范围。
# 单个模板重录的完整示例
yarn task build --template angular-vite/docgen-server-ts --start-from auto
cd code/lib/docgen-harness
yarn baselines:sandbox --template angular-vite/docgen-server-ts --update
(模板 key 的实际拼写以 sandbox-templates.ts 中定义的 key 为准;--template 与 --sandbox 也可组合使用,后者用于指向非默认路径的已构建沙箱。)
总结
docgen-harness 的 sandbox baselines 用"每个组件一份 JSON 快照 + 精确匹配比对"的方式,把文档提供方在真实沙箱里的全部产出变成版本可追踪、失败可分级、diff 可审查的回归防线。它从 storybook-static/services/core/docgen/ 读取 build-storybook 的落盘产物,只保留 DocgenPayload 的可移植字段,做沙箱路径归一化、跳过全局引用组件、拒绝空录制,覆盖范围直接由模板的 experimentalDocgenServer 与 componentsManifest 两个特性 flag 推导。理解 regression 与 change 的分野——前者是 bug 要修复、后者是漂移可 --update 采纳——是在这套机制下高效工作的关键。相关源码与测试均可从 sandbox-baselines 目录 出发继续深入研读。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0624
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00