Compare commits

..

6 Commits

Author SHA1 Message Date
e0e0ae372a ci(cli-e2e): remove Dify stack build, read env from Secrets
Drop the middleware/API/provision steps that spun up a full Dify
Docker stack. All DIFY_E2E_* vars are now injected from repository
Secrets so the workflow targets an existing staging server instead
of building one on every run.

Also removed: dify_version input (no longer needed), Dump Dify logs
step (no Docker stack to inspect). Timeout reduced from 45→30 min.
2026-05-27 17:30:02 +08:00
bc3b1c0c81 fix(ci): fix pnpm version conflict in cli-e2e workflow; refine e2e suite
- Override pnpm to v11 (packageManager field) before CLI install step to
  resolve ERR_PNPM_BAD_PM_VERSION conflict with setup-web's pnpm@9
- Add per-suite dedicated tokens (logoutToken / devicesToken) in global-setup
  via device flow to prevent token cross-contamination between suites
- Translate all Chinese comments and test names to English across e2e suites
- Fix injectAuth: add tokenId field so devices revoke correctly detects selfHit
- Fix HITL test: read action id dynamically from pause response instead of
  hardcoding 'submit'
2026-05-27 17:23:46 +08:00
b734afd609 ci: add GitHub Actions workflow for CLI E2E tests
Triggers on pull_request when cli/** files change.
Spins up a full Dify stack via docker compose, provisions
an admin account + test apps, then runs the E2E smoke suite
(P0 cases only for CI speed).
2026-05-27 11:09:19 +08:00
5646bda88e test(e2e): add E2E test suite for CLI v1.0
Covers auth, config, run, and CLI framework scenarios against a live
staging server using vitest + real difyctl binary.

Suite layout:
  test/e2e/
  ├── helpers/
  │   ├── cli.ts          — run(), withAuthFixture(), mintFreshToken()
  │   ├── assert.ts       — assertExitCode, assertJson, assertErrorEnvelope
  │   ├── cleanup-registry.ts — staging data teardown
  │   └── retry.ts        — withRetry() for flaky network assertions
  ├── setup/
  │   ├── global-setup.ts — health-check, disposable token mint
  │   └── global-teardown.ts — conversation cleanup
  └── suites/
      ├── auth/           — status, use, whoami, devices, logout
      ├── config/         — path, get/set/unset/view, env override
      └── run/            — basic, streaming, conversation, file, HITL

Key design decisions:
- Each test uses an isolated temp configDir via withAuthFixture()
- Logout and devices-revoke tests run last to avoid invalidating
  the shared E.token used by all other suites
- mintFreshToken() mints a disposable dfoa_ token on demand via the
  device flow API so revoke tests never touch the primary session
- Global retry is 0; flaky network calls use withRetry() locally
- test:e2e:smoke script filters to [P0] cases via testNamePattern

package.json: add test:e2e / test:e2e:smoke / test:e2e:local scripts
.gitignore: exclude .env.e2e, oclif.manifest.json, tmp/
.env.e2e.example: credential template for local setup
2026-05-27 10:07:28 +08:00
yyh
58b8fc21d4 fix(plugin): align local install modal spacing (#36689)
Co-authored-by: wangxiaolei <fatelei@gmail.com>
2026-05-27 01:12:57 +00:00
e0ad088657 chore: add App type annotations to api endpoints (#36675) 2026-05-26 15:35:48 +00:00
46 changed files with 3747 additions and 159 deletions

91
.github/workflows/cli-e2e.yml vendored Normal file
View File

@ -0,0 +1,91 @@
name: CLI E2E Tests
on:
workflow_dispatch:
inputs:
cli_ref:
description: "Git ref to build the CLI from (default: current branch)"
type: string
required: false
test_scope:
description: "Test scope to run"
type: choice
required: false
default: smoke
options:
- smoke # [P0] cases only — fast
- full # all cases
permissions:
contents: read
jobs:
e2e:
name: E2E — difyctl
runs-on: ubuntu-latest
timeout-minutes: 30
defaults:
run:
shell: bash
steps:
# ── Checkout ───────────────────────────────────────────────────────────
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
# ── Runtime setup ──────────────────────────────────────────────────────
- name: Setup web environment
uses: ./.github/actions/setup-web
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
# Re-initialise pnpm to match the CLI's packageManager (pnpm@11.x).
# setup-web installs pnpm@9 via setup-vp; this step overrides it so
# the CLI workspace uses the correct version declared in cli/package.json.
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
with:
package_json_field: packageManager
run_install: false
- name: Install CLI dependencies
working-directory: cli
run: pnpm install --frozen-lockfile
- name: Generate command tree
working-directory: cli
run: pnpm tree:gen
# ── Run E2E tests ──────────────────────────────────────────────────────
- name: Run E2E tests (${{ inputs.test_scope || 'smoke' }})
working-directory: cli
env:
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
DIFY_E2E_WORKSPACE_ID: ${{ secrets.DIFY_E2E_WORKSPACE_ID }}
DIFY_E2E_WORKSPACE_NAME: ${{ secrets.DIFY_E2E_WORKSPACE_NAME }}
DIFY_E2E_CHAT_APP_ID: ${{ secrets.DIFY_E2E_CHAT_APP_ID }}
DIFY_E2E_WORKFLOW_APP_ID: ${{ secrets.DIFY_E2E_WORKFLOW_APP_ID }}
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
DIFY_E2E_HITL_APP_ID: ${{ secrets.DIFY_E2E_HITL_APP_ID }}
DIFY_E2E_FILE_APP_ID: ${{ secrets.DIFY_E2E_FILE_APP_ID }}
run: |
if [ "${{ inputs.test_scope }}" = "full" ]; then
pnpm test:e2e
else
pnpm test:e2e:smoke
fi
# ── Upload results ─────────────────────────────────────────────────────
- name: Upload test results on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: e2e-results-${{ github.run_id }}
path: cli/test-results/
retention-days: 3

View File

@ -5,7 +5,7 @@ from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
from libs.login import current_account_with_tenant, login_required
from models.model import AppMode
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
@ -19,7 +19,7 @@ class WorkflowAgentComposerApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model, node_id: str):
def get(self, app_model: App, node_id: str):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_workflow_composer(
tenant_id=tenant_id,
@ -33,7 +33,7 @@ class WorkflowAgentComposerApi(Resource):
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def put(self, app_model, node_id: str):
def put(self, app_model: App, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
@ -52,7 +52,7 @@ class WorkflowAgentComposerValidateApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
def post(self, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
@ -64,7 +64,7 @@ class WorkflowAgentComposerCandidatesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def get(self, app_model, node_id: str):
def get(self, app_model: App, node_id: str):
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
@ -74,7 +74,7 @@ class WorkflowAgentComposerImpactApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
def post(self, app_model: App, node_id: str):
_, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
@ -91,7 +91,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def post(self, app_model, node_id: str):
def post(self, app_model: App, node_id: str):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_workflow_composer(
@ -109,7 +109,7 @@ class AgentAppComposerApi(Resource):
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model):
def get(self, app_model: App):
_, tenant_id = current_account_with_tenant()
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
@ -119,7 +119,7 @@ class AgentAppComposerApi(Resource):
@account_initialization_required
@edit_permission_required
@get_app_model()
def put(self, app_model):
def put(self, app_model: App):
account, tenant_id = current_account_with_tenant()
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
return AgentComposerService.save_agent_app_composer(
@ -137,7 +137,7 @@ class AgentAppComposerValidateApi(Resource):
@login_required
@account_initialization_required
@get_app_model()
def post(self, app_model):
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
return {"result": "success", "errors": []}
@ -149,5 +149,5 @@ class AgentAppComposerCandidatesApi(Resource):
@login_required
@account_initialization_required
@get_app_model()
def get(self, app_model):
def get(self, app_model: App):
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)

View File

@ -8,7 +8,7 @@ from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import account_initialization_required, setup_required
from libs.helper import uuid_value
from libs.login import login_required
from models.model import AppMode
from models.model import App, AppMode
from services.agent_service import AgentService
@ -39,7 +39,7 @@ class AgentLogApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT_CHAT])
def get(self, app_model):
def get(self, app_model: App):
"""Get agent logs"""
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))

View File

@ -573,7 +573,7 @@ class AppApi(Resource):
@account_initialization_required
@enterprise_license_required
@get_app_model(mode=None)
def get(self, app_model):
def get(self, app_model: App):
"""Get app detail"""
app_service = AppService()
@ -581,7 +581,7 @@ class AppApi(Resource):
if FeatureService.get_system_features().webapp_auth.enabled:
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
app_model.access_mode = app_setting.access_mode
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
return response_model.model_dump(mode="json")
@ -598,7 +598,7 @@ class AppApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def put(self, app_model):
def put(self, app_model: App):
"""Update app"""
args = UpdateAppPayload.model_validate(console_ns.payload)
@ -627,7 +627,7 @@ class AppApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
def delete(self, app_model):
def delete(self, app_model: App):
"""Delete app"""
app_service = AppService()
app_service.delete_app(app_model)
@ -648,7 +648,7 @@ class AppCopyApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model):
def post(self, app_model: App):
"""Copy app"""
# The role of the current user in the ta table must be admin, owner, or editor
current_user, _ = current_account_with_tenant()
@ -709,7 +709,7 @@ class AppExportApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
def get(self, app_model):
def get(self, app_model: App):
"""Export app"""
args = AppExportQuery.model_validate(request.args.to_dict(flat=True))
@ -731,7 +731,7 @@ class AppPublishToCreatorsPlatformApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model):
def post(self, app_model: App):
"""Publish app to Creators Platform"""
from configs import dify_config
from core.helper.creators import get_redirect_url, upload_dsl
@ -762,7 +762,7 @@ class AppNameApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model):
def post(self, app_model: App):
args = AppNamePayload.model_validate(console_ns.payload)
app_service = AppService()
@ -784,7 +784,7 @@ class AppIconApi(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model):
def post(self, app_model: App):
args = AppIconPayload.model_validate(console_ns.payload or {})
app_service = AppService()
@ -811,7 +811,7 @@ class AppSiteStatus(Resource):
@account_initialization_required
@get_app_model(mode=None)
@edit_permission_required
def post(self, app_model):
def post(self, app_model: App):
args = AppSiteStatusPayload.model_validate(console_ns.payload)
app_service = AppService()
@ -833,7 +833,7 @@ class AppApiStatus(Resource):
@is_admin_or_owner_required
@account_initialization_required
@get_app_model(mode=None)
def post(self, app_model):
def post(self, app_model: App):
args = AppApiStatusPayload.model_validate(console_ns.payload)
app_service = AppService()
@ -874,7 +874,7 @@ class AppTraceApi(Resource):
@account_initialization_required
@edit_permission_required
@get_app_model
def post(self, app_model):
def post(self, app_model: App):
# add app trace
args = AppTracePayload.model_validate(console_ns.payload)

View File

@ -70,7 +70,7 @@ class ChatMessageAudioApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def post(self, app_model):
def post(self, app_model: App):
file = request.files["file"]
try:
@ -171,7 +171,7 @@ class TextModesApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
try:
args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))

View File

@ -33,7 +33,7 @@ from libs import helper
from libs.helper import uuid_value
from libs.login import current_user, login_required
from models import Account
from models.model import AppMode
from models.model import App, AppMode
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.errors.llm import InvokeRateLimitError
@ -84,7 +84,7 @@ class CompletionMessageApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
def post(self, app_model):
def post(self, app_model: App):
args_model = CompletionMessagePayload.model_validate(console_ns.payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
@ -131,7 +131,7 @@ class CompletionMessageStopApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
def post(self, app_model, task_id: str):
def post(self, app_model: App, task_id: str):
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")
@ -159,7 +159,7 @@ class ChatMessageApi(Resource):
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT])
@edit_permission_required
def post(self, app_model):
def post(self, app_model: App):
args_model = ChatMessagePayload.model_validate(console_ns.payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
@ -212,7 +212,7 @@ class ChatMessageStopApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def post(self, app_model, task_id: str):
def post(self, app_model: App, task_id: str):
if not isinstance(current_user, Account):
raise ValueError("current_user must be an Account instance")

View File

@ -33,7 +33,7 @@ from fields.conversation_fields import (
from libs.datetime_utils import naive_utc_now, parse_time_range
from libs.login import current_account_with_tenant, login_required
from models import Conversation, EndUser, Message, MessageAnnotation
from models.model import AppMode
from models.model import App, AppMode
from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError
@ -93,7 +93,7 @@ class CompletionConversationApi(Resource):
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
def get(self, app_model):
def get(self, app_model: App):
current_user, _ = current_account_with_tenant()
args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True))
@ -165,7 +165,7 @@ class CompletionConversationDetailApi(Resource):
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
def get(self, app_model, conversation_id: UUID):
def get(self, app_model: App, conversation_id: UUID):
conversation_id_str = str(conversation_id)
return ConversationMessageDetailResponse.model_validate(
_get_conversation(app_model, conversation_id_str), from_attributes=True
@ -182,7 +182,7 @@ class CompletionConversationDetailApi(Resource):
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
@edit_permission_required
def delete(self, app_model, conversation_id: UUID):
def delete(self, app_model: App, conversation_id: UUID):
current_user, _ = current_account_with_tenant()
conversation_id_str = str(conversation_id)
@ -207,7 +207,7 @@ class ChatConversationApi(Resource):
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@edit_permission_required
def get(self, app_model):
def get(self, app_model: App):
current_user, _ = current_account_with_tenant()
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True))
@ -318,7 +318,7 @@ class ChatConversationDetailApi(Resource):
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@edit_permission_required
def get(self, app_model, conversation_id: UUID):
def get(self, app_model: App, conversation_id: UUID):
conversation_id_str = str(conversation_id)
return ConversationDetailResponse.model_validate(
_get_conversation(app_model, conversation_id_str), from_attributes=True
@ -335,7 +335,7 @@ class ChatConversationDetailApi(Resource):
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@account_initialization_required
@edit_permission_required
def delete(self, app_model, conversation_id: UUID):
def delete(self, app_model: App, conversation_id: UUID):
current_user, _ = current_account_with_tenant()
conversation_id_str = str(conversation_id)

View File

@ -19,7 +19,7 @@ from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.login import login_required
from models import ConversationVariable
from models.model import AppMode
from models.model import App, AppMode
class ConversationVariablesQuery(BaseModel):
@ -94,7 +94,7 @@ class ConversationVariablesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.ADVANCED_CHAT)
def get(self, app_model):
def get(self, app_model: App):
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True))
stmt = (

View File

@ -17,7 +17,7 @@ from fields.base import ResponseModel
from libs.helper import to_timestamp
from libs.login import current_account_with_tenant, login_required
from models.enums import AppMCPServerStatus
from models.model import AppMCPServer
from models.model import App, AppMCPServer
class MCPServerCreatePayload(BaseModel):
@ -73,7 +73,7 @@ class AppMCPServerController(Resource):
@account_initialization_required
@setup_required
@get_app_model
def get(self, app_model):
def get(self, app_model: App):
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
if server is None:
return {}
@ -92,7 +92,7 @@ class AppMCPServerController(Resource):
@login_required
@setup_required
@edit_permission_required
def post(self, app_model):
def post(self, app_model: App):
_, current_tenant_id = current_account_with_tenant()
payload = MCPServerCreatePayload.model_validate(console_ns.payload or {})
@ -127,7 +127,7 @@ class AppMCPServerController(Resource):
@setup_required
@account_initialization_required
@edit_permission_required
def put(self, app_model):
def put(self, app_model: App):
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
server = db.session.get(AppMCPServer, payload.id)
if not server:

View File

@ -45,7 +45,7 @@ from libs.helper import to_timestamp, uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import current_account_with_tenant, login_required
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.message_service import MessageService, attach_message_extra_contents
@ -180,7 +180,7 @@ class ChatMessageListApi(Resource):
@setup_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
@edit_permission_required
def get(self, app_model):
def get(self, app_model: App):
args = ChatMessagesQuery.model_validate(request.args.to_dict())
conversation = db.session.scalar(
@ -257,7 +257,7 @@ class MessageFeedbackApi(Resource):
@setup_required
@login_required
@account_initialization_required
def post(self, app_model):
def post(self, app_model: App):
current_user, _ = current_account_with_tenant()
args = MessageFeedbackPayload.model_validate(console_ns.payload)
@ -314,7 +314,7 @@ class MessageAnnotationCountApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
count = db.session.scalar(
select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id)
)
@ -337,7 +337,7 @@ class MessageSuggestedQuestionApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def get(self, app_model, message_id: UUID):
def get(self, app_model: App, message_id: UUID):
current_user, _ = current_account_with_tenant()
message_id_str = str(message_id)
@ -379,7 +379,7 @@ class MessageFeedbackExportApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
args = FeedbackExportQuery.model_validate(request.args.to_dict())
# Import the service function
@ -417,7 +417,7 @@ class MessageApi(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model, message_id: UUID):
def get(self, app_model: App, message_id: UUID):
message_id_str = str(message_id)
message = db.session.scalar(

View File

@ -16,7 +16,7 @@ from events.app_event import app_model_config_was_updated
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.login import current_account_with_tenant, login_required
from models.model import AppMode, AppModelConfig
from models.model import App, AppMode, AppModelConfig
from services.app_model_config_service import AppModelConfigService
@ -52,7 +52,7 @@ class ModelConfigResource(Resource):
@edit_permission_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION])
def post(self, app_model):
def post(self, app_model: App):
"""Modify app model config"""
current_user, current_tenant_id = current_account_with_tenant()
# validate config

View File

@ -20,6 +20,7 @@ from fields.base import ResponseModel
from libs.datetime_utils import naive_utc_now
from libs.login import current_account_with_tenant, login_required
from models import Site
from models.model import App
class AppSiteUpdatePayload(BaseModel):
@ -84,7 +85,7 @@ class AppSite(Resource):
@edit_permission_required
@account_initialization_required
@get_app_model
def post(self, app_model):
def post(self, app_model: App):
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
current_user, _ = current_account_with_tenant()
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
@ -133,7 +134,7 @@ class AppSiteAccessTokenReset(Resource):
@is_admin_or_owner_required
@account_initialization_required
@get_app_model
def post(self, app_model):
def post(self, app_model: App):
current_user, _ = current_account_with_tenant()
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))

View File

@ -15,6 +15,7 @@ from libs.datetime_utils import parse_time_range
from libs.helper import convert_datetime_to_date
from libs.login import current_account_with_tenant, login_required
from models import AppMode
from models.model import App
class StatisticTimeRangeQuery(BaseModel):
@ -47,7 +48,7 @@ class DailyMessageStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -61,8 +62,12 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -104,7 +109,7 @@ class DailyConversationStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -118,8 +123,12 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -160,7 +169,7 @@ class DailyTerminalsStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -174,8 +183,12 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -217,7 +230,7 @@ class DailyTokenCostStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -232,8 +245,12 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -277,7 +294,7 @@ class AverageSessionInteractionStatistic(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -299,8 +316,12 @@ FROM
WHERE
c.app_id = :app_id
AND m.invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -353,7 +374,7 @@ class UserSatisfactionRateStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -371,8 +392,12 @@ LEFT JOIN
WHERE
m.app_id = :app_id
AND m.invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -419,7 +444,7 @@ class AverageResponseTimeStatistic(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=AppMode.COMPLETION)
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -433,8 +458,12 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
@ -476,7 +505,7 @@ class TokensPerSecondStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
@ -492,8 +521,12 @@ FROM
WHERE
app_id = :app_id
AND invoke_from != :invoke_from"""
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
assert account.timezone is not None
arg_dict: dict[str, object] = {
"tz": account.timezone,
"app_id": app_model.id,
"invoke_from": InvokeFrom.DEBUGGER,
}
try:
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)

View File

@ -11,7 +11,7 @@ from extensions.ext_database import db
from libs.datetime_utils import parse_time_range
from libs.login import current_account_with_tenant, login_required
from models.enums import WorkflowRunTriggeredFrom
from models.model import AppMode
from models.model import App, AppMode
from repositories.factory import DifyAPIRepositoryFactory
@ -46,7 +46,7 @@ class WorkflowDailyRunsStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
@ -86,7 +86,7 @@ class WorkflowDailyTerminalsStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
@ -126,7 +126,7 @@ class WorkflowDailyTokenCostStatistic(Resource):
@setup_required
@login_required
@account_initialization_required
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
@ -166,7 +166,7 @@ class WorkflowAverageAppInteractionStatistic(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW])
def get(self, app_model):
def get(self, app_model: App):
account, _ = current_account_with_tenant()
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))

20
cli/.env.e2e.example Normal file
View File

@ -0,0 +1,20 @@
# E2E test environment variables
# Copy this file to .env.e2e and fill in real values before running tests.
# See test/e2e/setup/env.ts for documentation on each variable.
# Required
DIFY_E2E_HOST=https://your-staging-host.dify.ai
DIFY_E2E_TOKEN=dfoa_your_token_here
DIFY_E2E_WORKSPACE_ID=ws-your-workspace-id
DIFY_E2E_CHAT_APP_ID=app-echo-chat-id
DIFY_E2E_WORKFLOW_APP_ID=app-echo-workflow-id
# Optional (skip related tests when absent)
DIFY_E2E_SSO_TOKEN=
DIFY_E2E_HITL_APP_ID=
DIFY_E2E_FILE_APP_ID=
DIFY_E2E_WORKSPACE_NAME=
# For logout / devices revoke tests (mint disposable tokens via device flow API)
DIFY_E2E_EMAIL=
DIFY_E2E_PASSWORD=

8
cli/.gitignore vendored
View File

@ -4,4 +4,10 @@ node_modules/
*.tsbuildinfo
.vitest-cache/
docs/specs/
context/
context/
# E2E test env (contains tokens/credentials — use .env.e2e.example instead)
.env.e2e
# Generated / runtime artifacts
oclif.manifest.json
npm-shrinkwrap.json
tmp/

View File

@ -30,6 +30,9 @@
"dev": "bun bin/dev.js",
"test": "vp test",
"test:coverage": "vp test --coverage",
"test:e2e": "vp test --config vitest.e2e.config.ts",
"test:e2e:smoke": "vp test --config vitest.e2e.config.ts --testNamePattern \"\\[P0\\]\"",
"test:e2e:local": "DIFY_E2E_MODE=local vp test --config vitest.e2e.config.ts",
"lint": "eslint",
"lint:fix": "eslint --fix",
"type-check": "tsc",

View File

115
cli/test/e2e/README.md Normal file
View File

@ -0,0 +1,115 @@
# 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
│ ├── global-setup.ts — Health-check server + mint disposable token
│ └── 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()
└── suites/
├── auth/
│ ├── status.e2e.ts — auth status (text + JSON + SSO)
│ ├── use.e2e.ts — workspace switching
│ ├── 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
└── 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
```
## Setup
Copy the credential template and fill in your values:
```bash
cp cli/.env.e2e.example cli/.env.e2e
# edit cli/.env.e2e with real credentials
```
### Required env vars
| Variable | Description |
| -------------------------- | -------------------------------------------------------- |
| `DIFY_E2E_HOST` | Staging server base URL (`http://localhost`) |
| `DIFY_E2E_TOKEN` | Internal user bearer token (`dfoa_…`) |
| `DIFY_E2E_WORKSPACE_ID` | Primary workspace ID |
| `DIFY_E2E_CHAT_APP_ID` | Chat app — outputs `echo: {query}` |
| `DIFY_E2E_WORKFLOW_APP_ID` | Workflow app — input `x` (required), outputs `echo: {x}` |
### Optional env vars
| Variable | Description |
| ------------------------- | ---------------------------------------------------- |
| `DIFY_E2E_SSO_TOKEN` | External SSO bearer token (`dfoe_…`) |
| `DIFY_E2E_HITL_APP_ID` | Workflow app with a Human-Input node |
| `DIFY_E2E_FILE_APP_ID` | Workflow app with a file input variable (`doc`) |
| `DIFY_E2E_WORKSPACE_NAME` | Display name for the primary workspace |
| `DIFY_E2E_EMAIL` | Console account email (enables disposable tokens) |
| `DIFY_E2E_PASSWORD` | Console account password (enables disposable tokens) |
> `DIFY_E2E_EMAIL` + `DIFY_E2E_PASSWORD` are used by `global-setup` and the
> `devices`/`logout` suites to mint fresh single-use `dfoa_` tokens via the
> device flow API, so those tests never revoke the shared `DIFY_E2E_TOKEN`.
## Running tests
```bash
cd cli
# Run the full E2E suite
bun run test:e2e
# Run only [P0] smoke cases
bun run test:e2e:smoke
# 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:
```
status → use → whoami → config → run (basic / streaming / file / HITL)
→ devices → logout
```
`devices` and `logout` run last because they revoke real server sessions.
## Design decisions
| Decision | Rationale |
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
| **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, so revoking it never kills the shared `DIFY_E2E_TOKEN`. |
| **Global `retry: 0`** | Flaky network calls use `withRetry()` locally with `shouldRetry` filtering; global retry masks non-idempotent failures (e.g. logout). |
| **Conversation cleanup** | `registerConversation()` + global-teardown delete staging conversations after the run. |

View File

@ -0,0 +1,155 @@
/**
* E2E assertion helpers.
*
* These wrap vitest's `expect` with richer failure messages that include the
* full stdout / stderr of the failing process — essential for debugging CI.
*/
import type { RunResult } from './cli.js'
import { expect } from 'vitest'
import './vitest-context.js'
// ── ANSI ──────────────────────────────────────────────────────────────────
// eslint-disable-next-line no-control-regex
const ANSI_RE = /\x1B\[[0-9;]*[mGKHFA-DJsuhl]/g
function redact(text: string): string {
return text
.replace(/\bBearer\s+[\w.-]+\b/g, 'Bearer [REDACTED]')
.replace(/\bdfo[ae]_[\w-]+\b/g, 'dfo*_REDACTED')
}
// ── Exit code ─────────────────────────────────────────────────────────────
/**
* Assert the exit code matches `expected`.
* On failure, prints the full stdout and stderr so the cause is visible in CI.
*/
export function assertExitCode(result: RunResult, expected: number): void {
if (result.exitCode !== expected) {
process.stderr.write(
`\n[E2E assertExitCode] expected ${expected}, got ${result.exitCode}\n`
+ `stdout:\n${redact(result.stdout) || '(empty)'}\n`
+ `stderr:\n${redact(result.stderr) || '(empty)'}\n`,
)
}
expect(result.exitCode, `exit code should be ${expected}`).toBe(expected)
}
/**
* Assert the exit code is NOT 0 (i.e. some error occurred).
*/
export function assertNonZeroExit(result: RunResult): void {
expect(result.exitCode, 'exit code should be non-zero').not.toBe(0)
}
// ── Stdout / stderr content ───────────────────────────────────────────────
/**
* Assert stdout is valid JSON and return the parsed value.
*/
export function assertJson<T = unknown>(result: RunResult): T {
let parsed: T
try {
parsed = JSON.parse(result.stdout) as T
}
catch {
throw new Error(
`stdout is not valid JSON.\nstdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}`,
)
}
return parsed
}
/**
* Assert stderr contains a valid JSON error envelope of the shape:
* { error: { code: string, message: string, hint?: string } }
*
* @param result - The run result to inspect.
* @param expectedCode - When provided, also asserts that error.code equals this value.
* Use the stable error codes from the CLI contract, e.g.:
* 'not_logged_in', 'app_not_found', 'insufficient_scope', 'auth_expired'
*
* @example
* assertErrorEnvelope(result, 'not_logged_in')
* assertErrorEnvelope(result, 'app_not_found')
*/
export function assertErrorEnvelope(
result: RunResult,
expectedCode?: string,
): { error: { code: string, message: string, hint?: string } } {
const raw = result.stderr.trim()
let parsed: { error: { code: string, message: string, hint?: string } }
try {
parsed = JSON.parse(raw) as typeof parsed
}
catch {
throw new Error(
`stderr is not valid JSON.\nstdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}`,
)
}
expect(parsed, 'stderr envelope missing "error" key').toHaveProperty('error')
expect(parsed.error, 'error.code must be a non-empty string').toHaveProperty('code')
expect(parsed.error, 'error.message must be a non-empty string').toHaveProperty('message')
expect(typeof parsed.error.code, 'error.code must be a string').toBe('string')
expect(parsed.error.code.length, 'error.code must be non-empty').toBeGreaterThan(0)
if (expectedCode !== undefined) {
expect(
parsed.error.code,
`error.code should be "${expectedCode}", got "${parsed.error.code}"\nstderr:\n${redact(result.stderr)}`,
).toBe(expectedCode)
}
return parsed
}
// ── ANSI / formatting ────────────────────────────────────────────────────
/**
* Assert the given text contains no ANSI escape sequences.
* Pass `label` to identify which stream failed (e.g. 'stdout', 'stderr').
*/
export function assertNoAnsi(text: string, label = 'output'): void {
const clean = text.replace(ANSI_RE, '')
expect(text, `${label} must not contain ANSI control codes`).toBe(clean)
}
/**
* Assert stdout starts with `{` and ends with `\n` — the canonical format
* for pipe-friendly JSON output.
*/
export function assertPipeFriendlyJson(result: RunResult): void {
assertNoAnsi(result.stdout, 'stdout')
expect(
result.stdout.trimStart().startsWith('{') || result.stdout.trimStart().startsWith('['),
'stdout should start with { or [ for pipe-friendly JSON',
).toBe(true)
expect(result.stdout.endsWith('\n'), 'stdout should end with newline').toBe(true)
}
// ── stdout / stderr contains ──────────────────────────────────────────────
/**
* Assert stdout contains the given substring, printing full output on failure.
*/
export function assertStdoutContains(result: RunResult, expected: string): void {
if (!result.stdout.includes(expected)) {
process.stderr.write(
`\n[E2E assertStdoutContains] "${expected}" not found in stdout.\n`
+ `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`,
)
}
expect(result.stdout).toContain(expected)
}
/**
* Assert stderr contains the given substring, printing full output on failure.
*/
export function assertStderrContains(result: RunResult, expected: string): void {
if (!result.stderr.includes(expected)) {
process.stderr.write(
`\n[E2E assertStderrContains] "${expected}" not found in stderr.\n`
+ `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`,
)
}
expect(result.stderr).toContain(expected)
}

View File

@ -0,0 +1,93 @@
/**
* E2E cleanup registry.
*
* Test suites call `registerConversation(host, token, appId, conversationId)`
* whenever a real conversation is created on staging. The global teardown
* iterates the registry and deletes all collected conversations so staging
* data stays clean between CI runs.
*
* Design notes:
* - Uses a module-level array (shared within the same worker process).
* - vitest runs E2E suites in a single fork (fileParallelism: false), so one
* process owns the full registry.
* - Deletion is best-effort: individual failures are logged but do not throw.
*/
export type ConversationEntry = {
host: string
token: string
appId: string
conversationId: string
}
const _conversations: ConversationEntry[] = []
/**
* Register a conversation for cleanup in teardown.
* Call this whenever `run app` returns a `conversation_id`.
*/
export function registerConversation(
host: string,
token: string,
appId: string,
conversationId: string,
): void {
if (!conversationId || !appId)
return
_conversations.push({ host, token, appId, conversationId })
}
/**
* Return all registered conversations (for use in teardown).
*/
export function getRegisteredConversations(): readonly ConversationEntry[] {
return _conversations
}
/**
* Delete all registered conversations from the staging server.
* Called once from global-teardown.ts.
*/
export async function cleanupRegisteredConversations(): Promise<void> {
if (_conversations.length === 0)
return
console.log(`[E2E teardown] Cleaning up ${_conversations.length} staged conversation(s)…`)
const results = await Promise.allSettled(
_conversations.map(({ host, token, appId, conversationId }) =>
deleteConversation(host, token, appId, conversationId),
),
)
const failed = results.filter(r => r.status === 'rejected')
if (failed.length > 0) {
console.warn(
`[E2E teardown] ${failed.length} conversation deletion(s) failed (non-blocking):`,
failed.map(r => (r as PromiseRejectedResult).reason).join(', '),
)
}
else {
console.log(`[E2E teardown] All conversations cleaned up.`)
}
_conversations.length = 0
}
async function deleteConversation(
host: string,
token: string,
appId: string,
conversationId: string,
): Promise<void> {
const url = `${host.replace(/\/$/, '')}/openapi/v1/apps/${appId}/conversations/${conversationId}`
const res = await fetch(url, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(8_000),
})
// 404 is acceptable — conversation may have already been cleaned up
if (!res.ok && res.status !== 404) {
throw new Error(`DELETE ${url} → HTTP ${res.status}`)
}
}

