首页
/ Dify E2E 测试体系详解:Cucumber + Playwright 的仓库级端到端测试如何编排、运行与保障契约

Dify E2E 测试体系详解:Cucumber + Playwright 的仓库级端到端测试如何编排、运行与保障契约

2026-09-06 17:34:37作者:董斯意

本文以 Dify 仓库的 e2e/AGENTS.md 为主体,完整还原该包定义的命令体系、运行时编排职责、标签语义、会话/清理契约与浏览器-API 边界,并结合 e2e/scripts/run-cucumber.tse2e/scripts/setup.tse2e/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.tssetup.tsseed-runner.tsrun-prepared.tsrun-external-runtime.tsrun-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 installpnpm -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.tsensureWebBuild 默认复用 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.tsBefore 钩子中,浏览器类型由 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/

其中两条值得强调的架构决策:

  1. 浏览器与 API 身份严格分离DifyWorld 同时持有浏览器的 BrowserContext 和独立的 consoleRequestContext(API 请求上下文)——文档解释其动机是"让未认证与登出旅程无法破坏 fixture 的所有权"。在 e2e/features/support/world.tsstartSession 中可以看到:浏览侧 context 在 @unauthenticated 时不加载 storageState,而 API 侧始终复用认证态,两者互不干扰。
  2. 跨 Actor 场景保持隔离。多角色场景为每个 actor 建立独立的 BrowserContext 与类型化的 DifyWorld 状态,确保诊断与清理覆盖到每一个 actor(诊断钩子会为多个页面逐一截图,见 e2e/features/support/hooks.tsdiagnosticPages 列表)。
  3. 步骤定义的写法约束:访问 World 状态的步骤定义必须写成 async function (this: DifyWorld, ...),因为箭头函数无法接收 Cucumber 绑定的 World 实例。这是一个容易被忽视的 TypeScript 细节。

3.2 run-cucumber.ts 的完整编排链

阅读 e2e/scripts/run-cucumber.tsmain 函数,实际执行顺序为:

  1. 可选重置与中间件启动--full 时先 resetState(),再 startMiddleware()
  2. 可选 Agent backend 托管栈shouldStartManagedAgentBackend() 为真时,先起 shellctl 沙箱(等待 http://127.0.0.1:5004/healthz 就绪),再起 agent backend(等待 http://127.0.0.1:5050/openapi.json 就绪);
  3. API 服务tsx ./scripts/setup.ts api,就绪判据是 ${apiURL}/health(180 秒超时);
  4. Celery worker:seed 模式下队列显式为 dataset,priority_dataset,workflow_based_app_execution(源码常量 seedCeleryQueues);
  5. Web 服务:通过 startWebServer 启动,支持复用既有服务(reuseExistingServer),超时 300 秒;
  6. 可选 seedrunSeed(seed)
  7. 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
  8. 行为门禁:退出码为 0 时,还会读取 cucumber-report/report.ndjson,断言至少出现一条 testCaseStarted 消息——即"空选标签不能通过";
  9. teardownfinally 中按顺序停止 web、celery、api、agent backend、shellctl 沙箱与中间件,任何 teardown 错误都会使进程失败;SIGINT/SIGTERM 同样触发同一清理路径。

e2e/scripts/setup.tsresetState 定义了"重置"的确切含义:停止中间件容器、清空 docker/volumes 下的 db/plugin_daemon/redis/weaviate 数据目录、删除 .authcucumber-report*.logs*playwright-reportseed-reporttest-results 等 E2E 本地状态。而 startMiddleware 通过 docker compose(--profile postgresql --profile weaviate)拉起 db_postgresredisweaviatesandboxssrf_proxyplugin_daemon 六个服务,并依次等待 PostgreSQL/Redis 健康检查、Weaviate ready 端点、sandbox 健康端点、plugin daemon 5002 端口可达。

3.3 认证是惰性完成的,确定性由 setup 证明

文档明确:未初始化的实例会被惰性地安装并认证,已初始化的实例则直接登录并复用认证态;完整的 reset 与 bootstrap 能力由 setup 流程本身证明,而不是靠某个 Gherkin 场景去证明。源码印证:hooks.tsBefore 钩子中,浏览器首次启动时执行 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-barsummaryhtml:./cucumber-report/report.htmlmessage:./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:preparede2e: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.tsgetAgentBackendBaseUrl 正是按"显式 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 挂上 RequestValidationPluginResponseValidationPlugin 两个插件,请求经由 Playwright 的 APIRequestContext 发出,并自动从 storageState 提取 csrf_token Cookie 注入 X-CSRF-Token 头——CSRF 缺失直接抛错,从机制上保证"未认证态的 API 调用"不会静默发生。

6. 种子、清理与诊断:可复现性的最后防线

6.1 命名与素材

  • 一次性资源名必须经 e2e/support/naming.tscreateE2EResourceName 生成,格式为 E2E [qualifier] <resource> <nonce>;同文件还提供 assertE2EResourceName,任何不以 E2E 开头的资源名会直接断言失败——这让"测试资源"在全库可辨识、可批量清理。
  • 确定性上传素材保留在 e2e/fixtures/test-materials/,统一经 e2e/support/test-materials.ts 解析路径。

6.2 清理契约:谁创建、谁负责

文档规定:seed 脚本拥有共享的长生命周期 fixture;场景拥有自己创建的一次性资源并必须注册清理。实现上分为两层(见 e2e/features/support/hooks.ts):

  1. 类型化清理队列DifyWorldcreatedAppIdscreatedAgentIdscreatedDatasetIdscreatedAgentConfigFilescreatedAgentConfigSkillscreatedBuiltinToolCredentials 等字段,在 Clean up scenario resources 钩子中以 toReversed()(LIFO) 顺序删除——先删子资源与被引用资源,再删所有者;
  2. 注册式清理:对类型化字段之外的生命周期归属者,用 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.tse2eStatePaths 的定义一致);
  • console 错误与 weberror 页面异常会被收集并在诊断阶段附加到报告。

