When CI runs parallel suite jobs, each job's vitest globalSetup calls provisionApps() independently. Simultaneous findAppByName() calls all return 'not found' (race condition), causing each job to importFromDsl and create duplicate apps — especially in auto_test1 (ws2-workflow.yml). Fix: 1. Check if all DIFY_E2E_*_APP_ID env vars are pre-set (from CI provision job outputs). If so, skip provisionApps entirely and reuse those IDs. 2. Fall back to env-sourced app IDs in capabilities assignment so that provision job outputs are always honoured even if provisionApps runs.
Dify CLI — E2E Test Suite
End-to-end tests that exercise the real difyctl binary against a live
Dify server. Every test uses an isolated temporary config directory so no
state leaks between test files.
Directory layout
test/e2e/
├── setup/
│ ├── env.ts — Load & validate DIFY_E2E_* env vars (CE + EE)
│ ├── global-setup.ts — CE/EE-aware bootstrap: account creation, token
│ │ minting, workspace provisioning, DSL import
│ └── global-teardown.ts — Delete conversations created during the run
│
├── helpers/
│ ├── cli.ts — run(), withAuthFixture(), mintFreshToken(),
│ │ injectAuth(), spawn_background()
│ ├── assert.ts — assertExitCode, assertJson, assertErrorEnvelope,
│ │ assertNoAnsi, assertPipeFriendlyJson, ...
│ ├── cleanup-registry.ts — registerConversation() / cleanupRegisteredConversations()
│ ├── retry.ts — withRetry(fn, { attempts, delayMs })
│ └── skip.ts — optionalIt(), optionalDescribe(),
│ enterpriseOnlyIt(), enterpriseOnlyDescribe(), isEE()
│
└── suites/
├── auth/
│ ├── status.e2e.ts — auth status (text + JSON + SSO)
│ ├── use.e2e.ts — workspace switching ([EE] cases require 2 workspaces)
│ ├── whoami.e2e.ts — whoami + external SSO session checks
│ ├── devices.e2e.ts — devices list + revoke (runs near-last)
│ └── logout.e2e.ts — logout + local credential cleanup (runs last)
├── config/
│ └── config.e2e.ts — config path/get/set/unset/view, env override
├── discovery/
│ ├── get-app-list.e2e.ts — basic get app list
│ ├── get-app-single.e2e.ts — get single app by ID
│ ├── describe-app.e2e.ts — describe app
│ └── get-app-all-workspaces.e2e.ts — get app -A ([EE] multi-workspace cases)
└── run/
├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming,
│ conversation, CI mode
├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing
├── run-app-file.e2e.ts — --file upload (local + remote URL)
└── run-app-hitl.e2e.ts — HITL pause + resume
Edition support
difyctl supports two Dify editions. The test suite adapts automatically:
| Edition | DIFY_E2E_EDITION |
Workspaces | EE-only cases |
|---|---|---|---|
| Community Edition (CE) | ce (default) |
1 | Skipped |
| Enterprise Edition (EE) | ee |
2 (auto-created) | Active |
EE-only test cases
Tests that require Enterprise Edition features (workspace switching between
independent workspaces, cross-workspace app query, etc.) are tagged [EE]
in their names and wrapped with enterpriseOnlyIt() / enterpriseOnlyDescribe()
from helpers/skip.ts. In CE mode these tests are automatically skipped.
// helpers/skip.ts usage
const eeIt = enterpriseOnlyIt(caps)
eeIt('[EE][P0] cross-workspace query returns apps from all workspaces', async () => {
// test body
})
Setup
Copy the credential template and fill in your values:
cp cli/test/e2e/.env.e2e.example cli/.env.e2e
# edit cli/.env.e2e with real credentials
Community Edition (CE) — minimum 3 vars
| Variable | Description |
|---|---|
DIFY_E2E_HOST |
Server base URL (http://localhost) |
DIFY_E2E_EMAIL |
Account email — created automatically by global-setup |
DIFY_E2E_PASSWORD |
Account password |
global-setup will:
- Register the account (idempotent — safe to rerun)
- Login and mint a bearer token via the device flow
- Import all DSL fixtures into the single workspace
- Publish apps and set access_mode → public
Enterprise Edition (EE) — 5 required vars
| Variable | Description |
|---|---|
DIFY_E2E_EDITION |
Must be ee |
DIFY_E2E_HOST |
Console/API base URL |
DIFY_E2E_EMAIL |
Member account email — created via enterprise API |
DIFY_E2E_PASSWORD |
Member account password |
DIFY_E2E_ENTERPRISE_API_URL |
Enterprise admin API base URL (https://.../inner/api) |
DIFY_E2E_ENTERPRISE_API_SECRET_KEY |
Enterprise admin API secret key |
Optional:
| Variable | Description |
|---|---|
DIFY_E2E_CONSOLE_URL |
Console URL if different from DIFY_E2E_HOST |
global-setup will:
- Create the member account via the enterprise admin API (idempotent)
- Login and obtain a session cookie
- Create two workspaces (
e2e-primary-auto,e2e-secondary-auto) via the enterprise API - Import DSL fixtures into both workspaces
- Publish apps and set access_mode → public via the enterprise API
Optional overrides (both editions)
| Variable | Description |
|---|---|
DIFY_E2E_TOKEN |
Pre-minted bearer token — skips device-flow mint |
DIFY_E2E_SSO_TOKEN |
External SSO bearer token (dfoe_...) |
DIFY_E2E_WORKSPACE_ID |
Override primary workspace ID |
DIFY_E2E_WORKSPACE_NAME |
Override primary workspace name |
DIFY_E2E_WS2_ID |
Override secondary workspace ID (EE) |
DIFY_E2E_CHAT_APP_ID |
Override echo-chat app ID |
DIFY_E2E_WORKFLOW_APP_ID |
Override echo-workflow app ID |
DIFY_E2E_FILE_APP_ID |
Override file-upload app ID |
DIFY_E2E_FILE_CHAT_APP_ID |
Override file-chat app ID |
DIFY_E2E_HITL_APP_ID |
Override HITL main app ID |
DIFY_E2E_HITL_EXTERNAL_APP_ID |
|
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID |
|
DIFY_E2E_HITL_MULTI_NODE_APP_ID |
|
DIFY_E2E_WS2_APP_ID |
Override secondary workspace app ID (EE) |
Running tests
cd cli
# Community Edition (default)
bun run test:e2e
# Enterprise Edition
DIFY_E2E_EDITION=ee bun run test:e2e
# Run only [P0] smoke cases
bun run test:e2e:smoke
# Run only EE-tagged cases (P0 smoke)
DIFY_E2E_EDITION=ee bun run test:e2e:smoke --testNamePattern "\[EE\]"
# Run offline-safe config tests only (no network required)
bun run test:e2e:local
# Run a single file
bun vitest --config vitest.e2e.config.ts test/e2e/suites/auth/status.e2e.ts
Test execution order
Files run sequentially (fileParallelism: false) in this order:
login → status → use → whoami → help → config → output → error-handling
→ framework → discovery → run (basic / streaming / file / HITL)
→ devices → logout
devices and logout run last because they revoke real server sessions.
Design decisions
| Decision | Rationale |
|---|---|
| CE/EE edition flag | DIFY_E2E_EDITION=ce/ee controls global-setup bootstrap path and activates/skips [EE]-tagged tests. |
[EE] tag convention |
Test names include [EE] to make skipped cases visible in the report and to allow --testNamePattern "\[EE\]" filtering. |
enterpriseOnlyIt(caps) |
Returns it in EE mode, it.skip in CE mode — no runtime assertions needed, skip is declarative. |
| No mocking | All HTTP traffic goes to the real server — this catches real integration regressions. |
| Isolated config dirs | Each test creates a fresh withTempConfig() dir; session state never leaks between tests. |
withAuthFixture() |
Combines withTempConfig + injectAuth into a single fixture; reduces beforeEach boilerplate. |
injectAuth() bypasses Device Flow |
Non-auth tests skip the browser step; only auth/ suites exercise the real flow. |
mintFreshToken() |
logout and devices-revoke tests mint a disposable dfoa_ token via the device flow API. |
Global retry: 0 |
Flaky network calls use withRetry() locally; global retry masks non-idempotent failures. |
| Conversation cleanup | registerConversation() + global-teardown delete staging conversations after the run. |