首页
/ Dify 的 @langgenius/dify-ui 组件包测试体系:Vitest 双项目、Storybook 渲染契约与 a11y 门禁

Dify 的 @langgenius/dify-ui 组件包测试体系:Vitest 双项目、Storybook 渲染契约与 a11y 门禁

2026-09-06 15:08:26作者:晏闻田Solitary

本文以 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 自有契约归本包"这一边界来组织的。

文档给出的命令分为两层:

  1. 仓库根目录执行格式、lint 与 TypeScript 诊断

    vp check packages/dify-ui
    
  2. 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(),实例为 chromiumheadless: 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-pluginstorybookTest({ 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-reactrendervite-plus/test/browseruserEvent
  • 断言默认 type="button"、可覆盖为 submitnativeButton={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-a11yaddon-vitestaddon-docsaddon-themes 等插件链——a11yvitest 两个 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

包内三处相关配置分别对应文档中的三条规则:

  1. unit 项目默认关闭动画vitest.setup.ts 正是文档中代码片段的落地,此外它还引入 ./vitest.css 并将 document.documentElement.dataset.theme 固定为 light,保证主题变量与故事一致;

  2. Storybook 项目保留真实动画生命周期:文档明确指出 Storybook 使用其 preview setup,"must retain real animation lifecycles",因此 vitest.setup.ts 只对 unit 项目生效(见 vite.config.ts 中仅 unit 项目配置 setupFiles);

  3. 有意断言动画行为的单测可局部恢复为 false,但必须在 cleanup 中还原旧值。仓库中已有两处示范这一模式:

    • popover 测试(约 L41-L69):在测试前读取并保存 animationSettings.BASE_UI_ANIMATIONS_DISABLED,置为 false,在 teardown 中还原为 animationsDisabled
    • toast 测试(L93-L162 附近):同样先保存 animationState,测试中关闭禁用标志,结束后还原。

    这种"保存—改写—还原"的写法避免了测试间的全局状态污染,是遵循文档要求的具体实现范式。

五、端到端自查流程

综合文档与仓库配置,修改 packages/dify-ui 中的一个组件后的完整验证流程为:

  1. packages/dify-ui/ 下运行 vp test --project unit,确认新增/修改的 wrapper 契约(class 变体、透传 props、data-attribute 钩子等)通过;
  2. packages/dify-ui/ 下运行 vp test --project storybook --run,确认所有 story 的渲染契约与 a11y 检查通过(违规会因 test = 'error' 而失败);
  3. 需要交互式核对组件行为时运行 vp run storybook 打开 6006 端口的 Storybook(注意 Storybook 保留真实动画,验证 presence/transition 相关行为时不要依赖 unit 项目的动画禁用标志);
  4. 回到仓库根目录运行 vp check packages/dify-ui,完成格式化、lint 与 TypeScript 诊断;
  5. 若涉及动画相关的 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.tsxtoast/index.spec.tsx

这套体系的核心思想可以概括为:用浏览器模式统一运行时,用"项目名"划分行为归属,用 Storybook 把文档示例升级为可执行的渲染契约,用 error 级别的 a11y 检查把可访问性变成 CI 硬门禁,并用一个全局动画标志在"断言状态"与"断言动画"两类测试之间做出清晰取舍。

登录后查看全文
热门项目推荐
相关项目推荐