首页
/ Dify CLI E2E 测试套件实战:用真实 difyctl 二进制验证社区版与企业版全链路

Dify CLI E2E 测试套件实战:用真实 difyctl 二进制验证社区版与企业版全链路

2026-09-05 16:23:41作者:尤辰城Agatha

Dify 仓库中的 cli/test/e2e 目录是 difyctl 命令行工具(@langgenius/difyctl)的端到端测试套件:它不 mock 任何 HTTP 流量,而是直接调用真实的 difyctl 开发入口,针对一个存活的 Dify staging 服务器执行登录、应用发现、DSL 导入与 run 运行等全链路验证。读完本篇,你可以掌握该套件的环境变量契约(DIFY_E2E_*)、CE/EE 双版本自动适配机制、global-setup 的账号与 token 引导流程,以及如何按 [P0]、EE、本地离线三种模式执行测试并理解其背后的隔离与幂等设计。

一、测试目标与总体设计

该套件的核心定位是:验证真实的 difyctl 二进制与真实 Dify 服务器之间的集成行为。根据 cli/test/e2e/README.md 的说明,每个测试都使用一个隔离的临时配置目录(isolated temporary config directory),确保会话状态不会在测试文件之间泄漏。

从源码结构看,这套设计落在几个关键实现上:

  • 测试通过 bun bin/dev.js 启动 CLI,因此无需先执行 pnpm build,见 cli.tsBIN 常量;
  • run(argv, opts) 是核心原语,统一注入 CI=1(抑制交互提示与 spinner)、NO_COLOR=1(去除 ANSI 转义码)、DIFY_E2E_NO_KEYRING=1(强制文件型 token 存储,避免 macOS keychain 的 UI 弹窗阻塞子进程),并在提供 configDir 时设置 DIFY_CONFIG_DIR 指向隔离目录,见 run()
  • 超时策略:默认 60 秒,超时先发 SIGINT,2 秒后仍无响应再 SIGKILL,退出码记为 124。

配套的 Vitest 运行器配置在 vitest.e2e.config.ts

test: {
  environment: 'node',
  globalSetup: ['test/e2e/setup/global-setup.ts'],
  setupFiles: [], // E2E 不复用单测的 setup.ts —— 真实二进制自行初始化全局对象
  testTimeout: 120_000,      // 调用真实 staging 服务器,放宽超时
  hookTimeout: 30_000,
  retry: Number(process.env.VITEST_RETRY ?? 0), // 本地默认 0,flaky 网络由 withRetry() 精确处理
  pool: 'forks',
  fileParallelism: false,    // 顺序执行,避免 staging 上的 workspace 级冲突
  reporters: ['verbose'],
}

值得注意的一点:配置加载阶段会尝试把 cli/.env.e2e 逐行解析进 process.env(跳过注释与空行,且已存在的环境变量优先),CI 中则直接通过环境变量注入,见 vitest.e2e.config.ts

二、目录结构与各模块职责

README 给出的目录布局(并已在仓库中核实存在):

test/e2e/
├── setup/
│   ├── env.ts              — 加载并校验 DIFY_E2E_* 环境变量(CE + EE)
│   ├── global-setup.ts     — CE/EE 感知的引导:建号、铸 token、发现 workspace、导入 DSL
│   └── global-teardown.ts  — 删除运行期间创建的会话
│
├── helpers/
│   ├── cli.ts              — run()、withAuthFixture()、mintFreshToken()、injectAuth()、spawn_background()
│   ├── assert.ts           — assertExitCode、assertJson、assertErrorEnvelope、assertNoAnsi 等
│   ├── cleanup-registry.ts — registerConversation() / cleanupRegisteredConversations()
│   ├── retry.ts            — withRetry(fn, { attempts, delayMs })
│   └── skip.ts             — optionalIt()、enterpriseOnlyIt()、enterpriseOnlyDescribe()、isEE()
│
└── suites/
    ├── auth/               — login、status、use、whoami、devices、logout
    ├── config/             — config path/get/set/unset/view 与环境变量覆盖
    ├── discovery/          — get app list / 单应用 / describe / 跨 workspace([EE])
    ├── dsl/                — export studio-app
    ├── error-handling/     — 错误消息与退出码规范
    ├── framework/          — help、全局 flag
    ├── output/             — table / json / yaml 输出
    ├── run/                — run 基础、streaming、file、reasoning、HITL
    └── agent/              — agent skill workflow

