mirror of
https://github.com/langgenius/dify.git
synced 2026-05-27 20:36:18 +08:00
Compare commits
6 Commits
fix/webapp
...
feat/cli-e
| Author | SHA1 | Date | |
|---|---|---|---|
| e0e0ae372a | |||
| bc3b1c0c81 | |||
| b734afd609 | |||
| 5646bda88e | |||
| 58b8fc21d4 | |||
| e0ad088657 |
91
.github/workflows/cli-e2e.yml
vendored
Normal file
91
.github/workflows/cli-e2e.yml
vendored
Normal 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
|
||||
@ -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)
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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 = (
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
20
cli/.env.e2e.example
Normal 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
8
cli/.gitignore
vendored
@ -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/
|
||||
|
||||
@ -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",
|
||||
|
||||
0
cli/test/e2e/.env.e2e.local
Normal file
0
cli/test/e2e/.env.e2e.local
Normal file
115
cli/test/e2e/README.md
Normal file
115
cli/test/e2e/README.md
Normal 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. |
|
||||
155
cli/test/e2e/helpers/assert.ts
Normal file
155
cli/test/e2e/helpers/assert.ts
Normal 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)
|
||||
}
|
||||
93
cli/test/e2e/helpers/cleanup-registry.ts
Normal file
93
cli/test/e2e/helpers/cleanup-registry.ts
Normal 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
373
cli/test/e2e/helpers/cli.ts
Normal 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 ?? ''
|
||||
}
|
||||
51
cli/test/e2e/helpers/retry.ts
Normal file
51
cli/test/e2e/helpers/retry.ts
Normal 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))
|
||||
}
|
||||
9
cli/test/e2e/helpers/skip.ts
Normal file
9
cli/test/e2e/helpers/skip.ts
Normal 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
|
||||
}
|
||||
9
cli/test/e2e/helpers/vitest-context.ts
Normal file
9
cli/test/e2e/helpers/vitest-context.ts
Normal 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
109
cli/test/e2e/setup/env.ts
Normal 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'
|
||||
}
|
||||
169
cli/test/e2e/setup/global-setup.ts
Normal file
169
cli/test/e2e/setup/global-setup.ts
Normal 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
|
||||
}
|
||||
15
cli/test/e2e/setup/global-teardown.ts
Normal file
15
cli/test/e2e/setup/global-teardown.ts
Normal 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()
|
||||
}
|
||||
135
cli/test/e2e/suites/auth/devices.e2e.ts
Normal file
135
cli/test/e2e/suites/auth/devices.e2e.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
183
cli/test/e2e/suites/auth/logout.e2e.ts
Normal file
183
cli/test/e2e/suites/auth/logout.e2e.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
180
cli/test/e2e/suites/auth/status.e2e.ts
Normal file
180
cli/test/e2e/suites/auth/status.e2e.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
186
cli/test/e2e/suites/auth/use.e2e.ts
Normal file
186
cli/test/e2e/suites/auth/use.e2e.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
175
cli/test/e2e/suites/auth/whoami.e2e.ts
Normal file
175
cli/test/e2e/suites/auth/whoami.e2e.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
299
cli/test/e2e/suites/config/config.e2e.ts
Normal file
299
cli/test/e2e/suites/config/config.e2e.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
452
cli/test/e2e/suites/run/run-app-basic.e2e.ts
Normal file
452
cli/test/e2e/suites/run/run-app-basic.e2e.ts
Normal 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)
|
||||
}
|
||||
161
cli/test/e2e/suites/run/run-app-file.e2e.ts
Normal file
161
cli/test/e2e/suites/run/run-app-file.e2e.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
142
cli/test/e2e/suites/run/run-app-hitl.e2e.ts
Normal file
142
cli/test/e2e/suites/run/run-app-hitl.e2e.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
125
cli/test/e2e/suites/run/run-app-streaming.e2e.ts
Normal file
125
cli/test/e2e/suites/run/run-app-streaming.e2e.ts
Normal 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
84
cli/vitest.e2e.config.ts
Normal 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'],
|
||||
},
|
||||
})
|
||||
@ -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 />
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user