Dify CLI E2E 测试套件实战:用真实 difyctl 二进制验证社区版与企业版全链路
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.ts 中BIN常量; 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.ts、setup/global-setup.ts、helpers/cli.ts、helpers/skip.ts、helpers/retry.ts 以及 fixtures/apps 下的 10 个 DSL 夹具(echo-chat.yml、echo-workflow.yml、file-upload.yml、file-chat.yml、4 个 hitl-*.yml、reasoning-chat.yml、ws2-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 节点,前提条件有二:
- 一个 LLM 节点使用
reasoning_format: separated的 chatflow; - 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
logout 与 devices-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:local 即 DIFY_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
顺序背后的两条原则:
- auth 最先:其余多数测试都依赖有效会话;
- 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.yml(token_storage: file),跳过浏览器步骤;只有 auth/ 套件走真实流程(injectAuth()) |
mintFreshToken() |
logout 与 devices-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(编排),修改任一环节前应先通读这四处。
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