Dify 的 @langgenius/dify-ui 组件包测试体系:Vitest 双项目、Storybook 渲染契约与 a11y 门禁
本文以 Dify 仓库中 packages/dify-ui/docs/testing.md 这份《Testing and Development》文档为核心,系统讲解 @langgenius/dify-ui 组件包的完整测试与开发工作流:如何运行格式化/lint/类型检查与双 Vitest 项目、"Storybook 故事即渲染契约"的测试边界设计、Storybook a11y 门禁的配置细节,以及 Base UI 动画在测试中的处理策略。读完本文,你将能够在该包中正确选择测试归属(unit 还是 storybook)、运行全部测试命令、并为组件编写符合包规范的单元测试与 Storybook play 测试。
一、包的定位与命令入口
packages/dify-ui 是 Dify 工作区内的私有 UI 原语包(@langgenius/dify-ui),提供独立 UI 原语、设计 token、CSS 优先的 Tailwind 样式和 cn() 工具函数,大部分交互原语是对 Base UI headless 组件的"薄而有主见"的封装(见 README)。测试体系正是围绕"Base UI 上游行为归上游、Dify 自有契约归本包"这一边界来组织的。
文档给出的命令分为两层:
-
仓库根目录执行格式、lint 与 TypeScript 诊断:
vp check packages/dify-ui -
在
packages/dify-ui/目录下执行测试与 Storybook 命令:命令 作用 vp test --project unit运行原语(primitive)单元测试 vp run storybook启动 Storybook vp test --project storybook --run以浏览器模式运行 Storybook 组件测试 vp test同时运行两个测试项目
这些命令与包内 package.json 中的 scripts 一一对应:"test": "vp test --project unit"、"test:storybook": "vp test --project storybook --run"、"test:watch": "vp test --project unit --watch"、"storybook": "storybook dev -p 6006"、"type-check": "tsc"。也就是说,vp test 这类 vite-plus 命令既可以通过根目录直接执行,也可以落到 npm script 上日常调用,二者等价。
二、测试边界:两个 Vitest 项目,一个 Chromium 运行时
文档明确了两点架构性事实:
- 包内配置了两个 Vitest 项目(Vitest projects),二者都运行在 Playwright Chromium Browser Mode 中——项目名标识的是"行为归属方",而不是不同的运行时。
- Storybook 用于承载"有文档的组件示例":每一个 story 都是一份渲染契约(render contract),并通过 Storybook Vitest addon 运行配置的无障碍检查。当示例还承担"可见状态变化、用户交互、键盘路径、overlay 流程、表单行为、加载行为、受控状态协同"中的任一项时,应为其添加
play测试。 - 普通 Vitest 测试用于更底层的 wrapper 契约:如 class 变体(cva variants)、Base UI 透传 props、hidden-input 序列化、data-attribute 钩子、stores,以及不需要"有文档示例"覆盖的边界情况。
源码层面的项目配置印证
vite.config.ts 完整实现了这一边界:
- 顶层
test.browser开启enabled: true,provider 为playwright(),实例为chromium,headless: true,失败时自动截图(screenshotFailures: true,截图落在./.vitest-browser/screenshots); projects[0]命名unit:挂载 Tailwind 插件、开启globals、加载setupFiles: ['./vitest.setup.ts']、include: ['src/**/__tests__/**/*.spec.{ts,tsx}'],并配置浏览器 trace 在失败时保留(trace.mode: 'retain-on-failure');projects[1]命名storybook:通过@storybook/addon-vitest/vitest-plugin的storybookTest({ configDir })加载.storybook目录下的故事并生成测试;- 覆盖率由 v8 提供,
include: ['src/**/*.{ts,tsx}'],排除 stories、__tests__、themes、styles;CI 环境(process.env.CI)输出json + json-summary,本地额外输出text报告。
这条路径解释了为什么"unit 项目也要跑在浏览器里":因为组件依赖真实 DOM、CSS 布局与 Base UI 的 presence 生命周期,浏览器模式才是与生产一致的验证环境。
unit 测试实例:Button 契约
以 Button 单元测试 为例,可以看到"wrapper 契约"测试的典型形态:
- 使用
vitest-browser-react的render与vite-plus/test/browser的userEvent; - 断言默认
type="button"、可覆盖为submit、nativeButton={false}时经 render prop 渲染为非原生元素; - 断言
disabled使用原生语义(toBeDisabled()且不带aria-disabled),loading状态可通过focusableWhenDisabled={false}选择退出焦点; - 断言 loading 中的 submit 按钮不会隐式触发表单提交——这正是文档所说"不需要有文档示例的底层契约"的典型用例。
三、无障碍(a11y)门禁:test = 'error' 与唯一的 color-contrast 例外
文档对 a11y 的约定非常严格:
- Storybook 无障碍测试使用
a11y.test = 'error',任何被启用的违例会直接让测试失败; - 颜色对比度(color-contrast)是唯一被全局禁用的规则,原因是它是已知的设计 token 缺口(known design-token gap);
- 明确规定:不要新增任何全局例外;临时例外必须局部化到受影响的 story;不要用
play测试来替代一个无障碍修复。
这一约定在 .storybook/preview.tsx 中逐行落实:
a11y: {
test: 'error',
config: {
rules: [
{
id: 'color-contrast',
enabled: false,
},
],
},
},
同时该 preview 文件通过 withThemeByDataAttribute 装饰器以 data-theme 属性切换 light/dark 主题(默认 light),并以 tags: ['autodocs'] 为每个 story 自动生成文档页。而 .storybook/main.ts 声明了故事发现 glob ../src/**/*.stories.@(js|jsx|mjs|ts|tsx)、react-vite 框架,以及 addon-a11y、addon-vitest、addon-docs、addon-themes 等插件链——a11y 与 vitest 两个 addon 正是"story 即契约 + 违规即失败"机制的执行者。
实践含义:如果你在某个 story 中确实需要临时豁免某条 a11y 规则,应把该豁免写在对应 story 的局部配置里,而不是回到 preview 里再加一条 enabled: false。
四、动画测试策略:BASE_UI_ANIMATIONS_DISABLED 标志
Base UI 在卸载由 transition 驱动的原语前会等待 element.getAnimations()。当测试断言的是最终 DOM 状态而非动画行为本身时,文档要求在 Vitest setup 文件中关闭动画:
;(
globalThis as typeof globalThis & {
BASE_UI_ANIMATIONS_DISABLED: boolean
}
).BASE_UI_ANIMATIONS_DISABLED = true
包内三处相关配置分别对应文档中的三条规则:
-
unit 项目默认关闭动画:vitest.setup.ts 正是文档中代码片段的落地,此外它还引入
./vitest.css并将document.documentElement.dataset.theme固定为light,保证主题变量与故事一致; -
Storybook 项目保留真实动画生命周期:文档明确指出 Storybook 使用其 preview setup,"must retain real animation lifecycles",因此
vitest.setup.ts只对 unit 项目生效(见 vite.config.ts 中仅 unit 项目配置setupFiles); -
有意断言动画行为的单测可局部恢复为
false,但必须在 cleanup 中还原旧值。仓库中已有两处示范这一模式:- popover 测试(约 L41-L69):在测试前读取并保存
animationSettings.BASE_UI_ANIMATIONS_DISABLED,置为false,在 teardown 中还原为animationsDisabled; - toast 测试(L93-L162 附近):同样先保存
animationState,测试中关闭禁用标志,结束后还原。
这种"保存—改写—还原"的写法避免了测试间的全局状态污染,是遵循文档要求的具体实现范式。
- popover 测试(约 L41-L69):在测试前读取并保存
五、端到端自查流程
综合文档与仓库配置,修改 packages/dify-ui 中的一个组件后的完整验证流程为:
- 在
packages/dify-ui/下运行vp test --project unit,确认新增/修改的 wrapper 契约(class 变体、透传 props、data-attribute 钩子等)通过; - 在
packages/dify-ui/下运行vp test --project storybook --run,确认所有 story 的渲染契约与 a11y 检查通过(违规会因test = 'error'而失败); - 需要交互式核对组件行为时运行
vp run storybook打开 6006 端口的 Storybook(注意 Storybook 保留真实动画,验证 presence/transition 相关行为时不要依赖 unit 项目的动画禁用标志); - 回到仓库根目录运行
vp check packages/dify-ui,完成格式化、lint 与 TypeScript 诊断; - 若涉及动画相关的 DOM 状态断言,确认你的测试落在 unit 项目(setup 已自动关闭动画);若要断言动画本身,参考 popover/toast 的测试写法在局部临时恢复
BASE_UI_ANIMATIONS_DISABLED = false并保证 cleanup 还原。
关键文件索引
| 关注点 | 文件 |
|---|---|
| 本文核心文档 | testing.md |
| 命令脚本 | package.json |
| 双项目与浏览器模式配置 | vite.config.ts |
| unit 项目 setup 与动画禁用 | vitest.setup.ts |
| a11y 门禁与主题装饰器 | preview.tsx |
| Story 发现与 addon 链 | main.ts |
| unit 测试示例 | button/index.spec.tsx |
| 动画标志的局部恢复范式 | popover/index.spec.tsx、toast/index.spec.tsx |
这套体系的核心思想可以概括为:用浏览器模式统一运行时,用"项目名"划分行为归属,用 Storybook 把文档示例升级为可执行的渲染契约,用 error 级别的 a11y 检查把可访问性变成 CI 硬门禁,并用一个全局动画标志在"断言状态"与"断言动画"两类测试之间做出清晰取舍。
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 StartedRust0623
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