对应的实际文件包括 setup/env.tssetup/global-setup.tshelpers/cli.tshelpers/skip.tshelpers/retry.ts 以及 fixtures/apps 下的 10 个 DSL 夹具(echo-chat.ymlecho-workflow.ymlfile-upload.ymlfile-chat.yml、4 个 hitl-*.ymlreasoning-chat.ymlws2-workflow.yml)。

三、CE/EE 双版本支持机制

difyctl 支持两种 Dify 版本,测试套件通过 DIFY_E2E_EDITION 自动适配:

版本 DIFY_E2E_EDITION Workspace 数量 EE 专属用例
社区版 (CE) ce(默认) 1 自动跳过
企业版 (EE) ee 2 激活

版本判定逻辑非常薄,全部集中在 env.ts

export function isEnterpriseEdition(): boolean {
  return (process.env.DIFY_E2E_EDITION ?? 'ce').toLowerCase() === 'ee'
}

[EE] 标签与声明式跳过

需要企业版特性(跨独立 workspace 切换、跨 workspace 应用查询等)的用例,命名中带 [EE] 标签,并用 skip.ts 中的包装器声明式跳过:

// helpers/skip.ts 用法
const eeIt = enterpriseOnlyIt(caps)
eeIt('[EE][P0] cross-workspace query returns apps from all workspaces', async () => {
  // test body
})
export function enterpriseOnlyIt(caps: E2ECapabilities): TestAPI {
  return optionalIt(caps.edition === 'ee') // EE 模式返回 it,CE 模式返回 it.skip
}

这里没有运行时断言:跳过是声明式的,[EE] 标签同时保证被跳过的用例在报告中可见,并支持 --testNamePattern "\[EE\]" 过滤。另外 isEE(caps) 用于普通 it 块内的内联守卫(if (!isEE(caps)) return)。

caps(即 E2ECapabilities)由 global-setup 通过 project.provide('e2eCapabilities', …) 注入到每个测试文件,其结构定义见 env.ts:除 edition 外,还包含主 token、以及为 logout/devices 这两个“破坏性”套件单独铸发的 logoutToken/devicesToken——撤销它们不会影响主 token。

四、环境变量契约与配置文件

凭证模板

复制模板并填入真实值(模板文件位于 cli/test/e2e/.env.e2e.example.env.e2e 已被 git 忽略):

cp cli/test/e2e/.env.e2e.example cli/.env.e2e
# 用真实凭证编辑 cli/.env.e2e

社区版(CE)—— 最少 3 个变量