373
cli/test/e2e/helpers/cli.ts Normal file
View File

@ -0,0 +1,373 @@
/**
* E2E CLI runner helpers.
*
* Core primitive: run(argv, opts) → { stdout, stderr, exitCode }
*
* The binary is invoked via `bun bin/dev.js` so tests work without a prior
* `pnpm build`. Each test should use its own isolated configDir (created via
* withTempConfig) to prevent session state leaking between tests.
*/
import { Buffer } from 'node:buffer'
import { execSync, spawn } from 'node:child_process'
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
/** Path to the dev entry point — no build required. */
export const BIN = resolve(__dirname, '../../../bin/dev.js')
/**
* Resolve the `bun` executable path.
* Priority: PATH → ~/.bun/bin/bun → /usr/local/bin/bun
*/
function resolveBun(): string {
const candidates = [
// Respect PATH first
'bun',
// Common install locations
`${process.env.HOME}/.bun/bin/bun`,
'/usr/local/bin/bun',
'/opt/homebrew/bin/bun',
]
for (const candidate of candidates) {
try {
execSync(`${candidate} --version`, { stdio: 'ignore', timeout: 3000 })
return candidate
}
catch { /* try next */ }
}
throw new Error(
'bun not found. Install it with: curl -fsSL https://bun.sh/install | bash',
)
}
export const BUN = resolveBun()
// ── Types ─────────────────────────────────────────────────────────────────
export type RunOptions = {
/**
* Override or extend the process environment.
* Values are merged on top of `process.env`.
*/
env?: Record<string, string>
/**
* Path to an isolated config directory.
* The CLI reads hosts.yml from this directory.
* Passed as DIFY_CONFIG_DIR env var.
*/
configDir?: string
/** Maximum time to wait for the process, in ms. Default: 30 000 */
timeout?: number
/** String to write to stdin, then close the pipe. */
stdin?: string
}
export type RunResult = {
stdout: string
stderr: string
exitCode: number
}
// ── Core runner ────────────────────────────────────────────────────────────
/**
* Execute `difyctl <argv>` and return the captured stdout, stderr and exit code.
*
* Environment notes:
* - CI=1 suppresses interactive prompts and spinners.
* - NO_COLOR=1 strips ANSI escape codes from output.
* - DIFY_CONFIG_DIR is set to opts.configDir when provided.
*/
export function run(argv: string[], opts: RunOptions = {}): Promise<RunResult> {
return new Promise((resolve, reject) => {
const env: Record<string, string> = {
...(process.env as Record<string, string>),
// Suppress interactive prompts in all E2E tests.
CI: '1',
NO_COLOR: '1',
// Point the CLI at the isolated config directory.
...(opts.configDir !== undefined ? { DIFY_CONFIG_DIR: opts.configDir } : {}),
...opts.env,
}
const proc = spawn(BUN, [BIN, ...argv], { env })
const timeoutMs = opts.timeout ?? 30_000
let timedOut = false
const timeoutId = setTimeout(() => {
timedOut = true
proc.kill('SIGINT')
setTimeout(() => proc.kill('SIGKILL'), 2000).unref?.()
}, timeoutMs)
timeoutId.unref?.()
let stdout = ''
let stderr = ''
proc.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
})
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
if (opts.stdin !== undefined) {
proc.stdin.write(opts.stdin)
proc.stdin.end()
}
proc.on('close', (code: number | null) => {
clearTimeout(timeoutId)
resolve({ stdout, stderr, exitCode: code ?? (timedOut ? 124 : 1) })
})
proc.on('error', (err: Error) => {
clearTimeout(timeoutId)
reject(new Error(`Failed to spawn CLI process: ${err.message}`))
})
})
}
// ── Config directory helpers ───────────────────────────────────────────────
export type TempConfig = {
/** Path to the isolated config directory. */
configDir: string
/** Remove the directory and all its contents. */
cleanup: () => Promise<void>
}
/**
* Create a fresh temporary config directory for a single test.
* Always call cleanup() in afterEach to avoid leaking temp directories.
*/
export async function withTempConfig(): Promise<TempConfig> {
const configDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-'))
return {
configDir,
cleanup: () => rm(configDir, { recursive: true, force: true }),
}
}
// ── Auth injection ─────────────────────────────────────────────────────────
export type AuthInjectionOptions = {
/** Staging server base URL (no trailing slash). */
host: string
/** Bearer token — dfoa_ for internal, dfoe_ for SSO. */
bearer: string
/** Primary workspace to write into the bundle. */
workspaceId: string
workspaceName: string
workspaceRole?: string
/**
* Server-side session UUID (OAuthAccessToken.id).
* When provided, written as `token_id` in hosts.yml so that
* `devices revoke` can correctly detect selfHit and clear local credentials.
*/
tokenId?: string
}
/**
* Write a pre-baked hosts.yml into configDir so tests can skip the real
* Device-Flow login. Auth-specific E2E tests (login/logout/status) use the
* real flow and should NOT call this function.
*/
export async function injectAuth(configDir: string, opts: AuthInjectionOptions): Promise<void> {
await mkdir(configDir, { recursive: true, mode: 0o700 })
const role = opts.workspaceRole ?? 'owner'
// Serialise to YAML manually to avoid a runtime dep on js-yaml in helpers.
const hostsYml = `${[
`current_host: ${opts.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${opts.bearer}`,
...(opts.tokenId !== undefined ? [`token_id: ${opts.tokenId}`] : []),
`workspace:`,
` id: ${opts.workspaceId}`,
` name: "${opts.workspaceName}"`,
` role: ${role}`,
`available_workspaces:`,
` - id: ${opts.workspaceId}`,
` name: "${opts.workspaceName}"`,
` role: ${role}`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
// ── Process signal helpers ─────────────────────────────────────────────────
export type SpawnedProcess = {
/** Send SIGINT (Ctrl+C) to the process. */
interrupt: () => void
/** Wait for the process to exit and return the result. */
wait: () => Promise<RunResult>
}
/**
* Start `difyctl <argv>` in the background without waiting for it to finish.
* Useful for testing interrupt / timeout behaviour.
*/
export function spawn_background(argv: string[], opts: RunOptions = {}): SpawnedProcess {
const env: Record<string, string> = {
...(process.env as Record<string, string>),
CI: '1',
NO_COLOR: '1',
...(opts.configDir !== undefined ? { DIFY_CONFIG_DIR: opts.configDir } : {}),
...opts.env,
}
const proc = spawn(BUN, [BIN, ...argv], { env })
const timeoutMs = opts.timeout ?? 30_000
let timedOut = false
const timeoutId = setTimeout(() => {
timedOut = true
proc.kill('SIGINT')
setTimeout(() => proc.kill('SIGKILL'), 2000).unref?.()
}, timeoutMs)
timeoutId.unref?.()
let stdout = ''
let stderr = ''
proc.stdout.on('data', (chunk: Buffer) => {
stdout += chunk.toString('utf8')
})
proc.stderr.on('data', (chunk: Buffer) => {
stderr += chunk.toString('utf8')
})
return {
interrupt: () => { proc.kill('SIGINT') },
wait: () => new Promise((res) => {
proc.on('close', (code: number | null) => {
clearTimeout(timeoutId)
res({ stdout, stderr, exitCode: code ?? (timedOut ? 124 : 1) })
})
}),
}
}
// ── Auth fixture ───────────────────────────────────────────────────────────
export type AuthFixture = {
/** Path to the isolated config directory, pre-loaded with a valid session. */
configDir: string
/**
* Run `difyctl <argv>` using the fixture's config dir.
* Shorthand for `run(argv, { configDir, env })`.
*/
r: (argv: string[], extraEnv?: Record<string, string>) => Promise<RunResult>
/** Remove the temp config directory. Call in afterEach. */
cleanup: () => Promise<void>
}
/**
* Create an isolated config directory pre-loaded with a valid internal-user
* session. Designed for use with vitest's beforeEach / afterEach:
*
* @example
* let fx: AuthFixture
* beforeEach(async () => { fx = await withAuthFixture(E) })
* afterEach(async () => { await fx.cleanup() })
*
* it('...', async () => {
* const result = await fx.r(['get', 'app'])
* assertExitCode(result, 0)
* })
*/
export async function withAuthFixture(
E: { host: string, token: string, workspaceId: string, workspaceName: string },
): Promise<AuthFixture> {
const { configDir, cleanup } = await withTempConfig()
await injectAuth(configDir, {
host: E.host,
bearer: E.token,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
return {
configDir,
r: (argv, extraEnv) => run(argv, { configDir, env: extraEnv }),
cleanup,
}
}
// ── On-demand disposable token ─────────────────────────────────────────────
/**
* Mint a fresh dfoa_ OAuth token on demand via the 3-step device flow API.
* Use this inside tests that need to revoke a real session without consuming
* the shared DIFY_E2E_TOKEN or the global-setup disposableToken.
*
* Requires DIFY_E2E_EMAIL and DIFY_E2E_PASSWORD to be set.
* Returns empty string if credentials are missing.
*
* Steps:
* 1. POST /console/api/login (Base64 password) → session cookie
* 2. POST /openapi/v1/oauth/device/code → device_code + user_code
* 3. POST /openapi/v1/oauth/device/approve → approved
* 4. POST /openapi/v1/oauth/device/token → dfoa_ token
*/
export async function mintFreshToken(
host: string,
email: string,
password: string,
): Promise<string> {
if (!email || !password)
return ''
const base = host.replace(/\/$/, '')
const sig = AbortSignal.timeout(15_000)
// Step 1 — console login
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
const loginRes = await fetch(`${base}/console/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
signal: AbortSignal.timeout(10_000),
})
if (!loginRes.ok)
return ''
const setCookieHeaders = loginRes.headers.getSetCookie?.() ?? []
const cookieString = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
const csrfMatch = cookieString.match(/csrf_token=([^;]+)/)
const csrfToken = csrfMatch ? csrfMatch[1] : ''
// Step 2 — device code
const codeRes = await fetch(`${base}/openapi/v1/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'difyctl', device_label: 'e2e-fresh' }),
signal: sig,
})
if (!codeRes.ok)
return ''
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
// Step 3 — approve
const approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRFToken': csrfToken },
body: JSON.stringify({ user_code }),
signal: AbortSignal.timeout(10_000),
})
if (!approveRes.ok)
return ''
// Step 4 — poll token
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
signal: AbortSignal.timeout(10_000),
})
if (!tokenRes.ok)
return ''
const body = await tokenRes.json() as { token?: string }
return body.token ?? ''
}

View File

@ -0,0 +1,51 @@
/**
* Retry helper for E2E tests running against a staging server.
*
* Staging environments can be flaky — occasional 5xx errors or slow cold
* starts are expected. Use `withRetry` to wrap assertions that may fail
* transiently without masking real failures.
*/
const DEFAULT_ATTEMPTS = 3
const DEFAULT_DELAY_MS = 1000
export type RetryOptions = {
/** Total number of attempts (first try + retries). Default: 3 */
attempts?: number
/** Delay between retries in ms. Default: 1000 */
delayMs?: number
/** Optional predicate — only retry when this returns true for the error. */
shouldRetry?: (err: unknown) => boolean
}
/**
* Execute `fn()` and retry on failure.
*
* @example
* const result = await withRetry(() => run(['get', 'app', '-o', 'json']))
*/
export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
const total = opts.attempts ?? DEFAULT_ATTEMPTS
const delay = opts.delayMs ?? DEFAULT_DELAY_MS
const shouldRetry = opts.shouldRetry ?? (() => true)
let lastErr: unknown
for (let attempt = 1; attempt <= total; attempt++) {
try {
return await fn()
}
catch (err) {
lastErr = err
if (attempt === total || !shouldRetry(err))
break
console.warn(`[E2E retry] attempt ${attempt}/${total} failed — retrying in ${delay}ms`)
await sleep(delay)
}
}
throw lastErr
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}

View File

@ -0,0 +1,9 @@
import { describe, it } from 'vitest'
export function optionalDescribe(condition: boolean) {
return condition ? describe : describe.skip
}
export function optionalIt(condition: boolean) {
return condition ? it : it.skip
}

View File

@ -0,0 +1,9 @@
import type { E2ECapabilities } from '../setup/env.js'
declare module 'vitest' {
export type ProvidedContext = {
e2eCapabilities: E2ECapabilities
}
}
export { }

109
cli/test/e2e/setup/env.ts Normal file
View File

@ -0,0 +1,109 @@
/**
* E2E environment configuration.
*
* All DIFY_E2E_* variables must be set before running E2E tests.
* In CI they are injected from GitHub Actions secrets.
* Locally, export them in your shell or use a .env.e2e file.
*
* Required:
* DIFY_E2E_HOST Staging server base URL (e.g. https://api.staging.dify.ai)
* DIFY_E2E_TOKEN Internal user bearer token (dfoa_ prefix)
* DIFY_E2E_WORKSPACE_ID Workspace ID for the test account
* DIFY_E2E_CHAT_APP_ID Echo-chat app — outputs "echo: {query}"
* DIFY_E2E_WORKFLOW_APP_ID Echo-workflow app — input x (required), outputs "echo: {x}"
*
* Optional (skip related tests when absent):
* DIFY_E2E_SSO_TOKEN External SSO bearer token (dfoe_ prefix)
* DIFY_E2E_HITL_APP_ID Workflow app with a Human-Input node
* DIFY_E2E_FILE_APP_ID Workflow app with a file input variable (doc)
*/
export type E2EEnv = {
/** Staging server base URL */
host: string
/** Internal user bearer token (dfoa_…) */
token: string
/** External SSO bearer token (dfoe_…) — may be empty */
ssoToken: string
/** Primary workspace ID */
workspaceId: string
/** Workspace name (informational) */
workspaceName: string
/** Chat app that echoes the query */
chatAppId: string
/** Workflow app that echoes input x */
workflowAppId: string
/** Workflow app with HITL node — empty when not configured */
hitlAppId: string
/** Workflow app with file input (doc variable) — empty when not configured */
fileAppId: string
/**
* Console account email — used by global-setup to mint a disposable token
* for logout tests via the device flow API. Optional: if absent, logout
* tests that need a real revoke are skipped.
*/
email: string
/** Console account password (plain-text; Base64-encoded before sending) */
password: string
}
export type E2ECapabilities = {
tokenValid: boolean
tokenId?: string
/**
* Per-suite dedicated tokens minted by global-setup via the device flow.
* Each destructive suite (logout, devices) gets its own fresh dfoa_ token so
* that revoking it never invalidates DIFY_E2E_TOKEN used by other suites.
* Empty string when DIFY_E2E_EMAIL/PASSWORD are not configured.
*/
logoutToken: string
devicesToken: string
}
let _cached: E2EEnv | undefined
/** Load and validate E2E environment variables. Throws if required vars are missing. */
export function loadE2EEnv(): E2EEnv {
if (_cached !== undefined)
return _cached
const required: Array<[keyof NodeJS.ProcessEnv, string]> = [
['DIFY_E2E_HOST', 'Staging server URL'],
['DIFY_E2E_TOKEN', 'Internal user bearer token'],
['DIFY_E2E_WORKSPACE_ID', 'Workspace ID'],
['DIFY_E2E_CHAT_APP_ID', 'Echo-chat app ID'],
['DIFY_E2E_WORKFLOW_APP_ID', 'Echo-workflow app ID'],
]
const missing = required.filter(([k]) => !process.env[k])
if (missing.length > 0) {
const list = missing.map(([k, desc]) => ` ${k} (${desc})`).join('\n')
throw new Error(
`E2E tests require the following environment variables to be set:\n${list}\n\n`
+ 'See test/e2e/setup/env.ts for documentation.',
)
}
_cached = {
host: process.env.DIFY_E2E_HOST!,
token: process.env.DIFY_E2E_TOKEN!,
ssoToken: process.env.DIFY_E2E_SSO_TOKEN ?? '',
workspaceId: process.env.DIFY_E2E_WORKSPACE_ID!,
workspaceName: process.env.DIFY_E2E_WORKSPACE_NAME ?? 'E2E Workspace',
chatAppId: process.env.DIFY_E2E_CHAT_APP_ID!,
workflowAppId: process.env.DIFY_E2E_WORKFLOW_APP_ID!,
hitlAppId: process.env.DIFY_E2E_HITL_APP_ID ?? '',
fileAppId: process.env.DIFY_E2E_FILE_APP_ID ?? '',
email: process.env.DIFY_E2E_EMAIL ?? '',
password: process.env.DIFY_E2E_PASSWORD ?? '',
}
return _cached
}
/**
* Skip a test when an optional app fixture is not configured.
* Usage: skipUnless(E.hitlAppId, 'DIFY_E2E_HITL_APP_ID')
*/
export function isE2ELocalMode(): boolean {
return process.env.DIFY_E2E_MODE === 'local'
}

View File

@ -0,0 +1,169 @@
/**
* Vitest global setup — runs once before all E2E suites.
*
* Responsibilities:
* 1. Validate required environment variables are present.
* 2. Confirm the staging server is reachable AND the shared token is valid —
* GET /openapi/v1/account/sessions (HTTP 200 = valid, else abort).
* 3. Resolve the current session's token_id via the prefix field.
* 4. Mint per-suite dedicated tokens for suites that revoke sessions:
* - logoutToken → auth/logout.e2e.ts
* - devicesToken → auth/devices.e2e.ts
* Each suite consumes only its own token, so DIFY_E2E_TOKEN remains
* valid for all non-destructive suites throughout the run.
*
* If the health-check fails the entire test run is aborted early.
*/
import type { TestProject } from 'vitest/node'
import type { E2ECapabilities } from './env.js'
import { Buffer } from 'node:buffer'
import { loadE2EEnv } from './env.js'
export async function setup(project: TestProject): Promise<void> {
if (process.env.DIFY_E2E_MODE === 'local')
return
const E = loadE2EEnv()
const base = E.host.replace(/\/$/, '')
// ── 1. Validate main token ─────────────────────────────────────────────
const sessionsUrl = `${base}/openapi/v1/account/sessions?page=1&limit=100`
let res: Response
try {
res = await fetch(sessionsUrl, {
headers: { Authorization: `Bearer ${E.token}` },
signal: AbortSignal.timeout(10_000),
})
}
catch (err) {
throw new Error(
`[E2E global-setup] Cannot reach staging server at ${sessionsUrl}.\n`
+ `Check DIFY_E2E_HOST and network connectivity.\n${String(err)}`,
)
}
if (!res.ok) {
throw new Error(
`[E2E global-setup] Token is invalid or expired (HTTP ${res.status}).\n`
+ `Update DIFY_E2E_TOKEN and retry.\nURL: ${sessionsUrl}`,
)
}
console.log(`[E2E] Staging server is healthy and token is valid at ${E.host}`)
// ── 2. Resolve token_id ────────────────────────────────────────────────
const body = await res.json() as { data: Array<{ id: string, prefix: string }> }
const match = body.data.find(s => s.prefix !== '' && E.token.startsWith(s.prefix))
// ── 3. Mint per-suite dedicated tokens ────────────────────────────────
let logoutToken = ''
let devicesToken = ''
if (E.email && E.password) {
const mint = (label: string) => mintToken(base, E.email, E.password, label)
const [lt, dt] = await Promise.allSettled([
mint('e2e-logout-suite'),
mint('e2e-devices-suite'),
])
if (lt.status === 'fulfilled') {
logoutToken = lt.value
console.log(`[E2E] logoutToken minted: ${logoutToken.slice(0, 20)}`)
}
else {
console.warn(`[E2E global-setup] Failed to mint logoutToken: ${lt.reason}`)
}
if (dt.status === 'fulfilled') {
devicesToken = dt.value
console.log(`[E2E] devicesToken minted: ${devicesToken.slice(0, 20)}`)
}
else {
console.warn(`[E2E global-setup] Failed to mint devicesToken: ${dt.reason}`)
}
}
else {
console.warn('[E2E global-setup] DIFY_E2E_EMAIL/PASSWORD not set — per-suite tokens not minted; destructive tests may skip')
}
const capabilities: E2ECapabilities = {
tokenValid: true,
tokenId: match?.id,
logoutToken,
devicesToken,
}
project.provide('e2eCapabilities', capabilities)
}
export { teardown } from './global-teardown.js'
// ── Device flow token minting ──────────────────────────────────────────────
/**
* Mint a fresh dfoa_ OAuth token via the 3-step device flow:
* 1. POST /openapi/v1/oauth/device/code → device_code + user_code
* 2. POST /console/api/login → session cookie + CSRF
* POST /openapi/v1/oauth/device/approve (with cookie)
* 3. POST /openapi/v1/oauth/device/token → dfoa_ bearer token
*/
async function mintToken(base: string, email: string, password: string, label: string): Promise<string> {
// Step 1 — request device code
const codeRes = await fetch(`${base}/openapi/v1/oauth/device/code`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ client_id: 'difyctl', device_label: label }),
signal: AbortSignal.timeout(15_000),
})
if (!codeRes.ok)
throw new Error(`device/code failed: HTTP ${codeRes.status}`)
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
// Step 2a — console login → session cookie + CSRF token
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
const loginRes = await fetch(`${base}/console/api/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
signal: AbortSignal.timeout(10_000),
})
if (!loginRes.ok)
throw new Error(`console/api/login failed: HTTP ${loginRes.status}`)
const setCookieHeaders = loginRes.headers.getSetCookie?.() ?? []
const cookieString = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
const csrfMatch = cookieString.match(/csrf_token=([^;]+)/)
const csrfToken = csrfMatch ? csrfMatch[1] : ''
// Step 2b — approve the device code
const approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': cookieString,
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({ user_code }),
signal: AbortSignal.timeout(10_000),
})
if (!approveRes.ok)
throw new Error(`device/approve failed: HTTP ${approveRes.status}`)
// Step 3 — exchange device code for bearer token
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
signal: AbortSignal.timeout(10_000),
})
if (!tokenRes.ok)
throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
const tokenBody = await tokenRes.json() as { token?: string, error?: string }
if (!tokenBody.token)
throw new Error(`device/token response missing token: ${JSON.stringify(tokenBody)}`)
return tokenBody.token
}