7. 实践路径:如何从仓库状态到一次可信的 E2E 运行

结合 e2e/AGENTS.md 与上述源码,一条可复现的本地实践路径如下(均为文档给出的运行方式,不涉及修改仓库内容):

  1. 一次性准备pnpm install + pnpm -C e2e e2e:install(安装 chromium 与 webkit);
  2. 日常快跑(已初始化实例):pnpm -C e2e e2e -- --tags @smoke,有头调试加 e2e:headed,慢放加 E2E_SLOW_MO=500
  3. 完整确定性验证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"门禁;
  4. 共享 fixture 场景E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:prepared;只想准备数据不跑场景时用 pnpm -C e2e seed -- --profile prepared
  5. 外部运行时场景E2E_START_AGENT_BACKEND=1 pnpm -C e2e e2e:external,或提供 E2E_AGENT_BACKEND_URL / AGENT_BACKEND_BASE_URL 指向既有运行时;
  6. 无障碍审计:按级别跑 e2e:accessibility:a / e2e:accessibility:aa,或按页面标签精确到单页;变更审计工作流、页面矩阵或就绪契约时,PR 作者应在合并前跑一遍 AA/全量路径(文档定位为 opt-in 人工审计,而非回归门禁)。

8. 小结

Dify 的 e2e 包展示了"仓库级端到端测试"的一种严谨形态:一份 AGENTS.md 作为包级契约,命令、标签、清理、诊断、契约边界全部成文;每个约定在 e2e/scripts/run-cucumber.tse2e/scripts/setup.tse2e/features/support/world.tse2e/features/support/hooks.ts 中都有可验证的落地实现;行为门禁(Cucumber 退出码 + 非空 testCaseStarted 断言)与契约校验(oRPC 请求/响应双向校验、CSRF 强制)共同保证了"通过"二字的含金量。对于需要在大型产品中建设 E2E 体系的团队,这套"编排器唯一、职责分片、身份分离、LIFO 清理、空跑不通过"的设计是值得直接对标的范本。

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