Dify E2E 测试体系详解:Cucumber + Playwright 的仓库级端到端测试如何编排、运行与保障契约
本文以 Dify 仓库的 e2e/AGENTS.md 为主体,完整还原该包定义的命令体系、运行时编排职责、标签语义、会话/清理契约与浏览器-API 边界,并结合 e2e/scripts/run-cucumber.ts、e2e/scripts/setup.ts、e2e/features/support/world.ts 等源码,说明每个约定背后的真实实现。读完本文,你可以独立搭建并运行 Dify 的仓库级 E2E 套件,理解其确定性执行策略与"空选标签不可通过"的行为门禁,并能在贡献新场景时遵守该包的编写与评审规范。
1. e2e 包的定位:Cucumber 场景 + Playwright 浏览器层
e2e/AGENTS.md 开篇即定义了本包的角色:它是 Dify 的仓库级 Cucumber 场景集合,以 Playwright 作为浏览器层。该文件本身是"唯一事实来源"(canonical documentation),管辖当前包的架构、运行时、会话与标签语义、种子(seed)、协议与清理契约;场景编写与评审方法论归仓库本地技能所有,而功能特性级的事实则就近写在各特性目录的 AGENTS.md 中(例如 e2e/features/agent-v2/AGENTS.md)。
从目录结构看,整个包的职责划分非常清晰:
- e2e/features/:Gherkin 场景(
.feature)与按能力域组织的步骤定义(step-definitions/),覆盖accessibility(WCAG 扫描、键盘导航)、agent-v2(Agent v2 运行时)、apps(各类应用创建/发布/分享)、auth(会话刷新、重定向安全)、smoke(认证/未认证入口)等域; - e2e/scripts/:运行时编排(
run-cucumber.ts、setup.ts、seed-runner.ts、run-prepared.ts、run-external-runtime.ts、run-post-merge.ts); - e2e/support/:API 客户端、清理、命名、测试素材等共享能力;
- e2e/fixtures/test-materials/:确定性上传素材,包括 voice-input.wav(麦克风场景使用的假音频)、含中文与特殊字符的文件名素材 agent-special-filename-中文 @#$%.txt 等。
e2e/README.md 仅有一行指针,声明规范文档位于 AGENTS.md,这也印证了文档"一个包、一份权威契约"的组织方式。
2. 命令体系:从单次运行到完整确定性执行
2.1 前置条件与并发约束
所有命令都在仓库根目录执行。依赖与浏览器只需一次性安装:pnpm install 与 pnpm -C e2e e2e:install(后者对应 e2e/package.json 中的 playwright install --with-deps chromium webkit)。
文档特别强调一条硬性约束:同一时间只能运行一个本地 pnpm -C e2e e2e* 进程。原因是各 runner 共享端口、认证状态与日志路径。这一点在 e2e/scripts/setup.ts 中有直接证据:startApi 启动前会检测 127.0.0.1:5001 是否已被占用,若被占用会直接抛出包含端口监听者描述的错误(Agent backend 的 5050 端口与 shellctl 沙箱的 5004 端口同理),从机制上防止了多进程互相踩踏。
2.2 完整命令清单
以下命令完整继承自 e2e/AGENTS.md 的 Commands 一节,并与 e2e/package.json 的 scripts 定义一一对应:
| 场景 | 命令 |
|---|---|
| 在已初始化的实例上运行 E2E | pnpm -C e2e e2e |
| 独立自动化 WCAG A 级扫描 | pnpm -C e2e e2e:accessibility:a |
| 独立自动化 WCAG AA 级扫描 | pnpm -C e2e e2e:accessibility:aa |
| 单页 WCAG 自动化扫描 | pnpm -C e2e exec tsx ./scripts/run-cucumber.ts --full -- --tags "@axe and @wcag-a and @wcag-page-studio"(按需替换级别与页面标签) |
| 重置、初始化并运行确定性场景 | pnpm -C e2e e2e:full |
| 准备并运行依赖共享 fixture 的场景 | E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:prepared |
| 运行标签子集 | pnpm -C e2e e2e -- --tags @smoke |
| 有头(headed)调试 | pnpm -C e2e e2e:headed -- --tags @smoke |
| 准备并运行外部运行时场景 | E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:external |
| 仅对已有中间件做种子注入、不跑 Cucumber | pnpm -C e2e seed -- --profile <prepared|external-runtime|post-merge> |
| 重置持久化的 E2E 状态 | pnpm -C e2e e2e:reset |
| 仅构建生产 Web 产物、不启动服务 | pnpm -C e2e e2e:web:build |
| 中间件生命周期 | pnpm -C e2e e2e:middleware:up / pnpm -C e2e e2e:middleware:down |
| 作用域静态检查 | vp check e2e |
此外还有两个文档提到、e2e/package.json 中同样存在的派生命令:e2e:prepared:prepare / e2e:external:prepare / e2e:post-merge:prepare(仅执行 --seed-only 的种子阶段)与 e2e:post-merge(post-merge 全量运行)。
2.3 常用环境变量
文档还给出了若干调试与覆盖开关,均可在源码中验证:
E2E_FORCE_WEB_BUILD=1:强制重建前端。e2e/scripts/setup.ts 的ensureWebBuild默认复用web/.next/BUILD_ID——它会基于 git 工作树(HEAD、工作区/暂存区 diff、未跟踪文件)与环境配置计算 SHA-256 构建戳,与web/.next/e2e-web-build.sha256比对;戳一致则直接复用产物,不一致才执行pnpm run build。该机制保证"复用不导致测试跑在过期产物上",而E2E_FORCE_WEB_BUILD=1可绕过戳校验强制重建。E2E_BROWSER=webkit:聚焦跨浏览器运行。e2e/features/support/hooks.ts 的Before钩子中,浏览器类型由e2eBrowser === 'webkit' ? webkit : chromium决定;且@microphone场景在 WebKit 下会直接抛错(麦克风场景要求E2E_BROWSER=chromium)。E2E_SLOW_MO=500:配合 headed 命令做本地操作调试,慢放浏览器动作。
3. 运行时所有权:谁启动什么,顺序如何
3.1 职责分配表
e2e/AGENTS.md 的 "Runtime Ownership" 一节给出了精确的职责边界,每一条都能在源码中定位:
| 职责 | 归属文件 |
|---|---|
| 重置、中间件、后端、前端启动 | e2e/scripts/setup.ts |
| 唯一 E2E 运行时编排器:服务生命周期、可选 seed、Cucumber 调用、teardown | e2e/scripts/run-cucumber.ts |
| fixture 创建与验证(只对接已运行的运行时,绝不启动服务) | e2e/scripts/seed-runner.ts |
| 前端复用、就绪探测与关闭 | e2e/support/web-server.ts |
| 共享认证 bootstrap、场景生命周期与诊断 | e2e/features/support/hooks.ts |
DifyWorld:每场景 BrowserContext、认证态 setup 与清理客户端 |
e2e/features/support/world.ts |
能力导向的步骤定义胶水;common/ 仅放真正跨能力的步骤 |
e2e/features/step-definitions/ |
其中两条值得强调的架构决策:
- 浏览器与 API 身份严格分离。
DifyWorld同时持有浏览器的BrowserContext和独立的consoleRequestContext(API 请求上下文)——文档解释其动机是"让未认证与登出旅程无法破坏 fixture 的所有权"。在 e2e/features/support/world.ts 的startSession中可以看到:浏览侧 context 在@unauthenticated时不加载storageState,而 API 侧始终复用认证态,两者互不干扰。 - 跨 Actor 场景保持隔离。多角色场景为每个 actor 建立独立的
BrowserContext与类型化的DifyWorld状态,确保诊断与清理覆盖到每一个 actor(诊断钩子会为多个页面逐一截图,见 e2e/features/support/hooks.ts 的diagnosticPages列表)。 - 步骤定义的写法约束:访问 World 状态的步骤定义必须写成
async function (this: DifyWorld, ...),因为箭头函数无法接收 Cucumber 绑定的 World 实例。这是一个容易被忽视的 TypeScript 细节。
3.2 run-cucumber.ts 的完整编排链
阅读 e2e/scripts/run-cucumber.ts 的 main 函数,实际执行顺序为:
- 可选重置与中间件启动:
--full时先resetState(),再startMiddleware(); - 可选 Agent backend 托管栈:
shouldStartManagedAgentBackend()为真时,先起 shellctl 沙箱(等待http://127.0.0.1:5004/healthz就绪),再起 agent backend(等待http://127.0.0.1:5050/openapi.json就绪); - API 服务:
tsx ./scripts/setup.ts api,就绪判据是${apiURL}/health(180 秒超时); - Celery worker:seed 模式下队列显式为
dataset,priority_dataset,workflow_based_app_execution(源码常量seedCeleryQueues); - Web 服务:通过
startWebServer启动,支持复用既有服务(reuseExistingServer),超时 300 秒; - 可选 seed:
runSeed(seed); - Cucumber 调用:
npx tsx ./node_modules/@cucumber/cucumber/bin/cucumber.js --config ./cucumber.config.ts,透传--tags等参数;--full且未自定义标签时注入排除表达式not @axe and not @prepared and not @external-model and not @external-tool; - 行为门禁:退出码为 0 时,还会读取
cucumber-report/report.ndjson,断言至少出现一条testCaseStarted消息——即"空选标签不能通过"; - teardown:
finally中按顺序停止 web、celery、api、agent backend、shellctl 沙箱与中间件,任何 teardown 错误都会使进程失败;SIGINT/SIGTERM 同样触发同一清理路径。
e2e/scripts/setup.ts 的 resetState 定义了"重置"的确切含义:停止中间件容器、清空 docker/volumes 下的 db/plugin_daemon/redis/weaviate 数据目录、删除 .auth、cucumber-report*、.logs*、playwright-report、seed-report、test-results 等 E2E 本地状态。而 startMiddleware 通过 docker compose(--profile postgresql --profile weaviate)拉起 db_postgres、redis、weaviate、sandbox、ssrf_proxy、plugin_daemon 六个服务,并依次等待 PostgreSQL/Redis 健康检查、Weaviate ready 端点、sandbox 健康端点、plugin daemon 5002 端口可达。
3.3 认证是惰性完成的,确定性由 setup 证明
文档明确:未初始化的实例会被惰性地安装并认证,已初始化的实例则直接登录并复用认证态;完整的 reset 与 bootstrap 能力由 setup 流程本身证明,而不是靠某个 Gherkin 场景去证明。源码印证:hooks.ts 的 Before 钩子中,浏览器首次启动时执行 ensureAuthenticatedState(browser, baseURL)(会话缓存 bootstrap),后续场景直接复用;e2e/scripts/seed-runner.ts 在 seed 前也会独立完成一次 ensureAuthenticatedState,然后建立 standalone console session 创建 fixture,且 seed 遇到 blocked 任务会失败,除非显式 --allow-blocked。
4. 标签语义:选择集、外部运行时与特殊通道
4.1 标签总表
| 标签 | 语义 |
|---|---|
| (默认) | 使用共享认证 storage state |
@unauthenticated |
创建干净的未认证 context |
@authenticated |
仅表意与选择用途,不改变行为 |
@axe |
标记独立自动化 WCAG 扫描;被默认功能套件与普通 CI 命令排除 |
@wcag-a / @wcag-aa |
限定独立级别扫描;选择任一级别的命令必须同时选择 @axe |
@wcag-page-<slug> |
页面选择器,挂在 e2e/features/accessibility/ 对应 Examples 块上 |
@prepared |
需要 prepared fixture;post-merge seed 档案包含这些 fixture |
@external-model / @external-tool |
调用真实外部运行时;确定性命令排除这些标签,external 命令显式 opt-in |
@microphone |
使用签入的假音频素材与隔离 Chromium context |
@browser-smoke |
在 Chromium 与 WebKit CI 通道中运行聚焦的键盘/导航覆盖 |
@skip |
从所有 runner 档案中临时排除;产品行为恢复后应立即移除,禁止用于永久性或环境性屏蔽 |
@agent-backend-runtime |
Agent v2 运行时场景专用;要求显式的运行时可用性步骤 |
4.2 标签如何在代码层生效
e2e/cucumber.config.ts 是标签策略的落点:默认标签表达式为 (not @axe and not @prepared and not @external-model and not @external-tool) and not @skip,可由环境变量 E2E_CUCUMBER_TAGS 或命令行 --tags 覆盖;报告格式固定为 progress-bar、summary、html:./cucumber-report/report.html 与 message:./cucumber-report/report.ndjson(后者正是"至少一条 testCaseStarted"门禁的数据来源);场景默认超时 60 秒。
e2e/features/support/hooks.ts 则实现了 @unauthenticated 与 @microphone 的行为差异:@microphone 场景使用单独的 Chromium 实例,启动参数为 --use-fake-device-for-media-stream、--use-fake-ui-for-media-stream 与 --use-file-for-fake-audio-capture=<voice-input.wav>%noloop,并对 origin 授予 microphone 权限。
4.3 生命周期不可复制,运行时开关互斥
文档对 CI 有一条强约束:seed 与 Cucumber 必须共享同一个运行时生命周期;组合命令(e2e:prepared、e2e:external 等)拥有 reset、中间件、服务、seed、Cucumber 与 teardown 的完整生命周期,CI 不得在 workflow YAML 里复刻这套生命周期。E2E_START_AGENT_BACKEND=1 会在 API 之前启动托管本地 backend,且与显式提供 Agent backend URL(E2E_AGENT_BACKEND_URL / AGENT_BACKEND_BASE_URL)互斥——e2e/scripts/setup.ts 的 getAgentBackendBaseUrl 正是按"显式 URL 优先、开关兜底"的优先级解析的。文档同时告诫:不要滥用运行时标签去暗示无关服务,也不要在必需 fixture 缺失时静默跳过行为。
5. 浏览器、API 与契约边界
这是 e2e/AGENTS.md 中最具工程价值观的一节,核心原则是"被测动作归属浏览器":
- API 只能用于准备 fixture、轮询持久化结果、清理,不能替代用户的
When动作;除非被测契约就是持久化后端状态,否则优先断言用户可见的浏览器结果。 - 对普通 Console JSON 与可表示的 multipart 操作,使用场景级或进程级的生成式 oRPC 客户端,并开启请求与响应校验;直接调用生成操作,不得手写端点 URL、复制 DTO/schema、写响应强转、一对一转发包装、跨场景可变客户端或 TanStack Query 缓存。
- 助手(helper)只在拥有fixture 构造、多操作编排、清理注册表、不变量、最终一致性轮询、收窄测试视图或协议适配器时才允许存在;SSE、二进制下载、纯重定向流程、外部服务、基础设施就绪检查可以集中到真实归属者名下的适配器。
- 校验失败即契约失败:应追踪到后端 schema 归属者,按需更新 api/controllers/API_SCHEMA_GUIDE.md 中的契约并重新生成
@dify/contracts(位于 packages/contracts),保持场景与产品真实状态归属者对齐;不得关闭校验或添加回退 schema 来让 E2E 通过。
e2e/support/api/console-client.ts 是这条规则的教科书式实现:它从 @dify/contracts 导入生成路由契约 consoleRouterContract,通过 OpenAPILink 挂上 RequestValidationPlugin 与 ResponseValidationPlugin 两个插件,请求经由 Playwright 的 APIRequestContext 发出,并自动从 storageState 提取 csrf_token Cookie 注入 X-CSRF-Token 头——CSRF 缺失直接抛错,从机制上保证"未认证态的 API 调用"不会静默发生。
6. 种子、清理与诊断:可复现性的最后防线
6.1 命名与素材
- 一次性资源名必须经 e2e/support/naming.ts 的
createE2EResourceName生成,格式为E2E [qualifier] <resource> <nonce>;同文件还提供assertE2EResourceName,任何不以E2E开头的资源名会直接断言失败——这让"测试资源"在全库可辨识、可批量清理。 - 确定性上传素材保留在 e2e/fixtures/test-materials/,统一经 e2e/support/test-materials.ts 解析路径。
6.2 清理契约:谁创建、谁负责
文档规定:seed 脚本拥有共享的长生命周期 fixture;场景拥有自己创建的一次性资源并必须注册清理。实现上分为两层(见 e2e/features/support/hooks.ts):
- 类型化清理队列:
DifyWorld的createdAppIds、createdAgentIds、createdDatasetIds、createdAgentConfigFiles、createdAgentConfigSkills、createdBuiltinToolCredentials等字段,在Clean up scenario resources钩子中以toReversed()(LIFO) 顺序删除——先删子资源与被引用资源,再删所有者; - 注册式清理:对类型化字段之外的生命周期归属者,用
registerCleanup(...)注册回调,注册回调在类型化队列之后按 LIFO 执行(e2e/features/support/world.ts)。
清理顺序遵循 Cucumber 的 After 钩子逆注册序:诊断 → 清理 → 关闭会话。清理失败不允许被吞掉:错误会被 attach 到报告,且对已通过的场景,若清理出错同样会使场景失败(shouldFailForCleanupErrors)。
6.3 诊断与报告布局
- 失败场景(FAILED/AMBIGUOUS/PENDING/UNDEFINED/UNKNOWN)会产出全页截图与 HTML 捕获,落在
cucumber-report/artifacts/,文件名带时间戳与场景名; - HTML 报告与 Cucumber Messages(
report.ndjson)位于cucumber-report/; - 后端与前端启动日志位于
.logs/(API、Celery、web、agent backend、shellctl 各自独立日志文件); - 各 CI 通道保留自己的报告与日志目录(如
cucumber-report-non-external、.logs-webkit,与 e2e/scripts/setup.ts 中e2eStatePaths的定义一致); console错误与weberror页面异常会被收集并在诊断阶段附加到报告。
7. 实践路径:如何从仓库状态到一次可信的 E2E 运行
结合 e2e/AGENTS.md 与上述源码,一条可复现的本地实践路径如下(均为文档给出的运行方式,不涉及修改仓库内容):
- 一次性准备:
pnpm install+pnpm -C e2e e2e:install(安装 chromium 与 webkit); - 日常快跑(已初始化实例):
pnpm -C e2e e2e -- --tags @smoke,有头调试加e2e:headed,慢放加E2E_SLOW_MO=500; - 完整确定性验证:
pnpm -C e2e e2e:full——它先清数据卷与 E2E 状态,再拉起 postgres/redis/weaviate/sandbox/ssrf_proxy/plugin_daemon,随后启动 API(5001)、Celery、Web(3000),跑完标签为not @axe and not @prepared and not @external-model and not @external-tool的确定性场景,并执行"至少一条testCaseStarted"门禁; - 共享 fixture 场景:
E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:prepared;只想准备数据不跑场景时用pnpm -C e2e seed -- --profile prepared; - 外部运行时场景:
E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:external,或提供E2E_AGENT_BACKEND_URL/AGENT_BACKEND_BASE_URL指向既有运行时; - 无障碍审计:按级别跑
e2e:accessibility:a/e2e:accessibility:aa,或按页面标签精确到单页;变更审计工作流、页面矩阵或就绪契约时,PR 作者应在合并前跑一遍 AA/全量路径(文档定位为 opt-in 人工审计,而非回归门禁)。
8. 小结
Dify 的 e2e 包展示了"仓库级端到端测试"的一种严谨形态:一份 AGENTS.md 作为包级契约,命令、标签、清理、诊断、契约边界全部成文;每个约定在 e2e/scripts/run-cucumber.ts、e2e/scripts/setup.ts、e2e/features/support/world.ts、e2e/features/support/hooks.ts 中都有可验证的落地实现;行为门禁(Cucumber 退出码 + 非空 testCaseStarted 断言)与契约校验(oRPC 请求/响应双向校验、CSRF 强制)共同保证了"通过"二字的含金量。对于需要在大型产品中建设 E2E 体系的团队,这套"编排器唯一、职责分片、身份分离、LIFO 清理、空跑不通过"的设计是值得直接对标的范本。
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