View File

@ -0,0 +1,15 @@
/**
* Vitest global teardown — runs once after all E2E suites complete.
*
* Responsibilities:
* 1. Delete all conversations created on the staging server during the run
* (collected via registerConversation() in test suites).
*
* Deletion is best-effort — failures are logged but do not fail the run.
*/
import { cleanupRegisteredConversations } from '../helpers/cleanup-registry.js'
export async function teardown(): Promise<void> {
await cleanupRegisteredConversations()
}

View File

@ -0,0 +1,135 @@
/**
* E2E: difyctl auth devices — multi-device session management
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Multi-device Session Management (21 cases)
*/
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { assertExitCode, assertJson } from '../../helpers/assert.js'
import { injectAuth, mintFreshToken, run, withTempConfig } from '../../helpers/cli.js'
import { optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const caps = inject('e2eCapabilities')
const tokenValid = caps.tokenValid
const tokenId = caps.tokenId
describe('E2E / difyctl auth devices', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
await injectAuth(configDir, {
host: E.host,
bearer: E.token,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
tokenId,
})
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
// ── devices list ─────────────────────────────────────────────────────────────
const itSessions = optionalIt(tokenValid)
itSessions('[P0] logged-in user can view the devices list', async () => {
// Spec: logged-in user can view the devices list
const result = await r(['auth', 'devices', 'list'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
itSessions('[P0] devices list displays device IDs', async () => {
// Spec: devices list displays device IDs
const result = await r(['auth', 'devices', 'list'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/tok-|id|device/i)
})
itSessions('[P0] devices list supports JSON output and returns valid JSON', async () => {
// Spec: devices list supports JSON output
const result = await r(['auth', 'devices', 'list', '--json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[], total: number }>(result)
expect(parsed).toHaveProperty('data')
expect(Array.isArray(parsed.data)).toBe(true)
})
itSessions('[P1] devices list JSON schema is stable (contains data and total fields)', async () => {
// Spec: devices list JSON schema is stable
const result = await r(['auth', 'devices', 'list', '--json'])
assertExitCode(result, 0)
const parsed = assertJson<{ data: unknown[], total: number, page: number, limit: number }>(result)
expect(parsed).toHaveProperty('total')
expect(parsed).toHaveProperty('page')
expect(parsed).toHaveProperty('limit')
})
it('[P0] unauthenticated devices list returns auth error (exit code 4)', async () => {
// Spec: unauthenticated devices list returns auth error + exit code 4
const unauthTmp = await withTempConfig()
try {
const result = await run(['auth', 'devices', 'list'], { configDir: unauthTmp.configDir })
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
}
finally {
await unauthTmp.cleanup()
}
})
// ── devices revoke ───────────────────────────────────────────────────────────
itSessions('[P0] revoking a specified device succeeds (exit code 0)', async () => {
// Spec: revoking a specified device succeeds
// Mint a fresh token on demand so this test only revokes its own session,
// never the shared E.token or the global-setup disposableToken.
const freshToken = await mintFreshToken(E.host, E.email, E.password)
if (!freshToken) {
// Credentials not configured — skip rather than risk revoking the main session.
return
}
// Inject the fresh token into a dedicated config dir
const revokeTmp = await withTempConfig()
try {
await injectAuth(revokeTmp.configDir, {
host: E.host,
bearer: freshToken,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir })
// List sessions authenticated as the fresh token
const listResult = await revokeR(['auth', 'devices', 'list', '--json'])
assertExitCode(listResult, 0)
const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult)
// Find the entry whose prefix matches the fresh token
const entry = data.find(d => d.prefix && freshToken.startsWith(d.prefix))
if (!entry) {
// Fresh session not found — may have been filtered; skip gracefully.
return
}
const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes'])
assertExitCode(revokeResult, 0)
}
finally {
await revokeTmp.cleanup()
}
})
})

View File

@ -0,0 +1,183 @@
/**
* E2E: difyctl auth logout — Logout
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Logout (18 cases)
*/
import { access } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
import { assertExitCode } from '../../helpers/assert.js'
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const caps = inject('e2eCapabilities')
describe('E2E / difyctl auth logout', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const { configDir: dir, cleanup: cl } = await withTempConfig()
configDir = dir
cleanup = cl
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
/**
* Inject the dedicated per-suite logoutToken so that auth logout
* calls DELETE /account/sessions/self on a disposable session and
* never revokes the shared DIFY_E2E_TOKEN used by other suites.
*/
async function withAuth() {
const token = caps.logoutToken || E.token
await injectAuth(configDir, {
host: E.host,
bearer: token,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
}
async function hostsFileExists(): Promise<boolean> {
try {
await access(join(configDir, 'hosts.yml'))
return true
}
catch { return false }
}
// ── Basic logout ────────────────────────────────────────────────────────────
it('[P0] logged-in user can logout successfully — stdout contains success message', async () => {
// Spec: logged-in user can logout successfully
await withAuth()
const result = await r(['auth', 'logout'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/logged out/i)
})
it('[P0] local hosts.yml is deleted after logout', async () => {
// Spec: local token deleted after logout
await withAuth()
expect(await hostsFileExists()).toBe(true)
await r(['auth', 'logout'])
expect(await hostsFileExists()).toBe(false)
})
it('[P0] auth status returns "Not logged in" after logout', async () => {
// Spec: auth status returns not-logged-in after logout
await withAuth()
await r(['auth', 'logout'])
const statusResult = await r(['auth', 'status'])
expect(statusResult.exitCode).toBe(4)
expect(statusResult.stdout).toMatch(/not logged in/i)
})
it('[P1] auth status exit code is 4 after logout', async () => {
// Spec: auth status exit code is 4 after logout
await withAuth()
await r(['auth', 'logout'])
const statusResult = await r(['auth', 'status'])
expect(statusResult.exitCode).toBe(4)
})
it('[P0] logout calls the revoke session endpoint (or best-effort local credential clear)', async () => {
// Spec: logout calls the revoke session endpoint + logout returns success when revoke succeeds
// Uses disposableToken so the shared DIFY_E2E_TOKEN is not revoked.
await withAuth()
const result = await r(['auth', 'logout'])
// Local token must be cleared regardless of whether server revoke succeeds
assertExitCode(result, 0)
expect(await hostsFileExists()).toBe(false)
})
it('[P0] local credentials are cleared even when server revoke fails (best-effort)', async () => {
// Spec: local credentials cleared even when server revoke fails
// Inject an invalid token → server rejects revoke, but local state must still be cleared
await injectAuth(configDir, {
host: E.host,
bearer: 'dfoa_invalid_will_fail_revoke',
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
const result = await r(['auth', 'logout'])
// exit 0 (best-effort); local file is cleared
assertExitCode(result, 0)
expect(await hostsFileExists()).toBe(false)
})
// ── Unauthenticated (idempotent) ─────────────────────────────────────────────
it('[P0] logout without a session returns not_logged_in error (exit code 4)', async () => {
// Spec: logout without a session is idempotent
// Actual behaviour: CLI returns not_logged_in (exit 4) when no token is present
const result = await r(['auth', 'logout'])
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in/i)
})
// ── External SSO logout ─────────────────────────────────────────────────────
it('[P0] external SSO user logout works correctly — local token cleared', async () => {
// Spec: external SSO user logout works correctly
const { writeFile } = await import('node:fs/promises')
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await r(['auth', 'logout'])
assertExitCode(result, 0)
expect(await hostsFileExists()).toBe(false)
})
// ── Network error scenario ───────────────────────────────────────────────────
it('[P0] local token is cleared even when logout encounters a network error', async () => {
// Spec: local credentials cleared even when network is unavailable
// Use an unreachable host to simulate network failure
const { writeFile, mkdir } = await import('node:fs/promises')
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: http://unreachable-host-xyz.invalid`,
`token_storage: file`,
`tokens:`,
` bearer: dfoa_test_network_error`,
`workspace:`,
` id: ws-1`,
` name: Test`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await run(['auth', 'logout'], { configDir, timeout: 10_000 })
// Local token is cleared even if network request fails
assertExitCode(result, 0)
expect(await hostsFileExists()).toBe(false)
})
// ── Post-logout operations ───────────────────────────────────────────────────
it('[P1] run app returns auth error (exit code 4) after logout', async () => {
// Spec: run app returns auth error after logout
// Use disposableToken so the shared DIFY_E2E_TOKEN is not revoked.
await withAuth()
await r(['auth', 'logout'])
const result = await r(['run', 'app', E.chatAppId, 'test'])
expect(result.exitCode).toBe(4)
})
})

View File

@ -0,0 +1,180 @@
/**
* E2E: difyctl auth status — Auth Status
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Auth Status (12 cases)
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
describe('E2E / difyctl auth status', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const { configDir: dir, cleanup: cl } = await withTempConfig()
configDir = dir
cleanup = cl
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[], extraEnv?: Record<string, string>) {
return run(argv, { configDir, env: extraEnv })
}
async function withAuth() {
// Write a complete bundle including account fields so --json output includes account
const { writeFile, mkdir } = await import('node:fs/promises')
const { join } = await import('node:path')
await mkdir(configDir, { recursive: true, mode: 0o700 })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.token}`,
`account:`,
` id: acct-e2e`,
` email: e2e@example.com`,
` name: E2E User`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
async function withSSOAuth() {
await injectAuth(configDir, {
host: E.host,
bearer: E.ssoToken || 'dfoe_test',
workspaceId: '',
workspaceName: '',
})
// Overwrite to add external_subject field
const { writeFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.ssoToken || 'dfoe_test'}`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
// ── Basic status display ─────────────────────────────────────────────────────
it('[P0] internal user auth status displays host, email, and workspace info', async () => {
// Spec: internal user auth status displays host information
await withAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toContain(E.host.replace(/^https?:\/\//, ''))
expect(result.stdout).toContain(E.workspaceName)
})
it('[P0] auth status --json outputs a valid JSON schema', async () => {
// Spec: auth status --json output is a parseable schema
await withAuth()
const result = await r(['auth', 'status', '--json'])
assertExitCode(result, 0)
const parsed = JSON.parse(result.stdout) as Record<string, unknown>
expect(parsed).toHaveProperty('logged_in', true)
expect(parsed).toHaveProperty('host')
expect(parsed).toHaveProperty('account')
})
it('[P1] auth status -v displays workspace role and storage info', async () => {
// Spec: auth status -v displays workspace role
await withAuth()
const result = await r(['auth', 'status', '-v'])
assertExitCode(result, 0)
expect(result.stdout).toContain('owner')
expect(result.stdout).toMatch(/file|keychain/)
})
// ── Unauthenticated scenario ─────────────────────────────────────────────────
it('[P0] unauthenticated auth status returns "Not logged in" — exit code 4', async () => {
// Spec: unauthenticated auth status returns error + exit code 4
// configDir is empty (no hosts.yml)
const result = await r(['auth', 'status'])
assertExitCode(result, 4)
expect(result.stdout).toMatch(/not logged in/i)
})
// ── External SSO user ────────────────────────────────────────────────────────
it('[P0] external SSO user auth status does not display workspace row', async () => {
// Spec: external SSO user auth status does not show workspace
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).not.toMatch(/workspace/i)
})
it('[P0] external SSO user auth status displays issuer URL', async () => {
// Spec: external SSO user auth status displays issuer URL
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toContain('issuer.example.com')
})
it('[P0] external SSO user auth status displays External SSO session info', async () => {
// Spec: external SSO user auth status displays External SSO Session info
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/SSO|apps:run/i)
})
// ── Error scenarios ──────────────────────────────────────────────────────────
it('[P0] auth status returns auth error when token is expired (401)', async () => {
// Spec: auth status returns auth error after token expires
// Inject a syntactically valid but actually expired token
await injectAuth(configDir, {
host: E.host,
bearer: 'dfoa_invalid_expired_token_xyz',
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
// auth status reads only the local hosts.yml (no network); status is shown as long as a token exists.
// Real token-expiry detection happens when commands like get app / run app are executed.
const result = await r(['auth', 'status'])
// A token present → show status without a 401 (status makes no network request)
assertExitCode(result, 0)
})
it('[P1] auth status outputs JSON error envelope in JSON mode', async () => {
// Spec: auth status outputs JSON error in JSON mode
const result = await r(['auth', 'status', '--json'])
// When not logged in, --json mode should output JSON rather than plain text
expect(result.exitCode).toBe(4)
// stdout should contain JSON (not-logged-in state)
const parsed = JSON.parse(result.stdout) as { logged_in: boolean }
expect(parsed.logged_in).toBe(false)
})
it('[P0] auth status output contains no ANSI colour (non-TTY)', async () => {
await withAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
})
})

View File

@ -0,0 +1,186 @@
/**
* E2E: difyctl auth use — Workspace switching
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Workspace Switching (22 cases)
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode } from '../../helpers/assert.js'
import { run, withTempConfig } from '../../helpers/cli.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
// Secondary workspace used in tests — injected into available_workspaces
const WS2_ID = 'ws-e2e-secondary-0000-000000000002'
const WS2_NAME = 'Secondary Workspace'
describe('E2E / difyctl auth use', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
/** Inject a bundle with two workspaces. */
async function withTwoWorkspaces() {
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.token}`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
` - id: ${WS2_ID}`,
` name: "${WS2_NAME}"`,
` role: normal`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
async function withSSOAuth() {
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
// ── Normal workspace switch ──────────────────────────────────────────────────
it('[P0] internal user can switch to a specified workspace', async () => {
// Spec: internal user can switch to a specified workspace
await withTwoWorkspaces()
const result = await r(['auth', 'use', WS2_ID])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/switched|workspace/i)
expect(result.stdout).toContain(WS2_NAME)
})
it('[P0] auth status shows the new workspace after auth use', async () => {
// Spec: auth status shows new workspace after auth use
await withTwoWorkspaces()
await r(['auth', 'use', WS2_ID])
const status = await r(['auth', 'status'])
assertExitCode(status, 0)
expect(status.stdout).toContain(WS2_NAME)
})
it('[P0] auth use updates current_workspace_id (hosts.yml is updated)', async () => {
// Spec: auth use updates current_workspace_id
await withTwoWorkspaces()
await r(['auth', 'use', WS2_ID])
const { readFile } = await import('node:fs/promises')
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsContent).toContain(WS2_ID)
})
it('[P1] switching to the same workspace repeatedly is idempotent', async () => {
// Spec: switching to the same workspace is idempotent
await withTwoWorkspaces()
const r1 = await r(['auth', 'use', E.workspaceId])
assertExitCode(r1, 0)
const r2 = await r(['auth', 'use', E.workspaceId])
assertExitCode(r2, 0)
})
it('[P1] current workspace is persisted after auth use', async () => {
// Spec: current workspace is persisted after auth use
await withTwoWorkspaces()
await r(['auth', 'use', WS2_ID])
// Read hosts.yml directly to verify the workspace id was written
const { readFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsContent).toContain(WS2_ID)
})
// ── Error scenarios ──────────────────────────────────────────────────────────
it('[P0] switching to a non-existent workspace returns an error', async () => {
// Spec: switching to a non-existent workspace returns an error
await withTwoWorkspaces()
const result = await r(['auth', 'use', 'ws-does-not-exist-xyz'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/not found|workspace/i)
})
it('[P0] current_workspace_id is unchanged when workspace switch fails', async () => {
// Spec: current_workspace_id is unchanged when workspace switch fails
await withTwoWorkspaces()
await r(['auth', 'use', 'ws-does-not-exist-xyz'])
// Read hosts.yml directly; the original workspace id should still be present
const { readFile } = await import('node:fs/promises')
const { join } = await import('node:path')
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
expect(hostsContent).toContain(E.workspaceId)
})
it('[P0] unauthenticated auth use returns auth error (exit code 4)', async () => {
// Spec: unauthenticated auth use returns auth error + exit code 4
const result = await r(['auth', 'use', E.workspaceId])
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
})
it('[P0] missing workspace argument returns a usage error', async () => {
// Spec: missing workspace argument returns a usage error
await withTwoWorkspaces()
const result = await r(['auth', 'use'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument|workspace/i)
})
// ── External SSO user ────────────────────────────────────────────────────────
it('[P0] external SSO user is rejected when executing auth use', async () => {
// Spec: external SSO user is rejected when executing auth use
await withSSOAuth()
const result = await r(['auth', 'use', 'any-ws-id'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/external SSO|workspace/i)
})
it('[P1] external SSO user auth use exit code is 1 or 2', async () => {
// Spec: external SSO user auth use exit code is 1
await withSSOAuth()
const result = await r(['auth', 'use', 'any-ws-id'])
expect([1, 2]).toContain(result.exitCode)
})
// ── JSON mode ────────────────────────────────────────────────────────────────
it('[P1] stderr contains an error description when workspace does not exist', async () => {
// Spec: non-existent workspace returns an error
// Note: auth use does not support the -o flag; errors are reported via stderr text
await withTwoWorkspaces()
const result = await r(['auth', 'use', 'ws-nonexistent-xyz'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/not.?found|workspace/i)
})
})

View File

@ -0,0 +1,175 @@
/**
* E2E: difyctl auth whoami + external SSO session behaviour
*
* Test cases sourced from: Dify CLI Enhanced spec
* - Dify CLI/Auth/External SSO Login (19 cases, testable subset)
*
* Note: interactive login (Device Flow browser) and Headless auth require a real browser;
* E2E layer bypasses Device Flow via injectAuth, focusing on session state and CLI behaviour.
*/
import { mkdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode } from '../../helpers/assert.js'
import { run, withTempConfig } from '../../helpers/cli.js'
import { optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
describe('E2E / difyctl auth whoami + SSO session', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[]) {
return run(argv, { configDir })
}
async function withInternalAuth() {
await mkdir(configDir, { recursive: true, mode: 0o700 })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.token}`,
`account:`,
` id: acct-e2e`,
` email: e2e-user@example.com`,
` name: E2E User`,
`workspace:`,
` id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
`available_workspaces:`,
` - id: ${E.workspaceId}`,
` name: "${E.workspaceName}"`,
` role: owner`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
async function withSSOAuth(issuer = 'https://idp.example.com') {
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: dfoe_sso_test_token`,
`external_subject:`,
` email: sso-user@example.com`,
` issuer: ${issuer}`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
}
// ── auth whoami — internal user ──────────────────────────────────────────────
it('[P0] internal user auth whoami outputs email', async () => {
await withInternalAuth()
const result = await r(['auth', 'whoami'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/@/)
})
it('[P0] auth whoami --json outputs valid JSON containing email', async () => {
await withInternalAuth()
const result = await r(['auth', 'whoami', '--json'])
assertExitCode(result, 0)
const parsed = JSON.parse(result.stdout) as { email: string }
expect(parsed).toHaveProperty('email')
expect(parsed.email).toMatch(/@/)
})
it('[P0] unauthenticated auth whoami returns auth error (exit code 4)', async () => {
const result = await r(['auth', 'whoami'])
assertExitCode(result, 4)
})
// ── External SSO user behaviour ──────────────────────────────────────────────
it('[P0] external SSO user auth status displays apps:run-only restriction', async () => {
// Spec: auth status displays apps:run-only restriction
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/apps:run|SSO/i)
})
it('[P0] external SSO user auth status does not display workspace info', async () => {
// Spec: auth status does not display workspace information
await withSSOAuth()
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
// SSO users have no workspace
expect(result.stdout).not.toMatch(/^ {2}Workspace:/m)
})
it('[P0] external SSO user auth status displays issuer URL', async () => {
// Spec: auth status displays External SSO Session + issuer URL
await withSSOAuth('https://idp.enterprise.com')
const result = await r(['auth', 'status'])
assertExitCode(result, 0)
expect(result.stdout).toContain('idp.enterprise.com')
})
it('[P0] external user gets an error executing auth use (external SSO subjects have no workspaces)', async () => {
// Spec: external user gets an error when executing auth use
await withSSOAuth()
const result = await r(['auth', 'use', 'any-ws-id'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/external SSO|workspace/i)
})
it('[P0] external user get workspace returns empty list or insufficient_scope', async () => {
// Spec: external user get workspace returns an empty list
await withSSOAuth()
const result = await r(['get', 'workspace'])
// SSO token has no workspace scope
expect(result.exitCode).not.toBe(0)
})
it('[P0] external user get app returns insufficient_scope error', async () => {
// Spec: external user get app returns insufficient_scope
await withSSOAuth()
const result = await r(['get', 'app'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i)
})
it('[P0] external user whoami outputs SSO email', async () => {
await withSSOAuth()
const result = await r(['auth', 'whoami'])
assertExitCode(result, 0)
expect(result.stdout).toContain('sso-user@example.com')
})
const itWithSso = optionalIt(Boolean(E.ssoToken))
itWithSso('[P0] external user can execute run app using SSO token', async () => {
await mkdir(configDir, { recursive: true })
const hostsYml = `${[
`current_host: ${E.host}`,
`token_storage: file`,
`tokens:`,
` bearer: ${E.ssoToken}`,
`external_subject:`,
` email: sso@example.com`,
` issuer: https://issuer.example.com`,
].join('\n')}\n`
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
const result = await r(['run', 'app', E.chatAppId, 'hello'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
})

View File

@ -0,0 +1,299 @@
/**
* E2E: difyctl config — configuration management
*
* Test cases sourced from: Dify CLI Enhanced spec
* - Dify CLI/Config/Initialization & Default Paths (26 cases, testable subset)
* - Dify CLI/Config/Environment Variable Override Priority (26 cases, testable subset)
*
* Covers sub-commands: config path / config get / config set / config unset / config view
* All cases run purely locally — no real Dify server required.
*/
import { access, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
import { run, withTempConfig } from '../../helpers/cli.js'
describe('E2E / difyctl config', () => {
let configDir: string
let cleanup: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanup = tmp.cleanup
})
afterEach(async () => {
await cleanup()
})
function r(argv: string[], extraEnv?: Record<string, string>) {
return run(argv, { configDir, env: extraEnv })
}
// ── config path ──────────────────────────────────────────────────────────────
it('[P0] config path returns the correct absolute path to config.yml', async () => {
// Spec: default config path is correct
const result = await r(['config', 'path'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe(join(configDir, 'config.yml'))
})
it('[P0] config path output ends with a newline', async () => {
const result = await r(['config', 'path'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/config\.yml\n$/)
})
// ── config set / get ─────────────────────────────────────────────────────────
it('[P0] config set defaults.format writes successfully — stdout contains key=value', async () => {
const result = await r(['config', 'set', 'defaults.format', 'json'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/defaults\.format/)
})
it('[P0] config get reads the previously written defaults.format', async () => {
await r(['config', 'set', 'defaults.format', 'json'])
const result = await r(['config', 'get', 'defaults.format'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('json')
})
it('[P0] config set defaults.limit writes and reads back correctly', async () => {
await r(['config', 'set', 'defaults.limit', '50'])
const result = await r(['config', 'get', 'defaults.limit'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('50')
})
it('[P0] config set state.current_app writes and reads back correctly', async () => {
const appId = 'app-e2e-config-test'
await r(['config', 'set', 'state.current_app', appId])
const result = await r(['config', 'get', 'state.current_app'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe(appId)
})
it('[P0] config get returns empty string for an unset key (exit 0)', async () => {
// Spec: missing config fields fall back to default values
const result = await r(['config', 'get', 'defaults.format'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('')
})
it('[P1] multiple config set calls for different keys each persist independently', async () => {
// Spec: existing config is not overwritten when setting other keys
await r(['config', 'set', 'defaults.format', 'yaml'])
await r(['config', 'set', 'defaults.limit', '30'])
const fmt = await r(['config', 'get', 'defaults.format'])
const lim = await r(['config', 'get', 'defaults.limit'])
expect(fmt.stdout.trim()).toBe('yaml')
expect(lim.stdout.trim()).toBe('30')
})
// ── config unset ─────────────────────────────────────────────────────────────
it('[P0] config unset clears a set key — get returns empty string', async () => {
await r(['config', 'set', 'defaults.format', 'json'])
await r(['config', 'unset', 'defaults.format'])
const result = await r(['config', 'get', 'defaults.format'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('')
})
it('[P1] config unset of an unset key is idempotent (exit 0)', async () => {
const result = await r(['config', 'unset', 'defaults.format'])
assertExitCode(result, 0)
})
it('[P1] other keys are unaffected after config unset', async () => {
await r(['config', 'set', 'defaults.format', 'table'])
await r(['config', 'set', 'defaults.limit', '10'])
await r(['config', 'unset', 'defaults.format'])
const lim = await r(['config', 'get', 'defaults.limit'])
expect(lim.stdout.trim()).toBe('10')
})
// ── config view ──────────────────────────────────────────────────────────────
it('[P0] config view outputs nothing for an empty configuration', async () => {
const result = await r(['config', 'view'])
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe('')
})
it('[P0] config view displays all set key = value pairs', async () => {
await r(['config', 'set', 'defaults.format', 'yaml'])
await r(['config', 'set', 'defaults.limit', '20'])
const result = await r(['config', 'view'])
assertExitCode(result, 0)
expect(result.stdout).toContain('defaults.format = yaml')
expect(result.stdout).toContain('defaults.limit = 20')
})
it('[P0] config view --json outputs valid JSON containing the set keys', async () => {
await r(['config', 'set', 'defaults.format', 'json'])
await r(['config', 'set', 'defaults.limit', '15'])
const result = await r(['config', 'view', '--json'])
assertExitCode(result, 0)
const parsed = JSON.parse(result.stdout) as Record<string, unknown>
expect(parsed).toHaveProperty('defaults.format', 'json')
expect(parsed).toHaveProperty('defaults.limit', 15)
})
it('[P1] config view --json outputs a valid empty JSON object for an empty config', async () => {
const result = await r(['config', 'view', '--json'])
assertExitCode(result, 0)
const parsed = JSON.parse(result.stdout)
expect(typeof parsed).toBe('object')
})
// ── Initialization and default paths ─────────────────────────────────────────
it('[P0] the first config set auto-creates the config directory and config.yml file', async () => {
// Spec: config directory and file are auto-created on first use
await r(['config', 'set', 'defaults.format', 'json'])
await expect(access(join(configDir, 'config.yml'))).resolves.toBeUndefined()
})
it('[P0] config.yml file permissions are 0o600', async () => {
// Spec: config file has correct default permissions
await r(['config', 'set', 'defaults.format', 'json'])
const info = await stat(join(configDir, 'config.yml'))
expect(info.mode & 0o777).toBe(0o600)
})
it('[P0] config.yml contains the correct schema_version field', async () => {
// Spec: config file has the correct default schema
await r(['config', 'set', 'defaults.format', 'json'])
const raw = await import('node:fs/promises').then(fs =>
fs.readFile(join(configDir, 'config.yml'), 'utf8'),
)
expect(raw).toMatch(/schema_version/)
})
it('[P0] invalid YAML content in config returns a parse error', async () => {
// Spec: invalid config content returns a parse error
await mkdir(configDir, { recursive: true })
await writeFile(join(configDir, 'config.yml'), ': broken: yaml: [[[', { mode: 0o600 })
const result = await r(['config', 'get', 'defaults.format'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/parse|yaml|config/i)
})
it('[P0] schema_version higher than supported returns config_schema_unsupported error', async () => {
// Spec: config with schema_version higher than supported returns an error
await mkdir(configDir, { recursive: true })
await writeFile(
join(configDir, 'config.yml'),
'schema_version: 999\ndefaults: {}\nstate: {}\n',
{ mode: 0o600 },
)
const result = await r(['config', 'get', 'defaults.format'])
expect(result.exitCode).toBe(6) // VersionCompat
expect(result.stderr).toMatch(/schema_version|unsupported|upgrade/i)
})
it('[P0] DIFY_CONFIG_DIR overrides the default path — config path returns the specified directory', async () => {
// Spec: DIFY_CONFIG_DIR env var overrides the default path
const altDir = await mkdtemp(join(tmpdir(), 'difyctl-alt-'))
try {
const result = await run(['config', 'path'], { configDir: altDir })
assertExitCode(result, 0)
expect(result.stdout.trim()).toBe(join(altDir, 'config.yml'))
}
finally {
await rm(altDir, { recursive: true, force: true })
}
})
it('[P0] a temporary DIFY_CONFIG_DIR does not modify the original config directory', async () => {
// Spec: a temporary DIFY_CONFIG_DIR injection does not modify the original config
await r(['config', 'set', 'defaults.format', 'yaml'])
const altDir = await mkdtemp(join(tmpdir(), 'difyctl-alt-'))
try {
await run(['config', 'set', 'defaults.format', 'json'], { configDir: altDir })
// The original configDir content must be unchanged
const original = await r(['config', 'get', 'defaults.format'])
expect(original.stdout.trim()).toBe('yaml')
}
finally {
await rm(altDir, { recursive: true, force: true })
}
})
// ── Error scenarios ──────────────────────────────────────────────────────────
it('[P0] config get of an unknown key returns exit code 2', async () => {
const result = await r(['config', 'get', 'unknown.key'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toMatch(/unknown config key/i)
})
it('[P0] config set of an unknown key returns exit code 2', async () => {
const result = await r(['config', 'set', 'unknown.key', 'val'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toMatch(/unknown config key/i)
})
it('[P0] config unset of an unknown key returns exit code 2', async () => {
const result = await r(['config', 'unset', 'unknown.key'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toMatch(/unknown config key/i)
})
it('[P0] config set defaults.format with an invalid value returns exit code 2', async () => {
// Spec: config_invalid_value → usage error
const result = await r(['config', 'set', 'defaults.format', 'not_a_format'])
expect(result.exitCode).toBe(2)
expect(result.stderr).toMatch(/defaults\.format|not one of/i)
})
it('[P0] config set defaults.limit 0 (below minimum) returns exit code 2', async () => {
const result = await r(['config', 'set', 'defaults.limit', '0'])
expect(result.exitCode).toBe(2)
})
it('[P0] config set defaults.limit 201 (above maximum) returns exit code 2', async () => {
const result = await r(['config', 'set', 'defaults.limit', '201'])
expect(result.exitCode).toBe(2)
})
it('[P0] config set defaults.limit with a non-numeric string returns exit code 2', async () => {
const result = await r(['config', 'set', 'defaults.limit', 'abc'])
expect(result.exitCode).toBe(2)
})
it('[P1] config set with missing value argument returns an error', async () => {
const result = await r(['config', 'set', 'defaults.format'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument/i)
})
it('[P1] config get with missing key argument returns an error', async () => {
const result = await r(['config', 'get'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/missing required argument/i)
})
// ── Output format ────────────────────────────────────────────────────────────
it('[P0] config output contains no ANSI colour (non-TTY environment)', async () => {
await r(['config', 'set', 'defaults.format', 'json'])
const result = await r(['config', 'view'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
})
it('[P0] config initialization/operations do not leak sensitive information (token/secret)', async () => {
// Spec: config initialization logs do not leak sensitive information
const result = await r(['config', 'view'])
assertExitCode(result, 0)
expect(result.stdout + result.stderr).not.toMatch(/dfoa_|dfoe_|secret|password/i)
})
})

View File

@ -0,0 +1,452 @@
/**
* E2E: difyctl run app — basic app execution + streaming + conversation
*
* Test cases sourced from: Dify CLI Enhanced spec
* - Dify CLI/Run/Basic App Execution (26 cases)
* - Dify CLI/Run/Streaming Output (subset; full coverage in run-app-streaming.e2e.ts)
* - Dify CLI/Run/Conversation Mode (subset)
* - Dify CLI/Error Handling/Exit Code (run-related)
* - Dify CLI/CLI Framework/Non-Interactive (run-related)
*
* Staging app prerequisites (specified via DIFY_E2E_* env vars):
* echo-chat — mode=chat, query variable, outputs "echo: {query}"
* echo-workflow — mode=workflow, x variable (required), outputs "echo: {x}"
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
assertErrorEnvelope,
assertExitCode,
assertJson,
assertNoAnsi,
assertPipeFriendlyJson,
assertStderrContains,
assertStdoutContains,
} from '../../helpers/assert.js'
import { registerConversation } from '../../helpers/cleanup-registry.js'
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
// ── Suite ──────────────────────────────────────────────────────────────────
describe('E2E / difyctl run app', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
// =========================================================================
// Basic execution
// =========================================================================
describe('Basic execution', () => {
it('[P0] logged-in internal user can run app — stdout contains the app result', async () => {
// Spec: logged-in internal user can run app / default output shows execution result
// withRetry: staging LLM inference may have transient 5xx on cold start
const result = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'hello']), {
attempts: 3,
delayMs: 2000,
shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message),
})
assertExitCode(result, 0)
assertStdoutContains(result, 'echo:hello')
})
it('[P0] run app invokes the execute endpoint (stdout has actual content)', async () => {
// Spec: run app invokes the execute endpoint
const result = await fx.r(['run', 'app', E.chatAppId, 'e2e-smoke'])
assertExitCode(result, 0)
expect(result.stdout.length).toBeGreaterThan(0)
})
it('[P1] text output preserves newlines (stdout ends with \\n)', async () => {
// Spec: text output preserves newlines
const result = await fx.r(['run', 'app', E.chatAppId, 'newline'])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/\n$/)
})
it('[P1] repeated run app calls each complete independently (3 iterations)', async () => {
// Spec: repeated run app calls do not affect historical state
for (let i = 0; i < 3; i++) {
const result = await fx.r(['run', 'app', E.chatAppId, `repeat-${i}`])
assertExitCode(result, 0)
assertStdoutContains(result, `echo:repeat-${i}`)
}
})
})
// =========================================================================
// Output format
// =========================================================================
describe('Output format (-o)', () => {
it('[P0] -o json outputs valid JSON', async () => {
// Spec: -o json produces valid JSON
const result = await fx.r(['run', 'app', E.chatAppId, 'json-test', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ answer: string, mode: string }>(result)
expect(parsed).toHaveProperty('answer')
expect(parsed.mode).toMatch(/chat/)
})
it('[P1] JSON output includes execution metadata (message_id / conversation_id)', async () => {
// Spec: JSON output includes execution metadata
const result = await fx.r(['run', 'app', E.chatAppId, 'meta', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<Record<string, unknown>>(result)
expect(parsed).toHaveProperty('message_id')
expect(parsed).toHaveProperty('conversation_id')
})
it('[P1] JSON output supports piping (no ANSI, starts with {, ends with \\n)', async () => {
// Spec: JSON output supports piping
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe', '-o', 'json'])
assertExitCode(result, 0)
assertPipeFriendlyJson(result)
})
it('[P1] JSON mode outputs a JSON error envelope to stderr', async () => {
// Spec: JSON mode outputs a JSON error envelope
const result = await fx.r(['run', 'app', 'app-nonexistent-xyz-e2e', 'hello', '-o', 'json'])
assertNonZeroExit(result)
assertErrorEnvelope(result, 'server_4xx_other')
})
})
// =========================================================================
// --inputs flag
// =========================================================================
describe('--inputs flag', () => {
it('[P0] run app supports --inputs (workflow app)', async () => {
// Spec: run app supports --inputs
// withRetry: staging workflow execution may have transient 5xx
const result = await withRetry(
() => fx.r(['run', 'app', E.workflowAppId, '--inputs', JSON.stringify({ x: 'workflow-val' })]),
{ attempts: 3, delayMs: 2000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) },
)
assertExitCode(result, 0)
assertStdoutContains(result, 'workflow-val')
})
it('[P0] multiple inputs take effect simultaneously', async () => {
// Spec: multiple --inputs entries take effect simultaneously
const result = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'multi-test' }),
])
assertExitCode(result, 0)
})
it('[P0] invalid JSON for --inputs returns usage error (exit code 2)', async () => {
// Spec: missing required parameter / invalid input
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', 'not-json'])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/valid JSON/i)
})
it('[P0] JSON array for --inputs returns usage error', async () => {
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', '[1,2,3]'])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/JSON object/i)
})
it('[P0] --inputs and --inputs-file are mutually exclusive — returns usage error', async () => {
// Spec: mutually exclusive flags return a usage error
const inputsFile = join(fx.configDir, 'inputs.json')
await writeFile(inputsFile, JSON.stringify({ x: 'file-val' }))
const result = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
'{"x":"flag-val"}',
'--inputs-file',
inputsFile,
])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/mutually exclusive/i)
})
it('[P0] positional message passed to workflow app returns usage error', async () => {
// Spec: execution fails when required positional parameter is missing (workflow)
const result = await fx.r(['run', 'app', E.workflowAppId, 'positional-msg'])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/workflow apps do not accept a positional message/i)
})
it('[P0] --inputs-file reads JSON inputs from a file', async () => {
const inputsFile = join(fx.configDir, 'wf-inputs.json')
await writeFile(inputsFile, JSON.stringify({ x: 'from-file' }))
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs-file', inputsFile])
assertExitCode(result, 0)
assertStdoutContains(result, 'from-file')
})
})
// =========================================================================
// Error scenarios
// =========================================================================
describe('Error scenarios', () => {
it('[P0] non-existent app returns error — exit code 1', async () => {
// Spec: non-existent app returns app-not-found + exit code 1
const result = await fx.r(['run', 'app', 'app-id-does-not-exist-e2e-xyz', 'hello'])
assertExitCode(result, 1)
expect(result.stderr).toMatch(/not.?found/i)
})
it('[P0] missing app id returns error (exit code 1 — CLI returns 1 for missing required arg)', async () => {
// Spec: missing app id returns a usage error
// Actual behaviour: CLI framework returns exit 1 (not 2) for missing required argument
const result = await fx.r(['run', 'app'])
assertExitCode(result, 1)
expect(result.stderr).toMatch(/missing required argument/i)
})
it('[P0] unauthenticated run app returns auth error (exit code 4)', async () => {
// Spec: unauthenticated run app returns auth error + exit code 4
const unauthTmp = await withTempConfig()
try {
const result = await run(['run', 'app', E.chatAppId, 'hello'], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
}
finally {
await unauthTmp.cleanup()
}
})
})
// =========================================================================
// Streaming output
// =========================================================================
describe('Streaming output', () => {
it('[P0] --stream receives streaming output correctly — stdout has content', async () => {
// Spec: run app --stream receives streaming output correctly
const result = await fx.r(['run', 'app', E.chatAppId, 'stream-test', '--stream'])
assertExitCode(result, 0)
assertStdoutContains(result, 'echo:stream-test')
})
it('[P0] exit code is 0 after streaming completes', async () => {
// Spec: streaming exits normally after completion
const result = await fx.r(['run', 'app', E.chatAppId, 'end-ok', '--stream'])
assertExitCode(result, 0)
})
it('[P1] stderr is not mixed into stdout in streaming mode', async () => {
// Spec: stderr is not mixed into stdout in streaming mode
const result = await fx.r(['run', 'app', E.chatAppId, 'sep', '--stream'])
assertExitCode(result, 0)
expect(result.stdout).not.toContain('hint:')
assertStderrContains(result, '--conversation')
})
it('[P1] --stream -o json outputs a valid JSON envelope', async () => {
// Spec: streaming mode produces valid JSON output
const result = await fx.r(['run', 'app', E.chatAppId, 'sjson', '--stream', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ mode: string, answer: string }>(result)
expect(parsed.mode).toMatch(/chat/)
})
it('[P0] streaming with non-existent app returns error (exit code 1)', async () => {
// Spec: streaming with non-existent app returns an error
const result = await fx.r(['run', 'app', 'nonexistent-xyz-e2e', 'hi', '--stream'])
assertExitCode(result, 1)
})
it('[P0] unauthenticated streaming returns auth error (exit code 4)', async () => {
// Spec: unauthenticated streaming returns an auth error
const unauthTmp = await withTempConfig()
try {
const result = await run(['run', 'app', E.chatAppId, 'hi', '--stream'], {
configDir: unauthTmp.configDir,
})
assertExitCode(result, 4)
}
finally {
await unauthTmp.cleanup()
}
})
it('[P1] streaming mode output supports piping (no ANSI, ends with \\n)', async () => {
// Spec: streaming mode output supports piping
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe-s', '--stream'])
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
expect(result.stdout.endsWith('\n')).toBe(true)
})
it('[P0] workflow streaming output contains succeeded status', async () => {
const result = await fx.r([
'run',
'app',
E.workflowAppId,
'--inputs',
JSON.stringify({ x: 'wf-stream-val' }),
'--stream',
'-o',
'json',
])
assertExitCode(result, 0)
const parsed = assertJson<{ data?: { status?: string } }>(result)
expect(parsed.data?.status).toBe('succeeded')
})
})
// =========================================================================
// Conversation mode
// =========================================================================
describe('Conversation mode', () => {
it('[P0] chat app can create a new conversation — stderr contains hint', async () => {
// Spec: chat app can create a new conversation
const result = await fx.r(['run', 'app', E.chatAppId, 'start-conv'])
assertExitCode(result, 0)
assertStderrContains(result, '--conversation')
})
it('[P0] JSON output includes conversation_id', async () => {
// Spec: JSON output includes conversation_id
const result = await fx.r(['run', 'app', E.chatAppId, 'conv-json', '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<{ conversation_id: string }>(result)
expect(typeof parsed.conversation_id).toBe('string')
expect(parsed.conversation_id.length).toBeGreaterThan(0)
registerConversation(E.host, E.token, E.chatAppId, parsed.conversation_id)
})
it('[P0] --conversation flag works — conversation_id is reused in subsequent requests', async () => {
// Spec: --conversation flag works; conversation_id is reused in subsequent requests
const first = await fx.r(['run', 'app', E.chatAppId, 'first-msg', '-o', 'json'])
assertExitCode(first, 0)
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
registerConversation(E.host, E.token, E.chatAppId, conversation_id)
const second = await fx.r([
'run',
'app',
E.chatAppId,
'second-msg',
'--conversation',
conversation_id,
'-o',
'json',
])
assertExitCode(second, 0)
const secondParsed = assertJson<{ conversation_id: string }>(second)
expect(secondParsed.conversation_id).toBe(conversation_id)
})
it('[P0] a new session is auto-created when conversation_id is omitted', async () => {
// Spec: a new session is auto-created when conversation_id is omitted
const result = await fx.r(['run', 'app', E.chatAppId, 'new-conv', '-o', 'json'])
assertExitCode(result, 0)
const { conversation_id } = assertJson<{ conversation_id: string }>(result)
expect(conversation_id).toBeTruthy()
})
it('[P0] invalid conversation_id returns error (exit code 1)', async () => {
// Spec: invalid conversation_id returns an error
const result = await fx.r([
'run',
'app',
E.chatAppId,
'bad-conv',
'--conversation',
'invalid-conv-id-xyz-not-exist',
])
assertNonZeroExit(result)
})
it('[P1] conversation mode supports streaming', async () => {
// Spec: conversation mode supports streaming
const first = await fx.r(['run', 'app', E.chatAppId, 'init', '-o', 'json'])
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
const result = await fx.r([
'run',
'app',
E.chatAppId,
'continue',
'--conversation',
conversation_id,
'--stream',
])
assertExitCode(result, 0)
assertStdoutContains(result, 'echo:')
})
it('[P1] conversation output supports piping (-o json pipe-friendly format)', async () => {
// Spec: conversation output supports piping
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe-conv', '-o', 'json'])
assertExitCode(result, 0)
assertPipeFriendlyJson(result)
})
})
// =========================================================================
// Non-interactive mode / CI environment
// =========================================================================
describe('Non-interactive mode (CI)', () => {
it('[P0] CI=1 environment has no spinner — stdout has no ANSI colour', async () => {
// Spec: ANSI colour is disabled in non-TTY environment; spinner is suppressed in non-interactive mode
const result = await fx.r(['run', 'app', E.chatAppId, 'ci-test'], { CI: '1', NO_COLOR: '1' })
assertExitCode(result, 0)
assertNoAnsi(result.stdout, 'stdout')
assertNoAnsi(result.stderr, 'stderr')
})
it('[P0] non-interactive mode exit code is correctly propagated', async () => {
// Spec: non-interactive mode exit code is correct
const result = await fx.r(['run', 'app', E.chatAppId, 'code'])
expect(typeof result.exitCode).toBe('number')
expect(result.exitCode).toBe(0)
})
})
// =========================================================================
// Workspace override
// =========================================================================
describe('workspace override', () => {
it('[P1] --workspace flag overrides the default workspace', async () => {
// Spec: workspace override takes effect
// run app uses --workspace (no -w short form)
const result = await fx.r([
'run',
'app',
E.chatAppId,
'ws-override',
'--workspace',
E.workspaceId,
])
assertExitCode(result, 0)
})
})
})
// ── local helper (avoids import confusion) ─────────────────────────────────
function assertNonZeroExit(result: import('../../helpers/cli.js').RunResult): void {
expect(result.exitCode, 'exit code should be non-zero').not.toBe(0)
}

View File

@ -0,0 +1,161 @@
/**
* E2E: difyctl run app --file — file input specialisation
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/File Input (31 cases)
*
* Prerequisites:
* DIFY_E2E_FILE_APP_ID — workflow app with a required 'doc' file variable
* All file-related cases are skipped when this variable is not configured.
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { assertExitCode, assertJson } from '../../helpers/assert.js'
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
import { optionalDescribe, optionalIt } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
// supportsLocalUpload capability removed — local file upload probe is no longer
// performed in global-setup. Default to false (skip upload-specific cases).
const supportsLocalUpload = false
const describeSuite = optionalDescribe(Boolean(E.fileAppId))
describeSuite('E2E / difyctl run app --file', () => {
let configDir: string
let fileDir: string
let cleanupConfig: () => Promise<void>
beforeEach(async () => {
const tmp = await withTempConfig()
configDir = tmp.configDir
cleanupConfig = tmp.cleanup
fileDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-files-'))
await injectAuth(configDir, {
host: E.host,
bearer: E.token,
workspaceId: E.workspaceId,
workspaceName: E.workspaceName,
})
})
afterEach(async () => {
await cleanupConfig()
await rm(fileDir, { recursive: true, force: true })
})
function r(argv: string[]) {
return run(argv, { configDir })
}
const itLocalUpload = optionalIt(supportsLocalUpload)
itLocalUpload('[P0] run app supports single file upload (key=@path) — app executes correctly', async () => {
// Spec: run app supports single file upload + app executes correctly after upload
const filePath = join(fileDir, 'test.txt')
await writeFile(filePath, 'E2E test file content — single upload')
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`])
assertExitCode(result, 0)
})
itLocalUpload('[P0] file input argument name maps correctly (key binds to correct input field)', async () => {
// Spec: file input argument name maps correctly
const filePath = join(fileDir, 'mapping.txt')
await writeFile(filePath, 'mapping test content')
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '-o', 'json'])
assertExitCode(result, 0)
const parsed = assertJson<Record<string, unknown>>(result)
expect(parsed).toBeDefined()
})
itLocalUpload('[P0] run app --file syntax is key=@path (local file upload)', async () => {
// Spec: run app --file syntax is key=@path
const filePath = join(fileDir, 'syntax.txt')
await writeFile(filePath, 'syntax verification')
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`])
assertExitCode(result, 0)
})
it('[P0] --file remote URL syntax (key=https://...) requires no local upload', async () => {
// Spec: run app --file with remote URL executes the workflow correctly
const result = await r([
'run',
'app',
E.fileAppId,
'--file',
'doc=https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
])
assertExitCode(result, 0)
})
it('[P0] non-existent file path returns an error', async () => {
// Spec: non-existent file path returns an error
const result = await r([
'run',
'app',
E.fileAppId,
'--file',
'doc=@/nonexistent/path/missing-file.txt',
])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/failed|not.?found|upload/i)
})
it('[P1] malformed --file argument returns usage error (exit code 2)', async () => {
// Spec: malformed --file argument returns a usage error
const result = await r([
'run',
'app',
E.chatAppId,
'hello',
'--file',
'invalidformat',
])
assertExitCode(result, 2)
expect(result.stderr).toMatch(/--file must be key=@path/i)
})
itLocalUpload('[P1] file path containing spaces can be uploaded correctly', async () => {
// Spec: file path containing spaces can be uploaded correctly
const filePath = join(fileDir, 'file with spaces.txt')
await writeFile(filePath, 'space in name test')
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`])
assertExitCode(result, 0)
})
itLocalUpload('[P1] txt file upload is supported', async () => {
// Spec: txt file upload is supported
const f = join(fileDir, 'note.txt')
await writeFile(f, 'plain text content')
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`])
assertExitCode(result, 0)
})
itLocalUpload('[P1] --file combined with --stream works correctly', async () => {
// Spec: run app --file combined with --stream
const f = join(fileDir, 'stream.txt')
await writeFile(f, 'stream + file test')
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--stream'])
assertExitCode(result, 0)
})
it('[P0] unauthenticated file upload returns auth error (exit code 4)', async () => {
// Spec: unauthenticated file upload returns an auth error
const unauthTmp = await withTempConfig()
try {
const f = join(fileDir, 'unauth.txt')
await writeFile(f, 'test')
const result = await run(
['run', 'app', E.fileAppId || E.chatAppId, '--file', `doc=@${f}`],
{ configDir: unauthTmp.configDir },
)
assertExitCode(result, 4)
}
finally {
await unauthTmp.cleanup()
}
})
})

View File

@ -0,0 +1,142 @@
/**
* E2E: difyctl run app + difyctl resume app — HITL human-in-the-loop specialisation
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/HITL Human Intervention (19 cases)
*
* Prerequisites:
* DIFY_E2E_HITL_APP_ID — workflow app containing a Human Input node with display_in_ui=true
* All HITL cases are skipped when this variable is not configured.
*/
import type { AuthFixture } from '../../helpers/cli.js'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { assertExitCode, assertJson, assertStderrContains } from '../../helpers/assert.js'
import { withAuthFixture } from '../../helpers/cli.js'
import { optionalDescribe } from '../../helpers/skip.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
const describeSuite = optionalDescribe(Boolean(E.hitlAppId))
describeSuite('E2E / difyctl run app — HITL human intervention', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
it('[P0] workflow HITL pause outputs a pause block on stdout — exit code 0', async () => {
// Spec: workflow HITL pause outputs a pause block + exit code 0
const result = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'hitl-e2e' }),
])
assertExitCode(result, 0)
expect(result.stdout).toMatch(/Workflow paused|pause/i)
})
it('[P0] HITL pause JSON contains all required fields', async () => {
// Spec: HITL pause JSON output contains all required fields
const result = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'hitl-json' }),
'-o',
'json',
])
assertExitCode(result, 0)
const p = assertJson<Record<string, unknown>>(result)
expect(p).toHaveProperty('status', 'paused')
expect(p).toHaveProperty('form_token')
expect(p).toHaveProperty('workflow_run_id')
expect(p).toHaveProperty('node_title')
expect(p).toHaveProperty('form_content')
expect(p).toHaveProperty('actions')
})
it('[P0] HITL pause hint contains the full resume command', async () => {
// Spec: HITL pause hint contains the full resume command
const result = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'hint-test' }),
])
assertExitCode(result, 0)
assertStderrContains(result, 'difyctl resume app')
assertStderrContains(result, '--workflow-run-id')
})
it('[P0] AI Agent automation — extract form_token from JSON and auto-resume', async () => {
// Spec: AI Agent automation — extract form_token via jq and auto-resume
// Step 1: run → pause, obtain JSON envelope
const pauseResult = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'auto-resume' }),
'-o',
'json',
])
assertExitCode(pauseResult, 0)
const envelope = assertJson<{
form_token: string
workflow_run_id: string
app_id?: string
actions?: Array<{ id: string }>
}>(pauseResult)
expect(envelope.form_token).toBeTruthy()
expect(envelope.workflow_run_id).toBeTruthy()
// Step 2: resume — use the first action id from the pause response so
// the test is not coupled to any specific action label.
const actionId = envelope.actions?.[0]?.id ?? 'submit'
const resumeResult = await fx.r([
'resume',
'app',
E.hitlAppId,
envelope.form_token,
'--workflow-run-id',
envelope.workflow_run_id,
'--action',
actionId,
])
assertExitCode(resumeResult, 0)
})
it('[P0] resume app auto-selects the single action — workflow continues execution', async () => {
// Spec: resume app auto-selects the single action without requiring --action
const pause = await fx.r([
'run',
'app',
E.hitlAppId,
'--inputs',
JSON.stringify({ x: 'auto-action' }),
'-o',
'json',
])
assertExitCode(pause, 0)
const { form_token, workflow_run_id } = assertJson<{ form_token: string, workflow_run_id: string }>(pause)
// Resume without --action (single action auto-selected)
const resume = await fx.r([
'resume',
'app',
E.hitlAppId,
form_token,
'--workflow-run-id',
workflow_run_id,
])
assertExitCode(resume, 0)
})
})

View File

@ -0,0 +1,125 @@
/**
* E2E: difyctl run app --stream — streaming output specialisation
*
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Streaming Output (24 cases)
*
* Covers scenarios that run-app-basic.e2e.ts cannot handle:
* - Ctrl+C interruption (SIGINT)
* - Chunk arrival order verification (timing)
*/
import type { Buffer } from 'node:buffer'
import type { AuthFixture } from '../../helpers/cli.js'
import { spawn } from 'node:child_process'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { assertExitCode } from '../../helpers/assert.js'
import { BIN, BUN, withAuthFixture } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
import { loadE2EEnv } from '../../setup/env.js'
const E = loadE2EEnv()
describe('E2E / difyctl run app --stream (specialisation)', () => {
let fx: AuthFixture
beforeEach(async () => {
fx = await withAuthFixture(E)
})
afterEach(async () => {
await fx.cleanup()
})
it('[P0] streaming output arrives in real-time chunks (stdout non-empty, echo complete)', async () => {
// Spec: streaming output is printed in real-time by chunk + token order is preserved
// withRetry: staging SSE connections may fail transiently on cold start
await withRetry(async () => {
const query = 'chunk-order-test'
const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, query, '--stream'], {
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
})
const chunks: string[] = []
proc.stdout.on('data', (d: Buffer) => {
chunks.push(d.toString('utf8'))
})
let stderr = ''
proc.stderr.on('data', (d: Buffer) => {
stderr += d.toString('utf8')
})
const exitCode = await new Promise<number>((res) => {
proc.on('close', code => res(code ?? 1))
})
assertExitCode({ stdout: chunks.join(''), stderr, exitCode }, 0)
// May arrive in multiple chunks; the concatenated result must contain the full query
expect(chunks.join('')).toContain(query)
}, { attempts: 3, delayMs: 2000 })
})
it('[P1] Ctrl+C interrupts streaming (SIGINT → non-zero exit code)', async () => {
// Spec: Ctrl+C interrupts streaming + exit code is non-zero after Ctrl+C
const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, 'ctrl-c-test', '--stream'], {
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
})
let _stdout = ''
let _stderr = ''
proc.stdout.on('data', (d: Buffer) => {
_stdout += d.toString('utf8')
})
proc.stderr.on('data', (d: Buffer) => {
_stderr += d.toString('utf8')
})
// Wait for the process to start streaming, then interrupt.
await new Promise(res => setTimeout(res, 800))
proc.kill('SIGINT')
const exitCode = await new Promise<number>((res) => {
proc.on('close', code => res(code ?? 1))
})
expect(exitCode, 'SIGINT should cause non-zero exit').not.toBe(0)
})
it('[P0] server-side error event causes CLI to exit with non-zero code', async () => {
// Spec: server-side error event causes CLI to exit with non-zero code
// Use a non-existent app ID to force a server-side error.
const proc = spawn(BUN, [BIN, 'run', 'app', 'nonexistent-app-xyz-e2e', 'hi', '--stream'], {
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
})
let stderr = ''
proc.stderr.on('data', (d: Buffer) => {
stderr += d.toString('utf8')
})
const exitCode = await new Promise<number>((res) => {
proc.on('close', code => res(code ?? 1))
})
expect(exitCode, 'error event should cause non-zero exit').not.toBe(0)
expect(stderr.length).toBeGreaterThan(0)
})
it('[P0] streaming fails when a required input is missing (exit code non-zero)', async () => {
// Spec: streaming fails when a required input is missing
// workflow app requires variable x (required); the server should return a validation error
// immediately, and the CLI exits with a non-zero code.
//
// ⚠️ Depends on feat/cli API version (server-side pre-validation of missing required inputs).
// Current local server 1.14.1 does not support this check; test passes once upgraded.
const proc = spawn(BUN, [BIN, 'run', 'app', E.workflowAppId, '--stream'], {
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
})
let stderr = ''
proc.stderr.on('data', (d: Buffer) => {
stderr += d.toString('utf8')
})
const exitCode = await new Promise<number>((res) => {
proc.on('close', code => res(code ?? 1))
})
expect(exitCode).not.toBe(0)
// The server should return a clear validation error rather than timing out
expect(stderr).toMatch(/validation|required|invalid|missing/i)
})
})

84
cli/vitest.e2e.config.ts Normal file
View File

@ -0,0 +1,84 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { defineConfig } from 'vite-plus'
import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js'
const buildInfo = resolveBuildInfo()
// Load .env.e2e into process.env (only if the file exists; in CI vars are
// injected directly via GitHub Actions secrets).
const envFilePath = resolve(process.cwd(), '.env.e2e')
try {
const raw = readFileSync(envFilePath, 'utf8')
for (const line of raw.split('\n')) {
const trimmed = line.trim()
if (!trimmed || trimmed.startsWith('#'))
continue
const eqIdx = trimmed.indexOf('=')
if (eqIdx === -1)
continue
const key = trimmed.slice(0, eqIdx).trim()
const val = trimmed.slice(eqIdx + 1).trim()
if (key && !(key in process.env))
process.env[key] = val
}
}
catch {
// .env.e2e not found — rely on environment variables already set in the shell
}
/**
* Vitest configuration for E2E tests.
*
* E2E tests run against a real staging Dify server and require
* DIFY_E2E_* environment variables to be set (see test/e2e/setup/env.ts).
*
* Run: bun vitest --config vitest.e2e.config.ts
*/
export default defineConfig({
pack: {
entry: ['src/index.ts'],
format: ['esm'],
outDir: 'dist',
target: 'node22',
define: {
__DIFYCTL_VERSION__: JSON.stringify(buildInfo.version),
__DIFYCTL_COMMIT__: JSON.stringify(buildInfo.commit),
__DIFYCTL_BUILD_DATE__: JSON.stringify(buildInfo.buildDate),
__DIFYCTL_CHANNEL__: JSON.stringify(buildInfo.channel),
__DIFYCTL_MIN_DIFY__: JSON.stringify(buildInfo.minDify),
__DIFYCTL_MAX_DIFY__: JSON.stringify(buildInfo.maxDify),
},
},
test: {
environment: 'node',
globalSetup: ['test/e2e/setup/global-setup.ts'],
// E2E tests do NOT use the unit-test setup.ts (no globalThis stubs needed —
// the real binary sets its own globals at startup).
setupFiles: [],
include: process.env.DIFY_E2E_MODE === 'local'
? ['test/e2e/suites/config/**/*.e2e.ts']
: [
// auth tests first (most others depend on a valid session)
'test/e2e/suites/auth/status.e2e.ts',
'test/e2e/suites/auth/use.e2e.ts',
'test/e2e/suites/auth/whoami.e2e.ts',
// config (local, no network)
'test/e2e/suites/config/**/*.e2e.ts',
// run tests (require valid token)
'test/e2e/suites/run/**/*.e2e.ts',
// devices + logout LAST — both can revoke tokens
'test/e2e/suites/auth/devices.e2e.ts',
'test/e2e/suites/auth/logout.e2e.ts',
],
// E2E calls a real staging server — allow plenty of time per test.
testTimeout: 60_000,
hookTimeout: 30_000,
// Retry up to 2 times on staging flakiness.
retry: 0, // flaky tests use withRetry() locally; global retry masks non-idempotent failures
// Run suites sequentially to avoid workspace-level conflicts on staging.
pool: 'forks',
fileParallelism: false,
reporters: ['verbose'],
},
})

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual'
import type { RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import {
Autocomplete,
AutocompleteClear,
@ -159,6 +159,29 @@ const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, in
const getSuggestionLabel = (item: Suggestion) => item.label
async function searchSuggestions(
suggestions: Suggestion[],
query: string,
filter: (item: string, query: string) => boolean,
): Promise<{ items: Suggestion[], error: string | null }> {
await new Promise(resolve => window.setTimeout(resolve, 500))
if (query === 'will_error') {
return {
items: [],
error: 'Failed to load suggestions. Please try again.',
}
}
return {
items: suggestions.filter(item => (
filter(item.label, query)
|| (item.description ? filter(item.description, query) : false)
)),
error: null,
}
}
const SuggestionItem = ({
item,
dense,
@ -227,6 +250,7 @@ const BasicTagAutocomplete = ({
<Autocomplete
items={tagSuggestions}
itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup size={size}>
@ -311,32 +335,64 @@ const LimitedStatus = ({
}
const AsyncSearchDemo = () => {
const [value, setValue] = useState('agent')
const [loading, setLoading] = useState(false)
const [items, setItems] = useState(remoteSuggestions)
const [searchValue, setSearchValue] = useState('')
const [searchResults, setSearchResults] = useState<Suggestion[]>([])
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const { contains } = useAutocompleteFilter()
const abortControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
setItems(
value.trim()
? remoteSuggestions.filter(item => item.label.toLowerCase().includes(value.trim().toLowerCase()))
: remoteSuggestions,
)
setLoading(false)
}, 500)
const status = (() => {
if (isPending)
return 'Searching remote suggestions…'
return () => window.clearTimeout(timeout)
}, [value])
if (error)
return error
if (searchValue === '')
return null
if (searchResults.length === 0)
return `No remote suggestion matches "${searchValue}".`
return `${searchResults.length} remote suggestion${searchResults.length === 1 ? '' : 's'} found`
})()
return (
<div className={inputWidth}>
<Autocomplete
items={items}
value={value}
onValueChange={setValue}
items={searchResults}
value={searchValue}
onValueChange={(nextSearchValue) => {
setSearchValue(nextSearchValue)
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
if (nextSearchValue === '') {
setSearchResults([])
setError(null)
return
}
startTransition(async () => {
setError(null)
const result = await searchSuggestions(remoteSuggestions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
itemToStringValue={getSuggestionLabel}
openOnInputClick
filter={null}
mode="list"
>
<AutocompleteInputGroup>
<span className="i-ri-cloud-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
@ -344,16 +400,15 @@ const AsyncSearchDemo = () => {
<AutocompleteClear />
<AutocompleteTrigger />
</AutocompleteInputGroup>
<AutocompleteContent>
<AutocompleteContent portalProps={{ hidden: !status }} popupProps={{ 'aria-busy': isPending || undefined }}>
<AutocompleteStatus>
{loading ? 'Loading suggestions…' : `${items.length} remote suggestions`}
{status}
</AutocompleteStatus>
<AutocompleteList>
{(item: Suggestion) => (
<SuggestionItem key={item.value} item={item} />
)}
</AutocompleteList>
<AutocompleteEmpty>No remote suggestion. Keep the typed query.</AutocompleteEmpty>
</AutocompleteContent>
</Autocomplete>
</div>
@ -467,6 +522,7 @@ const FuzzyMatchingDemo = () => {
onValueChange={setValue}
filter={contains}
itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
@ -567,6 +623,7 @@ export const GroupedSuggestions: Story = {
<Autocomplete
items={groupedSuggestions}
itemToStringValue={getSuggestionLabel}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
@ -595,6 +652,7 @@ export const LimitResults: Story = {
items={workflowSuggestions}
itemToStringValue={getSuggestionLabel}
limit={5}
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
@ -627,6 +685,7 @@ export const CommandPalette: Story = {
inline
items={commandGroups}
itemToStringValue={getSuggestionLabel}
mode="list"
autoHighlight="always"
keepHighlight
>
@ -649,6 +708,7 @@ const VirtualizedLongSuggestionsDemo = () => {
<Autocomplete
items={virtualizedSuggestions}
itemToStringValue={getSuggestionLabel}
mode="list"
virtualized
openOnInputClick
onItemHighlighted={(item, details) => {
@ -686,6 +746,7 @@ export const Empty: Story = {
items={tagSuggestions}
itemToStringValue={getSuggestionLabel}
defaultValue="private-release-note"
mode="list"
openOnInputClick
>
<AutocompleteInputGroup>
@ -710,7 +771,7 @@ export const Empty: Story = {
export const DisabledAndReadOnly: Story = {
render: () => (
<div className="flex w-80 flex-col gap-3">
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" disabled>
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" mode="list" disabled>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Disabled tag autocomplete" />
<AutocompleteClear />
@ -724,7 +785,7 @@ export const DisabledAndReadOnly: Story = {
</AutocompleteList>
</AutocompleteContent>
</Autocomplete>
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" readOnly>
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" mode="both" readOnly>
<AutocompleteInputGroup>
<AutocompleteInput aria-label="Read-only prompt autocomplete" />
<AutocompleteClear />

View File

@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
import type { Virtualizer } from '@tanstack/react-virtual'
import type { RefObject } from 'react'
import { useVirtualizer } from '@tanstack/react-virtual'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
import {
Combobox,
ComboboxChip,
@ -26,6 +26,7 @@ import {
ComboboxStatus,
ComboboxTrigger,
ComboboxValue,
useComboboxFilter,
useComboboxFilteredItems,
} from '.'
import { cn } from '../cn'
@ -178,8 +179,34 @@ const defaultPopupDataSource = dataSourceOptions[1]!
const readOnlyDataSource = dataSourceOptions[2]!
const defaultTool = toolGroups[0]!.items[0]!
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!]
const defaultAsyncReviewers = [reviewerOptions[1]!]
const defaultTag = tagOptions[2]!
const getOptionLabel = (option: Option) => option.label
async function searchOptions(
options: Option[],
query: string,
filter: (item: string, query: string) => boolean,
): Promise<{ items: Option[], error: string | null }> {
await new Promise(resolve => window.setTimeout(resolve, 450))
if (query === 'will_error') {
return {
items: [],
error: 'Failed to fetch matches. Please try again.',
}
}
return {
items: options.filter(option => (
filter(option.label, query)
|| (option.meta ? filter(option.meta, query) : false)
)),
error: null,
}
}
const renderOptionItem = (option: Option) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
<ComboboxItemText className="flex items-center gap-2 px-0">
@ -348,35 +375,88 @@ const VirtualizedLongListDemo = () => {
}
const AsyncDirectoryDemo = () => {
const [inputValue, setInputValue] = useState('ma')
const [value, setValue] = useState<Option | null>(null)
const [items, setItems] = useState(directoryOptions.slice(0, 3))
const [loading, setLoading] = useState(false)
const [searchResults, setSearchResults] = useState<Option[]>([])
const [selectedValue, setSelectedValue] = useState<Option | null>(null)
const [searchValue, setSearchValue] = useState('')
const [error, setError] = useState<string | null>(null)
const [isPending, startTransition] = useTransition()
const { contains } = useComboboxFilter()
const abortControllerRef = useRef<AbortController | null>(null)
const trimmedSearchValue = searchValue.trim()
const items = useMemo(() => {
if (!selectedValue || searchResults.some(option => option.value === selectedValue.value))
return searchResults
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
const query = inputValue.trim().toLowerCase()
setItems(
query
? directoryOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
: directoryOptions.slice(0, 5),
)
setLoading(false)
}, 450)
return [...searchResults, selectedValue]
}, [searchResults, selectedValue])
return () => window.clearTimeout(timeout)
}, [inputValue])
const status = (() => {
if (isPending)
return 'Searching directory matches…'
if (error)
return error
if (trimmedSearchValue === '')
return selectedValue ? null : 'Start typing to search owners…'
if (searchResults.length === 0)
return `No matches for "${trimmedSearchValue}".`
return `${searchResults.length} owner${searchResults.length === 1 ? '' : 's'} found`
})()
const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
? null
: 'Try a different owner search.'
return (
<FieldRoot name="owner" className={fieldWidth}>
<FieldLabel>Owner</FieldLabel>
<Combobox
items={value && !items.some(item => item.value === value.value) ? [value, ...items] : items}
value={value}
onValueChange={setValue}
inputValue={inputValue}
onInputValueChange={setInputValue}
items={items}
itemToStringLabel={getOptionLabel}
filter={null}
value={selectedValue}
onOpenChangeComplete={(open) => {
if (!open && selectedValue)
setSearchResults([selectedValue])
}}
onValueChange={(nextSelectedValue) => {
setSelectedValue(nextSelectedValue)
setSearchValue('')
setError(null)
}}
onInputValueChange={(nextSearchValue, { reason }) => {
setSearchValue(nextSearchValue)
if (nextSearchValue === '') {
setSearchResults([])
setError(null)
return
}
if (reason === 'item-press')
return
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
startTransition(async () => {
setError(null)
const result = await searchOptions(directoryOptions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
>
<ComboboxInputGroup className="h-8 min-h-8 px-2">
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
@ -384,12 +464,12 @@ const AsyncDirectoryDemo = () => {
<ComboboxClear className="mr-0.5" />
<ComboboxInputTrigger className="mr-0" />
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]">
<ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}>
<ComboboxStatus className="border-b border-divider-subtle">
{loading ? 'Loading directory matches…' : `${items.length} selectable owners`}
{status}
</ComboboxStatus>
<ComboboxList>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>No owner matches this query</ComboboxEmpty>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
</ComboboxContent>
</Combobox>
</FieldRoot>
@ -397,38 +477,111 @@ const AsyncDirectoryDemo = () => {
}
const AsyncReviewerDemo = () => {
const [inputValue, setInputValue] = useState('ma')
const [value, setValue] = useState<Option[]>([reviewerOptions[1]!])
const [items, setItems] = useState(reviewerOptions.slice(0, 3))
const [loading, setLoading] = useState(false)
const [searchResults, setSearchResults] = useState<Option[]>([])
const [selectedValues, setSelectedValues] = useState<Option[]>(defaultAsyncReviewers)
const [searchValue, setSearchValue] = useState('')
const [error, setError] = useState<string | null>(null)
const [blockStartStatus, setBlockStartStatus] = useState(false)
const [isPending, startTransition] = useTransition()
const { contains } = useComboboxFilter()
const abortControllerRef = useRef<AbortController | null>(null)
const selectedValuesRef = useRef<Option[]>(defaultAsyncReviewers)
const trimmedSearchValue = searchValue.trim()
useEffect(() => {
setLoading(true)
const timeout = window.setTimeout(() => {
const query = inputValue.trim().toLowerCase()
const matches = query
? reviewerOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
: reviewerOptions
const items = useMemo(() => {
if (selectedValues.length === 0)
return searchResults
setItems(matches)
setLoading(false)
}, 450)
const merged = [...searchResults]
return () => window.clearTimeout(timeout)
}, [inputValue])
selectedValues.forEach((selected) => {
if (!searchResults.some(result => result.value === selected.value))
merged.push(selected)
})
const selectedItems = value.filter(selected => !items.some(item => item.value === selected.value))
return merged
}, [searchResults, selectedValues])
const status = (() => {
if (isPending)
return 'Searching reviewer matches…'
if (error)
return error
if (trimmedSearchValue === '' && !blockStartStatus)
return selectedValues.length > 0 ? null : 'Start typing to search reviewers…'
if (searchResults.length === 0 && !blockStartStatus)
return `No matches for "${trimmedSearchValue}".`
return `${searchResults.length} reviewer${searchResults.length === 1 ? '' : 's'} found`
})()
const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
? null
: 'Try a different reviewer search.'
return (
<FieldRoot name="asyncReviewers" className={fieldWidth}>
<FieldLabel>Async reviewers</FieldLabel>
<Combobox
items={[...selectedItems, ...items]}
items={items}
itemToStringLabel={getOptionLabel}
multiple
value={value}
onValueChange={setValue}
inputValue={inputValue}
onInputValueChange={setInputValue}
filter={null}
value={selectedValues}
onOpenChangeComplete={(open) => {
if (!open) {
setSearchResults(selectedValuesRef.current)
setBlockStartStatus(false)
}
}}
onValueChange={(nextSelectedValues) => {
selectedValuesRef.current = nextSelectedValues
setSelectedValues(nextSelectedValues)
setSearchValue('')
setError(null)
if (nextSelectedValues.length === 0) {
setSearchResults([])
setBlockStartStatus(false)
}
else {
setBlockStartStatus(true)
}
}}
onInputValueChange={(nextSearchValue, { reason }) => {
setSearchValue(nextSearchValue)
const controller = new AbortController()
abortControllerRef.current?.abort()
abortControllerRef.current = controller
if (nextSearchValue === '') {
setSearchResults(selectedValuesRef.current)
setError(null)
setBlockStartStatus(false)
return
}
if (reason === 'item-press')
return
startTransition(async () => {
setError(null)
const result = await searchOptions(reviewerOptions, nextSearchValue, contains)
if (controller.signal.aborted)
return
startTransition(() => {
setSearchResults(result.items)
setError(result.error)
})
})
}}
>
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1">
<ComboboxChips>
@ -447,12 +600,12 @@ const AsyncReviewerDemo = () => {
</ComboboxValue>
</ComboboxChips>
</ComboboxInputGroup>
<ComboboxContent popupClassName="w-[420px]">
<ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}>
<ComboboxStatus className="border-b border-divider-subtle">
{loading ? 'Loading reviewer matches…' : `${items.length} selectable reviewers`}
{status}
</ComboboxStatus>
<ComboboxList>{renderOptionItem}</ComboboxList>
<ComboboxEmpty>No reviewer matches this query</ComboboxEmpty>
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
</ComboboxContent>
</Combobox>
<FieldDescription>Selected reviewers stay available while async matches change.</FieldDescription>

View File

@ -17,7 +17,7 @@ const Placeholder = ({
loadingFileName,
}: Props) => {
return (
<div className={wrapClassName}>
<div className={cn(wrapClassName, 'p-3')}>
<SkeletonRow>
<div
className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]

View File

@ -30,7 +30,7 @@ const Installed: FC<Props> = ({
}
return (
<>
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<p className="system-md-regular text-text-secondary">{(isFailed && errMsg) ? errMsg : t(`installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`, { ns: 'plugin' })}</p>
{payload && (
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">

View File

@ -116,7 +116,7 @@ const Installed: FC<Props> = ({
return (
<>
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<div className="system-md-regular text-text-secondary">
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
<p>

View File

@ -106,7 +106,7 @@ const Uploading: FC<Props> = ({
}, [handleUpload])
return (
<>
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
<div className="flex items-center gap-1 self-stretch">
<span className="i-ri-loader-2-line size-4 animate-spin-slow text-text-accent" />
<div className="system-md-regular text-text-secondary">

View File

@ -787,7 +787,7 @@ export const request = async<T>(url: string, options = {}, otherOptions?: IOther
isPublicAPI = false,
silent,
} = otherOptionsForBaseFetch
if (isPublicAPI && code === 'unauthorized' && IS_CE_EDITION) {
if (isPublicAPI && code === 'unauthorized') {
requiredWebSSOLogin()
return Promise.reject(err)
}