变量 说明
DIFY_E2E_HOST 服务器 base URL(如 http://localhost
DIFY_E2E_EMAIL 账号邮箱,由 global-setup 自动创建
DIFY_E2E_PASSWORD 账号密码(发送前 Base64 编码)

企业版(EE)—— 必选变量

变量 说明
DIFY_E2E_EDITION 必须为 ee
DIFY_E2E_HOST Console/API base URL
DIFY_E2E_EMAIL 成员账号邮箱
DIFY_E2E_PASSWORD 成员账号密码

模板注释(.env.e2e.example)明确了 EE 的 workspace 契约:登录账号下必须已存在名为 auto_test0(主 workspace,承载 8 个 fixture 应用)和 auto_test1(次 workspace,承载 ws2-workflow.yml 一个应用)的两个 workspace。任一缺失时 global-setup 不做 DSL 导入;应用按精确名称查找,存在则跳过导入。

可选覆盖变量(两个版本通用)

变量 说明
DIFY_E2E_TOKEN 预铸 bearer token,跳过 device-flow 铸 token
DIFY_E2E_SSO_TOKEN 外部 SSO bearer token(dfoe_ 前缀)
DIFY_E2E_CONSOLE_URL Console URL 与 DIFY_E2E_HOST 不同时使用
DIFY_E2E_WORKSPACE_ID / DIFY_E2E_WORKSPACE_NAME 覆盖主 workspace ID / 名称
DIFY_E2E_WS2_ID / DIFY_E2E_WS2_APP_ID 覆盖次 workspace(EE)ID / 应用 ID
DIFY_E2E_CHAT_APP_ID echo-chat 应用 ID
DIFY_E2E_WORKFLOW_APP_ID echo-workflow 应用 ID
DIFY_E2E_FILE_APP_ID / DIFY_E2E_FILE_CHAT_APP_ID 文件上传 / 文件对话应用 ID
DIFY_E2E_HITL_APP_ID 及其 _EXTERNAL__SINGLE_ACTION__MULTI_NODE_ 变体 HITL 各形态应用 ID
DIFY_E2E_REASONING_APP_ID separated-reasoning chatflow 应用 ID(opt-in)
DIFY_E2E_REASONING_PROVISION 1 → 自动导入 reasoning-chat.yml 夹具
DIFY_E2E_MODE=local 本地模式:只跑离线安全用例,global-setup 直接提前返回

这些变量在 loadE2EEnv() 中被统一读取、缓存,缺失必选变量时抛出带清单的错误。resolveEnv(caps) 则把 global-setup 解析出的 capabilities 叠加到环境变量之上——capabilities 永远优先,环境变量仅作回退。

分离模式推理套件(opt-in)

run-app-reasoning.e2e.ts 验证 reasoning_chunk 带外通道:--think 会把思维链以 think…/think_end 标签形式输出到 stderr,答案保持干净,-o json 则将其持久化到 metadata.reasoning。该套件在 DIFY_E2E_REASONING_APP_ID 未解析时自动跳过,因为它要跑真实的 LLM 节点,前提条件有二:

  1. 一个 LLM 节点使用 reasoning_format: separated 的 chatflow;
  2. workspace 已配置默认对话模型。

可以把 DIFY_E2E_REASONING_APP_ID 指向已有应用,或设置 DIFY_E2E_REASONING_PROVISION=1 自动导入 reasoning-chat.yml 夹具(其系统提示强制生成 think 块,因此任意对话模型都会走 separated 路径,无需专用推理模型)。在 global-setup 中,这个 opt-in 开关直接控制 reasoning-chat.yml 是否进入导入清单,见 global-setup.ts

五、global-setup 引导流程详解

global-setup.ts 是整个套件的地基,DIFY_E2E_MODE=local 时直接返回(离线模式)。真实模式下流程如下:

1. 账号引导(CE 路径)

CE: 1. 幂等注册账号(先试 /console/api/init,再退 /console/api/register;409 视为成功)
    2. 登录获取 session cookie
    3. 通过 device flow 铸造主 bearer token
    4. 校验 token
    5. 发现唯一 workspace(回退到第一个可用 workspace)
    6. 铸造 logout / devices 两套专属 token
    7. 导入全部 DSL 夹具并发布、设置 access_mode → public

CE 的 workspace 发现策略:优先取名称包含 auto 的 workspace(按字典序),找不到则回退到账号下第一个 workspace,见 discoverWorkspaces()

EE 路径则由运维预先建好 auto_test0/auto_test1 两个 workspace,global-setup 按精确名称发现它们(global-setup.ts),并额外调用企业 admin API 完成成员账号与应用的发布及 access_mode 设置。

2. Token 铸造:device flow 与本地缓存

主 token 的获取优先级为:DIFY_E2E_TOKEN 环境变量 → 本地缓存文件 .token-cache.json(与 .env.e2e 同目录、git-ignored,host 变化即失效)→ 现场铸造。缓存命中后会先调用 GET /openapi/v1/account/sessions 校验有效性,见 global-setup.ts

铸造过程走标准三步 device flow(mintTokenWithSession()):

POST /openapi/v1/oauth/device/code    → device_code + user_code
POST /openapi/v1/oauth/device/approve  → 批准(429/5xx 时按 2s×attempt 退避重试,最多 5 次)
POST /openapi/v1/oauth/device/token    → dfoa_ token

logoutdevices-revoke 两个套件会撤销真实服务端子会话,因此 global-setup 为它们各铸一个一次性 token,且这两个 token 刻意不缓存(每次都必须是新鲜的)。同样,测试内部也可用 mintFreshToken() 按需铸发一次性 dfoa_ token,避免消耗共享 token。

3. DSL 夹具供给(幂等 + 并发安全)

provisionApps() 对每个 fixture 依次执行:切换到目标 workspace → 按名称搜索应用(存在则复用)→ 不存在则通过 difyctl import studio-app --from-file <yml> --workspace <wsId> 导入 → 启用 Service API → 发布(workflow / advanced-chat / agent-chat 模式)→ 设置 access_mode → public(CE 服务器返回 404 时非致命跳过)。

针对 CI 并行 job 的竞态问题,代码有一个显式短路:如果全部 9 个 DIFY_E2E_*_APP_ID 环境变量已预置(例如来自 CI 的 provision job),则直接跳过 provisionApps,否则多个并行 job 会各自查到“not found”并重复导入同一应用,见 global-setup.ts 的注释。

4. 会话清理

registerConversation() 注册运行期间创建的会话,global-teardown 阶段统一删除,防止 staging 上残留测试数据。

六、运行测试

cli/ 目录下(package.json 定义了 vp test --config vitest.e2e.config.ts 系列脚本,当前版本 1.17.0,目标 Node ^24.20.0):

cd cli

# 社区版(默认)
bun run test:e2e

# 企业版
DIFY_E2E_EDITION=ee bun run test:e2e

# 只跑 [P0] 冒烟用例
bun run test:e2e:smoke

# 只跑 EE 标签用例(P0 冒烟)
DIFY_E2E_EDITION=ee bun run test:e2e:smoke --testNamePattern "\[EE\]"

# 只跑离线安全的用例(无需网络;实际为 help + agent 套件,见 DIFY_E2E_MODE=local 分支)
bun run test:e2e:local

# 只跑单个文件
bun vitest --config vitest.e2e.config.ts test/e2e/suites/auth/status.e2e.ts

脚本与源码的对应关系:test:e2e:smoke--testNamePattern "\[P0\]"test:e2e:localDIFY_E2E_MODE=local vp test …,见 package.json。此外,DIFY_E2E_INCLUDE 支持逗号分隔的 glob 列表来进一步收敛用例(兼容旧变量 DIFY_E2E_SINGLE_FILE),例如 DIFY_E2E_INCLUDE="test/e2e/suites/run/**/*.e2e.ts",见 vitest.e2e.config.ts

七、测试执行顺序

文件按顺序执行(fileParallelism: false),在 vitest.e2e.config.ts 中硬编码为:

auth(login → status → use → whoami) → framework(help)
  → output → error-handling → framework(其余)
  → discovery → dsl → run(basic / streaming / file / reasoning / HITL)
  → agent → devices → logout

顺序背后的两条原则:

  1. auth 最先:其余多数测试都依赖有效会话;
  2. devices 与 logout 最后:它们会撤销真实服务器上的会话 token,放在最后可避免“误伤”后续用例。

八、关键设计决策对照表

README 总结的设计决策及其源码佐证:

决策 理由(源码佐证)
CE/EE 版本开关 DIFY_E2E_EDITION=ce/ee 决定 global-setup 引导路径,并激活/跳过 [EE] 用例(isEnterpriseEdition()
[EE] 标签约定 测试名带 [EE],让被跳过的用例在报告中可见,并支持 --testNamePattern "\[EE\]" 过滤
enterpriseOnlyIt(caps) EE 模式返回 it,CE 模式返回 it.skip——跳过是声明式的,无运行时断言(skip.ts
无 mock 所有 HTTP 流量打到真实服务器,捕获真实集成回归
隔离配置目录 每个测试新建 withTempConfig() 临时目录,会话状态互不泄漏(withTempConfig()
withAuthFixture() 合并 withTempConfig + injectAuth 为一个 fixture,减少 beforeEach 样板(withAuthFixture()
injectAuth() 绕过 Device Flow 非 auth 测试写入预制 hosts.yml + tokens.ymltoken_storage: file),跳过浏览器步骤;只有 auth/ 套件走真实流程(injectAuth()
mintFreshToken() logoutdevices-revoke 通过 device flow API 铸发一次性 dfoa_ token,撤销时不伤主 token
全局 retry: 0 全局重试会掩盖非幂等失败;已知 flaky 网络调用改用局部的 withRetry() 精确控制(默认 3 次尝试、1s 间隔、可附 shouldRetry 谓词,见 retry.ts;CI 可用 VITEST_RETRY 显式打开全局重试)
会话清理 registerConversation() + global-teardown 在运行结束后删除 staging 会话

一个实用的 withRetry 用法示例(摘自源码注释):

const result = await withRetry(() => run(['get', 'app', '-o', 'json']))

九、小结

这套 E2E 测试的价值在于把 difyctl 与真实 Dify 服务之间的完整契约——device flow 认证、workspace 发现与切换、DSL 导入发布、run 的流式/HITL/推理通道、token 撤销——全部纳入可重复执行的回归验证。其工程取舍(顺序执行、token 本地缓存、capabilities 优先于环境变量、破坏性套件专用 token、声明式 EE 跳过)都直接服务于两个目标:幂等可重跑并行 CI 安全。对维护者而言,入口文件只有四个:env.ts(契约)、global-setup.ts(引导)、cli.ts(执行原语)、vitest.e2e.config.ts(编排),修改任一环节前应先通读这四处。

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