Dify E2E 测试体系:Cucumber + Playwright 仓库级端到端场景的运行、编排与契约实践
【免费下载链接】difyBuild Agentic workflows, RAG pipelines, with rich AI model and tool support on one collaborative workspace. Deploy on cloud, VPC, or self-hosted, so teams move from prototype to production without rebuilding the stack.项目地址: https://gitcode.com/GitHub_Trending/di/dify
本文以 Dify 仓库中的 e2e/README.md 为入口展开——该文件声明本包的规范化文档位于 e2e/AGENTS.md,因此后者构成本文的主体骨架。读完本文,你可以掌握 Dify E2E 包的全部运行命令与语义标签体系,理解"单一编排器拥有服务生命周期"的运行时所有权模型,并了解浏览器操作与 oRPC 生成契约如何划分 Browser/API 边界,以及种子数据、清理注册与失败诊断的完整约定。
1. 定位与核心结论:README 指向的规范化文档
e2e/README.md 全文只有一行有效信息:
Canonical documentation for this package lives in [AGENTS.md].
这确立了 Dify 仓库的一个文档约定:包级 README 只做导航,真正的架构、运行时、会话、标签语义、种子、协议与清理契约由 e2e/AGENTS.md 承载。该文档开篇即给出包的定位:
This package contains Dify's repository-level Cucumber scenarios with Playwright as the browser layer.
即:e2e/是 Dify 的仓库级 E2E 测试包,采用Cucumber(Gherkin 场景描述)+ Playwright(浏览器驱动)的组合。文档同时划清了职责边界:本文件(AGENTS.md)拥有当前包的架构、运行时/会话/标签语义、seed、协议与清理契约;编写与评审方法论由仓库本地的e2e-cucumber-playwrightskill 负责;而各功能特性的具体事实则放在各自 feature 最近的AGENTS.md中。
从源码结构看,e2e/目录与该描述完全吻合:Gherkin 特性文件位于 features/ 下的accessibility/、agent-v2/、apps/、auth/、smoke/等子目录;步骤定义(glue code)位于 features/step-definitions/,按能力域组织;共享能力位于 support/ 与 scripts/。
2. 运行命令全集:从一次性安装到按标签子集执行
所有命令必须从仓库根目录执行。先执行一次性安装:pnpm install(安装依赖)与pnpm -C e2e e2e:install(安装浏览器及其系统依赖)。注意:由于各 runner 共享端口、认证状态与日志路径,同一时间只能运行一个本地pnpm -C e2e e2e*进程。
AGENTS.md 给出的完整命令表如下(均已对照 e2e/package.json 中的 scripts 逐一核实存在):
| 场景 | 命令 |
|---|---|
| 对已初始化的实例执行场景 | pnpm -C e2e e2e |
| 独立的 WCAG Level A 无障碍扫描 | pnpm -C e2e e2e:accessibility:a |
| 独立的 WCAG Level 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 |
| 有头模式调试 | pnpm -C e2e e2e:headed -- --tags @smoke |
| 准备并运行外部运行时场景 | E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:external |
| 仅对现有中间件做 seed,不跑 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与e2e:full都调用tsx ./scripts/run-cucumber.ts(后者附加--full);e2e:prepared调用run-prepared.ts;e2e:external调用run-external-runtime.ts;e2e:middleware:up/down、e2e:reset、e2e:web:build全部收敛到tsx ./scripts/setup.ts <subcommand>;而e2e:install实际执行playwright install --with-deps chromium webkit,CI 变体e2e:install:ci使用--only-shell进一步缩减体积。此外还有文档未列出的e2e:post-merge入口(run-post-merge.ts),用于合并后的场景准备。
文档还给出三个关键环境变量:
E2E_FORCE_WEB_BUILD=1:runner 默认复用web/.next/BUILD_ID(即复用已有的 Next.js 构建产物),设置该变量可强制重新构建前端;E2E_BROWSER=webkit:针对特定浏览器做聚焦的跨浏览器运行;E2E_SLOW_MO=500:与有头命令配合,用于本地动作级调试的减速。
2.1 标签过滤的默认语义:cucumber.config.ts
Cucumber 的配置文件 e2e/cucumber.config.ts 值得完整解读,它定义了"没有显式--tags时到底跑什么":
const hasCliTags = process.argv.some((arg) => arg === '--tags' || arg.startsWith('--tags=')) const defaultNonExternalTags = 'not @axe and not @prepared and not @external-model and not @external-tool' const selectedTags = process.env.E2E_CUCUMBER_TAGS || (hasCliTags ? undefined : defaultNonExternalTags) const tags = selectedTags ? `(${selectedTags}) and not @skip` : 'not @skip' const config = { format: [ 'progress-bar', 'summary', 'html:./cucumber-report/report.html', 'message:./cucumber-report/report.ndjson', ], import: ['./tsx-register.js', 'features/**/*.ts'], paths: ['features/**/*.feature'], tags, timeout: 60_000, }其语义是:标签选择存在三级优先级——环境变量E2E_CUCUMBER_TAGS> 命令行--tags> 默认的defaultNonExternalTags(排除@axe、@prepared、@external-model、@external-tool四类外部/可选标签);无论哪级来源,最终都会再叠加and not @skip,保证被@skip标记的场景在所有 runner 配置下都被排除。配置还固定了:特性文件路径为features/**/*.feature;场景超时 60 秒;报告双写为 HTML(cucumber-report/report.html)与 Cucumber Messages NDJSON(cucumber-report/report.ndjson)。
3. 运行时所有权模型:每个脚本只拥有一件事
AGENTS.md 的 "Runtime Ownership" 一节是本包架构的核心:
| 文件 | 所有权职责 |
|---|---|
| scripts/setup.ts | reset、中间件、后端、前端的启动 |
| scripts/run-cucumber.ts | 唯一的 E2E 运行时编排器:服务生命周期、可选的 seed 执行、Cucumber 调用、teardown |
| scripts/seed-runner.ts | 针对已运行中的运行时创建并验证 fixture;永不启动服务 |
| support/web-server.ts | 前端复用、就绪探测与关闭 |
| features/support/hooks.ts | 共享的认证引导、场景生命周期与诊断 |
| features/support/world.ts | DifyWorld:每场景的行为级BrowserContext及其带认证的 setup/cleanup 客户端 |
| features/step-definitions/ | 按能力域组织的 glue;common/仅留给真正跨能力的步骤 |
阅读 run-cucumber.ts 可以印证上述每一条。其main()流程按序完成:
--full时先resetState()再startMiddleware()(run-cucumber.ts#L122-L127);- 若
shouldStartManagedAgentBackend()为真,先拉起 shellctl sandbox(默认端口E2E_SHELLCTL_PORT || 5004,健康检查/healthz),再拉起 agent backend(默认端口E2E_AGENT_BACKEND_PORT || 5050,就绪探针/openapi.json),均以startLoggedProcess记录日志到.logs/(run-cucumber.ts#L132-L161); - 启动 API server(就绪探针
${apiURL}/health,超时 180 秒)与 Celery worker——seed 场景下额外限定队列dataset,priority_dataset,workflow_based_app_execution(run-cucumber.ts#L20); startWebServer启动/复用前端(超时 300 秒);- 执行
runSeed(seed)(若请求); - 以
npx tsx ./node_modules/@cucumber/cucumber/bin/cucumber.js --config ./cucumber.config.ts调用 Cucumber,--headed通过CUCUMBER_HEADLESS=0/1传递; finally中按注册顺序执行清理:停 Web、停 Celery、停 API、停 agent backend、停 shellctl sandbox、停中间件(run-cucumber.ts#L88-L106)。
两个值得注意的实现细节:
- 未预期进程退出即快速失败:
waitForManagedProcess将waitForUrl与进程exit事件竞速,进程未就绪先退出时,会抛出附带日志尾部 20 行的错误(run-cucumber.ts#L28-L74),避免"服务挂了却干等超时"; - 双通道终止处理:注册
SIGINT/SIGTERM触发清理并以非零码退出,保证 Ctrl-C 也能完成 teardown(run-cucumber.ts#L108-L119)。
3.1 认证引导、行为门禁与步骤定义约定
文档还规定了几条行为层面的契约:
- 惰性初始化与认证复用:未初始化的实例在 setup 阶段被惰性安装并认证;已初始化的实例直接登录并复用认证状态。全量运行(
--full)通过 setup 阶段本身证明 reset 与 bootstrap 的正确性,而不是写一个 Gherkin 场景去证明。 - 行为门禁:Cucumber 的退出码是行为门禁;同时 runner 还要求报告里至少出现一条
testCaseStarted消息——run-cucumber.ts#L223-L226 在退出码为 0 后调用assertCucumberScenariosStarted(messages),防止"标签选择为空导致 0 个场景却通过"。文档明确禁止用"场景数基线"或"跳过场景白名单"替代这一门禁。 - World 隔离:浏览器身份与 API 身份保持分离,使未认证/登出旅程不会破坏 fixture 的所有权;跨 actor 场景为每个 actor 使用独立的
BrowserContext与类型化DifyWorld状态,确保诊断与清理覆盖所有 actor。 - TypeScript 写法约束:访问 World 状态的步骤定义必须写成
async function (this: DifyWorld, ...),因为箭头函数拿不到 Cucumber 绑定的 World 实例。
4. 标签体系与外部运行时:哪些场景属于哪条流水线
AGENTS.md 的 "Tags And External Runtime" 一节定义了整个 E2E 的选择语义,逐条归纳如下:
| 标签 | 语义 |
|---|---|
| (默认) | 场景使用共享的已认证存储状态(storage state) |
@unauthenticated | 创建干净的上下文,用于未认证旅程 |
@authenticated | 仅作意图与选择用,不改变运行时行为 |
@axe | 独立自动化 WCAG 扫描;被默认功能套件与常规 CI 命令排除 |
@wcag-a/@wcag-aa | 限定独立的级别化扫描;选择任一级别的命令必须同时选择@axe |
@wcag-page-<slug> | 页面选择器,挂在 features/accessibility/ 下对应 Examples 块 |
@prepared | 需要 prepared fixtures;post-merge seed profile 包含它们 |
@external-model/@external-tool | 场景会调用真实外部运行时;确定性命令排除这些标签,external 命令是显式选择(opt-in) |
@microphone | 使用签入仓库的假音频 fixture 与隔离的 Chromium 上下文 |
@browser-smoke | 在 Chromium 与 WebKit 两条 CI 通道运行聚焦的键盘与导航覆盖 |
@skip | 将场景临时排除出所有 runner 配置;产品行为恢复后应尽快移除,禁止用于永久或环境依赖性的屏蔽 |
@agent-backend-runtime | Agent v2 运行时场景;要求显式的运行时可用性步骤 |
无障碍工作流的定位值得强调:文档将其定义为opt-in 的人工审计而非回归门禁。当修改审计工作流、页面矩阵或就绪契约时,PR 作者应在合并前自行运行 AA/all 路径——这与package.json中e2e:accessibility默认转发到e2e:accessibility:aa的实现一致。
外部运行时的规则(对照 run-cucumber.ts 中的 agent backend 启动逻辑):
- Seed 与 Cucumber 必须共享同一个运行时生命周期。组合命令拥有 reset、中间件、服务、seed、Cucumber 与 teardown 全流程;CI 不允许在 workflow YAML 中自行复刻这套生命周期。
E2E_START_AGENT_BACKEND=1会在 API 之前拉起受管的本机 agent backend,且与显式的E2E_AGENT_BACKEND_URL/AGENT_BACKEND_BASE_URL互斥;feature 自带的服务使用自己的标签,agent v2 运行时场景使用@agent-backend-runtime并要求显式的运行时可用性步骤。- 文档末尾是一条纪律性约束:不得用运行时标签暗示无关服务,也不得在必需 fixture 缺失时静默跳过行为。
5. 浏览器、API 与契约边界:什么动作必须由浏览器完成
"Browser, API, And Contract Boundaries" 一节回答了 E2E 中最常见的设计问题:用户动作必须由被测浏览器执行,API 只能准备 fixture、轮询持久化结果与清理,不能替代用户的When动作;结果断言优先选择用户在浏览器中可观测的结果,除非持久化后端状态本身就是被测契约。
API 侧的调用规范同样具体:
- 对普通 Console JSON 与可表达的 multipart 操作,使用场景级或进程级的生成式 oRPC 客户端,并开启请求与响应校验;直接调用生成的操作。e2e/package.json 的依赖可以印证这条约束的落点:
@orpc/client、@orpc/contract、@orpc/openapi-client与 workspace 内的@dify/contracts包正是"生成契约"的来源。 - 禁止清单:手写端点 URL、复制 DTO/schema、响应强转(cast)、一对一转发包装器、可变跨场景客户端、TanStack Query 缓存。
- 仅在 helper 真正拥有某项职责时才保留:fixture 构建、多操作编排、清理注册表、不变量、最终一致性轮询、收窄的测试视图或协议适配器;SSE、二进制下载、仅重定向流程、外部服务与基础设施就绪检查可以集中到真实 owner 下的适配器。
- 校验失败即契约失败:应回溯到后端 schema 的 owner,必要时更新 api/controllers/API_SCHEMA_GUIDE.md 中的契约、重新生成
@dify/contracts,并让场景对齐产品真正的状态 owner;禁止关闭校验或添加兜底 schema 来让 E2E 通过。
6. 种子数据、清理与诊断:可复现与可归因的工程约定
最后一节 "Seeds, Cleanup, And Diagnostics" 给出五组约定,前两组可直接落到具体文件:
- 命名:通过 support/naming.ts 生成带
E2E前缀的一次性资源名;确定性上传素材放在fixtures/test-materials/,经 support/test-materials.ts 解析——二者在目录中均已确认存在。 - 所有权分层:seed 脚本拥有共享的长生命周期 fixture;场景拥有其创建的一次性资源,且必须注册清理。
- 清理机制:已知资源类型使用类型化的
DifyWorld清理字段,其他生命周期 owner 通过registerCleanup(...)注册;注册的回调在类型化清理队列之后按LIFO顺序执行,对应实现位于 support/cleanup.ts(编排器的 teardown 即调用其中的runCleanupTasks,见 run-cucumber.ts#L91-L98)。 - 顺序与归因:先删子资源与被引用资源,再删 owner;清理失败要附到报告上,不得吞掉。
- 诊断产物:失败场景在
cucumber-report/artifacts/下生成截图与 HTML 捕获;HTML 报告与 Cucumber Messages 报告统一放在cucumber-report/;后端与前端的启动日志在.logs/;额外 CI 通道各自保留自己的报告与日志目录。这些路径与cucumber.config.ts中html:./cucumber-report/report.html、message:./cucumber-report/report.ndjson的输出配置,以及 run-cucumber.ts#L79-L80 中cucumberReportDir/logDir的定义完全对应。
7. 小结:这套 E2E 体系回答了什么问题
把 e2e/AGENTS.md 的契约与仓库实现对照起来看,Dify 的仓库级 E2E 体系围绕四个问题给出了一致的答案:
- 谁来拥有服务生命周期?唯一编排器 scripts/run-cucumber.ts,组合命令拥有从 reset 到 teardown 的全流程,CI 不复刻生命周期;
- 哪些场景在默认流水线上跑?cucumber.config.ts 的三级标签优先级 +
not @skip,默认排除@axe/@prepared/@external-*四类可选与外部依赖标签; - 浏览器测试与 API 的边界在哪?
When动作必须发生在浏览器,API 只做 fixture 准备、轮询与清理,且必须走带校验的生成式 oRPC 客户端,校验失败按契约失败处理; - 状态如何可复现、失败如何可归因?
E2E前缀的一次性命名、LIFO 清理注册、cucumber-report/与.logs/的固定诊断产物,加上"至少一条testCaseStarted"的空跑门禁。
对维护者的实际意义是:新增一个 E2E 场景时,先决定它的标签(是否需要@preparedfixture、是否触碰真实外部运行时、是否属于@unauthenticated旅程),再决定When动作走浏览器还是 API,最后确认其创建的资源都注册了清理——这三步走完后,场景自然落入既有的运行时生命周期与报告契约之中。
【免费下载链接】difyBuild Agentic workflows, RAG pipelines, with rich AI model and tool support on one collaborative workspace. Deploy on cloud, VPC, or self-hosted, so teams move from prototype to production without rebuilding the stack.项目地址: https://gitcode.com/GitHub_Trending/di/dify
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考