mirror of
https://github.com/langgenius/dify.git
synced 2026-06-09 01:47:34 +08:00
Compare commits
34 Commits
copilot/di
...
feat/cli-e
| Author | SHA1 | Date | |
|---|---|---|---|
| dbefcedb7f | |||
| 11e464ddae | |||
| 971f7b964b | |||
| 33af0f55c2 | |||
| 2f60dd6ca5 | |||
| ada1da1781 | |||
| 777ba22431 | |||
| 0d66bbefc3 | |||
| 7306fa4c50 | |||
| fe91ccfe5d | |||
| 2d22d87970 | |||
| 56ba8f421a | |||
| 3133b196ad | |||
| 3cc20de830 | |||
| 363aabee73 | |||
| e61073ccd5 | |||
| 748d790a0d | |||
| 0f52c5e6f3 | |||
| d9b928577c | |||
| 400befc451 | |||
| 4649e52384 | |||
| c045e0b635 | |||
| cf7859cbf9 | |||
| 81d2c1638f | |||
| 69923a16e1 | |||
| 7114415cfd | |||
| 6c8ec0b1c8 | |||
| 5ff98b97df | |||
| 982ada6f4e | |||
| e0d5bc48d9 | |||
| e0e0ae372a | |||
| bc3b1c0c81 | |||
| b734afd609 | |||
| 5646bda88e |
415
.github/workflows/cli-e2e.yml
vendored
Normal file
415
.github/workflows/cli-e2e.yml
vendored
Normal file
@ -0,0 +1,415 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cli_ref:
|
||||
description: "Git ref (default: current branch)"
|
||||
type: string
|
||||
required: false
|
||||
|
||||
edition:
|
||||
description: "Dify edition"
|
||||
type: choice
|
||||
required: false
|
||||
default: ee
|
||||
options: [ee, ce]
|
||||
|
||||
test_scope:
|
||||
description: "smoke = [P0] only / full = all cases"
|
||||
type: choice
|
||||
required: false
|
||||
default: full
|
||||
options: [smoke, full]
|
||||
|
||||
# ── Suite on/off ────────────────────────────────────────────────────────
|
||||
suite_framework_output_error:
|
||||
description: "framework + output + error-handling suites"
|
||||
type: boolean
|
||||
default: true
|
||||
suite_discovery:
|
||||
description: "discovery suite (get app / describe app)"
|
||||
type: boolean
|
||||
default: true
|
||||
suite_run:
|
||||
description: "run suite (basic / streaming / conversation / file / hitl)"
|
||||
type: boolean
|
||||
default: true
|
||||
suite_auth:
|
||||
description: "auth suite (login / status / whoami / use / devices / logout)"
|
||||
type: boolean
|
||||
default: true
|
||||
suite_agent:
|
||||
description: "agent suite"
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ── Shared env injected into every E2E job ───────────────────────────────────
|
||||
# Each job reads DIFY_E2E_TOKEN + app IDs from the provision job outputs,
|
||||
# so global-setup skips minting and finds existing apps in < 10 s.
|
||||
env:
|
||||
DIFY_E2E_NO_KEYRING: "1" # Linux CI has no keychain; skip probe
|
||||
VITEST_RETRY: "2" # Retry flaky staging responses
|
||||
|
||||
jobs:
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 0. PROVISION — mint token + import DSL fixtures (runs once, outputs IDs)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
provision:
|
||||
name: "Provision: mint token + DSL apps"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
token: ${{ steps.out.outputs.DIFY_E2E_TOKEN }}
|
||||
workspace_id: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_ID }}
|
||||
workspace_name: ${{ steps.out.outputs.DIFY_E2E_WORKSPACE_NAME }}
|
||||
ws2_id: ${{ steps.out.outputs.DIFY_E2E_WS2_ID }}
|
||||
chat_app_id: ${{ steps.out.outputs.DIFY_E2E_CHAT_APP_ID }}
|
||||
workflow_app_id: ${{ steps.out.outputs.DIFY_E2E_WORKFLOW_APP_ID }}
|
||||
file_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_APP_ID }}
|
||||
file_chat_app_id: ${{ steps.out.outputs.DIFY_E2E_FILE_CHAT_APP_ID }}
|
||||
hitl_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_APP_ID }}
|
||||
hitl_external_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_EXTERNAL_APP_ID }}
|
||||
hitl_single_action_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID }}
|
||||
hitl_multi_node_app_id: ${{ steps.out.outputs.DIFY_E2E_HITL_MULTI_NODE_APP_ID }}
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- 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: Mint token & provision apps
|
||||
id: out
|
||||
working-directory: cli
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
run: bun scripts/e2e-provision.ts
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-B. framework + output + error-handling (parallel with run/discovery)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-framework-output-error:
|
||||
name: "Suite: framework + output + error-handling"
|
||||
if: ${{ inputs.suite_framework_output_error == true }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run framework + output + error-handling
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/framework/**/*.e2e.ts,test/e2e/suites/output/**/*.e2e.ts,test/e2e/suites/error-handling/**/*.e2e.ts"
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
else
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-C. Discovery (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-discovery:
|
||||
name: "Suite: discovery"
|
||||
if: ${{ inputs.suite_discovery == true }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run discovery suite
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/discovery/**/*.e2e.ts"
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
else
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-D. Run suite — 5 files in matrix (parallel)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-run:
|
||||
name: "Suite: run / ${{ matrix.name }}"
|
||||
if: ${{ inputs.suite_run == true }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- name: basic
|
||||
file: run-app-basic.e2e.ts
|
||||
- name: streaming
|
||||
file: run-app-streaming.e2e.ts
|
||||
- name: conversation
|
||||
file: run-app-conversation.e2e.ts
|
||||
- name: file
|
||||
file: run-app-file.e2e.ts
|
||||
- name: hitl
|
||||
file: run-app-hitl.e2e.ts
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: "Run run/${{ matrix.name }}"
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_SSO_TOKEN: ${{ secrets.DIFY_E2E_SSO_TOKEN }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_FILE_APP_ID: ${{ needs.provision.outputs.file_app_id }}
|
||||
DIFY_E2E_FILE_CHAT_APP_ID: ${{ needs.provision.outputs.file_chat_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/run/${{ matrix.file }}"
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
else
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
retention-days: 3
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 1-E. auth/login + status + whoami (parallel, read-only, safe)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-auth-safe:
|
||||
name: "Suite: auth (login / status / whoami)"
|
||||
if: ${{ inputs.suite_auth == true }}
|
||||
needs: provision
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run auth/login + status + whoami
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_INCLUDE: "test/e2e/suites/auth/login.e2e.ts,test/e2e/suites/auth/status.e2e.ts,test/e2e/suites/auth/whoami.e2e.ts"
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
pnpm test:e2e -- -t "\[P0\]"
|
||||
else
|
||||
pnpm test:e2e
|
||||
fi
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 2. DESTRUCTIVE — auth/use + devices + logout + agent (serial, runs LAST)
|
||||
# Must wait for ALL parallel suites to finish to avoid token revocation
|
||||
# invalidating other in-flight requests.
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
suite-last:
|
||||
name: "Suite: auth-use + devices + logout + agent (last, serial)"
|
||||
# Runs when auth is selected; also runs after all parallel jobs finish
|
||||
if: ${{ inputs.suite_auth == true || inputs.suite_agent == true }}
|
||||
needs:
|
||||
- provision
|
||||
- suite-framework-output-error
|
||||
- suite-discovery
|
||||
- suite-run
|
||||
- suite-auth-safe
|
||||
# `needs` on a skipped job is treated as success — safe to proceed even if
|
||||
# some suites were disabled via toggle.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
defaults:
|
||||
run:
|
||||
working-directory: cli
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: ./.github/actions/setup-web
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with: { bun-version: latest }
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with: { package_json_field: packageManager, run_install: false }
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm tree:gen
|
||||
|
||||
- name: Run use / devices / logout / agent (serial)
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_EDITION: ${{ inputs.edition || 'ee' }}
|
||||
DIFY_E2E_TOKEN: ${{ needs.provision.outputs.token }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ needs.provision.outputs.workspace_id }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ needs.provision.outputs.workspace_name }}
|
||||
DIFY_E2E_WS2_ID: ${{ needs.provision.outputs.ws2_id }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ needs.provision.outputs.chat_app_id }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ needs.provision.outputs.workflow_app_id }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ needs.provision.outputs.hitl_app_id }}
|
||||
DIFY_E2E_HITL_EXTERNAL_APP_ID: ${{ needs.provision.outputs.hitl_external_app_id }}
|
||||
DIFY_E2E_HITL_SINGLE_ACTION_APP_ID: ${{ needs.provision.outputs.hitl_single_action_app_id }}
|
||||
DIFY_E2E_HITL_MULTI_NODE_APP_ID: ${{ needs.provision.outputs.hitl_multi_node_app_id }}
|
||||
run: |
|
||||
# Collect files in safe order: use → devices → logout (revokes last) → agent
|
||||
FILES=()
|
||||
if [ "${{ inputs.suite_auth }}" = "true" ]; then
|
||||
FILES+=(
|
||||
test/e2e/suites/auth/use.e2e.ts
|
||||
test/e2e/suites/auth/devices.e2e.ts
|
||||
test/e2e/suites/auth/logout.e2e.ts
|
||||
)
|
||||
fi
|
||||
if [ "${{ inputs.suite_agent }}" = "true" ]; then
|
||||
while IFS= read -r f; do FILES+=("$f"); done \
|
||||
< <(find test/e2e/suites/agent -name '*.e2e.ts' | sort)
|
||||
fi
|
||||
|
||||
[ ${#FILES[@]} -eq 0 ] && { echo "Nothing to run."; exit 0; }
|
||||
|
||||
# Pass files via DIFY_E2E_INCLUDE (comma-separated) so vitest
|
||||
# config's include list is overridden instead of ANDed.
|
||||
INCLUDE=$(IFS=,; echo "${FILES[*]}")
|
||||
if [ "${{ inputs.test_scope }}" = "smoke" ]; then
|
||||
DIFY_E2E_INCLUDE="$INCLUDE" pnpm test:e2e -- -t "\[P0\]"
|
||||
else
|
||||
DIFY_E2E_INCLUDE="$INCLUDE" pnpm test:e2e
|
||||
fi
|
||||
|
||||
- name: Upload results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-last-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
retention-days: 3
|
||||
@ -949,11 +949,6 @@ class AuthConfig(BaseSettings):
|
||||
default=60,
|
||||
)
|
||||
|
||||
DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR: PositiveInt = Field(
|
||||
description="Max device-flow approve requests per session per hour on /openapi/oauth/device/approve.",
|
||||
default=10,
|
||||
)
|
||||
|
||||
|
||||
class ModerationConfig(BaseSettings):
|
||||
"""
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
@ -26,7 +25,6 @@ from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from libs.login import login_required
|
||||
from models import App
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
@ -44,24 +42,6 @@ class InstructionTemplatePayload(BaseModel):
|
||||
type: str = Field(..., description="Instruction template type")
|
||||
|
||||
|
||||
class WorkflowGeneratePayload(BaseModel):
|
||||
"""Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
|
||||
|
||||
See ``services/workflow_generator_service.py`` for behaviour. Errors are
|
||||
surfaced through the same envelope as ``/rule-generate`` so the frontend
|
||||
can reuse its existing handler.
|
||||
"""
|
||||
|
||||
mode: Literal["workflow", "advanced-chat"] = Field(..., description="Target app mode for the generated graph")
|
||||
instruction: str = Field(..., description="Natural-language workflow description")
|
||||
ideal_output: str = Field(default="", description="Optional sample output for grounding")
|
||||
model_config_data: ModelConfig = Field(..., alias="model_config", description="Model configuration")
|
||||
current_graph: dict | None = Field(
|
||||
default=None,
|
||||
description="Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch",
|
||||
)
|
||||
|
||||
|
||||
register_enum_models(console_ns, LLMMode)
|
||||
register_schema_models(
|
||||
console_ns,
|
||||
@ -70,7 +50,6 @@ register_schema_models(
|
||||
RuleStructuredOutputPayload,
|
||||
InstructionGeneratePayload,
|
||||
InstructionTemplatePayload,
|
||||
WorkflowGeneratePayload,
|
||||
ModelConfig,
|
||||
)
|
||||
|
||||
@ -286,56 +265,3 @@ class InstructionGenerationTemplateApi(Resource):
|
||||
return {"data": INSTRUCTION_GENERATE_TEMPLATE_CODE}
|
||||
case _:
|
||||
raise ValueError(f"Invalid type: {args.type}")
|
||||
|
||||
|
||||
@console_ns.route("/workflow-generate")
|
||||
class WorkflowGenerateApi(Resource):
|
||||
"""Generate a Workflow / Chatflow draft graph from a natural-language description.
|
||||
|
||||
Triggered by the cmd+k `/create` slash command. Returns a graph payload
|
||||
shaped exactly like ``WorkflowService.sync_draft_workflow``'s input, so the
|
||||
frontend can hand it straight to ``/apps/{id}/workflows/draft``.
|
||||
"""
|
||||
|
||||
@console_ns.doc("generate_workflow_graph")
|
||||
@console_ns.doc(description="Generate a Dify workflow graph from natural language")
|
||||
@console_ns.expect(console_ns.models[WorkflowGeneratePayload.__name__])
|
||||
@console_ns.response(200, "Workflow graph generated successfully")
|
||||
@console_ns.response(400, "Invalid request parameters")
|
||||
@console_ns.response(402, "Provider quota exceeded")
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@with_current_tenant_id
|
||||
def post(self, current_tenant_id: str):
|
||||
args = WorkflowGeneratePayload.model_validate(console_ns.payload)
|
||||
|
||||
# Reject obviously-empty instructions at the boundary — Pydantic only
|
||||
# validates ``instruction`` is a str, but a whitespace-only string
|
||||
# would still hit the LLM and waste a planner+builder roundtrip on a
|
||||
# response that the postprocess validator would reject anyway.
|
||||
if not args.instruction.strip():
|
||||
return {
|
||||
"error": "Instruction is required",
|
||||
"errors": [{"code": "EMPTY_INSTRUCTION", "detail": "Instruction is required"}],
|
||||
}, 400
|
||||
|
||||
try:
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id=current_tenant_id,
|
||||
mode=args.mode,
|
||||
instruction=args.instruction,
|
||||
model_config=args.model_config_data,
|
||||
ideal_output=args.ideal_output,
|
||||
current_graph=args.current_graph,
|
||||
)
|
||||
except ProviderTokenNotInitError as ex:
|
||||
raise ProviderNotInitializeError(ex.description)
|
||||
except QuotaExceededError:
|
||||
raise ProviderQuotaExceededError()
|
||||
except ModelCurrentlyNotSupportError:
|
||||
raise ProviderModelCurrentlyNotSupportError()
|
||||
except InvokeError as e:
|
||||
raise CompletionRequestError(e.description)
|
||||
|
||||
return result
|
||||
|
||||
@ -49,8 +49,8 @@ from extensions.ext_redis import redis_client
|
||||
from libs.helper import extract_remote_ip
|
||||
from libs.oauth_bearer import MINTABLE_PROFILES, SubjectType, bearer_feature_required
|
||||
from libs.rate_limit import (
|
||||
LIMIT_APPROVE_CONSOLE,
|
||||
LIMIT_DEVICE_CODE_PER_IP,
|
||||
LIMIT_DEVICE_FLOW_APPROVE,
|
||||
LIMIT_LOOKUP_PUBLIC,
|
||||
rate_limit,
|
||||
)
|
||||
@ -210,7 +210,7 @@ class DeviceApproveApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@bearer_feature_required
|
||||
@rate_limit(LIMIT_DEVICE_FLOW_APPROVE)
|
||||
@rate_limit(LIMIT_APPROVE_CONSOLE)
|
||||
@with_current_user
|
||||
@with_current_tenant_id
|
||||
def post(self, tenant: str, account: Account):
|
||||
@ -287,7 +287,7 @@ class DeviceDenyApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@bearer_feature_required
|
||||
@rate_limit(LIMIT_DEVICE_FLOW_APPROVE)
|
||||
@rate_limit(LIMIT_APPROVE_CONSOLE)
|
||||
def post(self):
|
||||
payload = _validate_json(DeviceMutateRequest)
|
||||
user_code = payload.user_code.strip().upper()
|
||||
|
||||
@ -392,7 +392,7 @@ Here is the extra instruction you need to follow:
|
||||
else:
|
||||
if len(messages[-1]) + len(line) < max_tokens * 0.5:
|
||||
messages[-1] += line
|
||||
elif get_prompt_tokens(messages[-1] + line) > max_tokens * 0.7:
|
||||
if get_prompt_tokens(messages[-1] + line) > max_tokens * 0.7:
|
||||
messages.append(line)
|
||||
else:
|
||||
messages[-1] += line
|
||||
|
||||
@ -135,7 +135,7 @@ class BuiltinTool(Tool):
|
||||
else:
|
||||
if len(messages[-1]) + len(j) < max_tokens * 0.5:
|
||||
messages[-1] += j
|
||||
elif get_prompt_tokens(messages[-1] + j) > max_tokens * 0.7:
|
||||
if get_prompt_tokens(messages[-1] + j) > max_tokens * 0.7:
|
||||
messages.append(j)
|
||||
else:
|
||||
messages[-1] += j
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
"""
|
||||
Workflow generator package.
|
||||
|
||||
Generates a Dify workflow graph (nodes, edges, viewport) from a natural-language
|
||||
instruction. Intended for the cmd+k `/create` slash command's preview/apply flow.
|
||||
|
||||
Pipeline (slim, single-shot variant):
|
||||
|
||||
runner.WorkflowGenerator.generate_workflow_graph(...)
|
||||
├── planner_prompts: short LLM call → high-level node plan
|
||||
└── builder_prompts: structured-output LLM call → full graph JSON
|
||||
└── postprocess: fill defaults, auto-layout viewport, sanity-check edges
|
||||
|
||||
The runner is pure domain logic; ``WorkflowGeneratorService`` (in ``services/``)
|
||||
owns the model-manager dependency and is what controllers call.
|
||||
"""
|
||||
|
||||
from .runner import WorkflowGenerator
|
||||
|
||||
__all__ = ["WorkflowGenerator"]
|
||||
@ -1 +0,0 @@
|
||||
"""Prompt templates for the workflow generator (planner + builder)."""
|
||||
@ -1,553 +0,0 @@
|
||||
"""
|
||||
Builder prompts.
|
||||
|
||||
The builder is the second step of the slim planner→builder pipeline. It takes
|
||||
the planner's high-level node list and emits the *full* graph JSON consumed by
|
||||
``WorkflowService.sync_draft_workflow``.
|
||||
|
||||
The builder owns: node configuration (prompts, code, headers, etc.), edge wiring,
|
||||
handle ids ("source"/"target"), positions, and the viewport. It is the only
|
||||
prompt that needs to know the concrete shape of each node type — keep its
|
||||
examples accurate or the LLM will invent fields.
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
# Per-node-type configuration cheatsheet.
|
||||
#
|
||||
# Each entry mirrors the production ``defaultValue`` from
|
||||
# ``web/app/components/workflow/nodes/<type>/default.ts`` so the generated
|
||||
# graph loads in Studio identically to a manually-created node and survives
|
||||
# both ``WorkflowService.sync_draft_workflow``'s structural checks and the
|
||||
# runtime entity validation each node performs when the workflow runs.
|
||||
#
|
||||
# The postprocessor in ``runner.py`` fills missing wrapper fields (``type``,
|
||||
# ``positionAbsolute``, ``width``, ``height``, ``sourcePosition`` /
|
||||
# ``targetPosition``, edge ``data.sourceType`` / ``data.targetType``), so the
|
||||
# LLM only needs to emit semantically meaningful fields.
|
||||
NODE_CONFIG_CHEATSHEET = """\
|
||||
## Node wrapper (every node, top-level)
|
||||
|
||||
{"id": "node1" (digits + letters only — see "Node IDs" below),
|
||||
"type": "custom", # ReactFlow renderer key. Iteration/loop
|
||||
# *start* children use special types
|
||||
# (see Containers below).
|
||||
"position": {"x": <number>, "y": <number>},
|
||||
"data": { ... per-type fields ... }}
|
||||
|
||||
Children of iteration / loop containers additionally need
|
||||
``parentId``, ``zIndex: 1002`` and ``extent: "parent"`` — see Containers.
|
||||
|
||||
## Shared "data" fields (every node)
|
||||
|
||||
{"type": "<node-type>", # e.g. "llm", "start", "if-else"
|
||||
"title": "<short label>",
|
||||
"desc": "<one-liner>",
|
||||
"selected": false}
|
||||
|
||||
## Per type — additional "data" fields
|
||||
|
||||
- start:
|
||||
{"variables": [
|
||||
{"variable": "url", "label": "URL", "type": "text-input",
|
||||
"required": true, "max_length": 256, "options": []},
|
||||
{"variable": "topic", "label": "Topic", "type": "paragraph",
|
||||
"required": false, "max_length": 4096, "options": []}
|
||||
]}
|
||||
EVERY user-supplied value referenced by a downstream node
|
||||
(``{{#node-id.var#}}`` in a prompt / answer / template, or
|
||||
``["node-id", "var"]`` in a value_selector / iterator_selector /
|
||||
tool_parameters) MUST be declared here as an entry of ``variables``.
|
||||
If the planner's ``start_inputs`` list is non-empty, use it verbatim
|
||||
(the user prompt section "Start inputs" surfaces it). Types:
|
||||
text-input | paragraph | select | number | file | file-list.
|
||||
In Advanced-Chat mode ``sys.query`` and ``sys.files`` are automatic
|
||||
system variables — downstream nodes may reference them; do NOT add
|
||||
them to ``variables``.
|
||||
|
||||
- end (Workflow mode only):
|
||||
{"outputs": [
|
||||
{"variable": "result", "value_selector": ["<src-node-id>", "<out-var>"]}
|
||||
]}
|
||||
|
||||
- answer (Advanced Chat mode only):
|
||||
{"variables": [],
|
||||
"answer": "<text with {{#<src>.<var>#}} placeholders>"}
|
||||
|
||||
- llm:
|
||||
{"model": {"provider": "<provider>", "name": "<model>", "mode": "chat",
|
||||
"completion_params": {"temperature": 0.7}},
|
||||
"prompt_template": [
|
||||
{"role": "system", "text": "<system prompt>"},
|
||||
{"role": "user", "text": "<user prompt with {{#<src>.<var>#}}>"}
|
||||
],
|
||||
"context": {"enabled": false, "variable_selector": []},
|
||||
"vision": {"enabled": false}}
|
||||
|
||||
Prompt-writing rules for the user-message text:
|
||||
* ``{{#node.var#}}`` placeholders are interpolated by Dify BEFORE the
|
||||
LLM sees them — at run time the model only sees the resolved value.
|
||||
So an instruction like "Translate this: {{#node1.text#}}" is read
|
||||
by the LLM as "Translate this: <the actual text>".
|
||||
* NEVER include placeholder syntax inside an "example output" block
|
||||
in your prompt — the LLM will treat the example as the literal
|
||||
answer template and echo placeholders back as output. Wrong:
|
||||
Output JSON: {"en": "{{#node1.text#}}", "es": "{{#node1.text#}}"}
|
||||
Right:
|
||||
Translate the input into English, Spanish, French, German.
|
||||
Output a JSON object with keys "en", "es", "fr", "de" whose
|
||||
values are the translations.
|
||||
Input: {{#node1.text#}}
|
||||
* Each placeholder only resolves the variable from its source node —
|
||||
it cannot be a Jinja template or call a function.
|
||||
|
||||
- knowledge-retrieval:
|
||||
{"query_variable_selector": ["<src>", "<var>"],
|
||||
"query_attachment_selector": [],
|
||||
"dataset_ids": [],
|
||||
"retrieval_mode": "multiple",
|
||||
"multiple_retrieval_config": {"top_k": 4, "score_threshold": null,
|
||||
"reranking_enable": false}}
|
||||
|
||||
- code (escape hatch — only if no installed tool fits):
|
||||
{"code_language": "python3",
|
||||
"code": "def main(arg1: str) -> dict:\\n return {'result': arg1}",
|
||||
"variables": [{"variable": "arg1", "value_selector": ["<src>", "<var>"]}],
|
||||
"outputs": {"result": {"type": "string", "children": null}}}
|
||||
|
||||
- template-transform:
|
||||
{"template": "Hello {{ name }}",
|
||||
"variables": [{"variable": "name", "value_selector": ["<src>", "<var>"]}]}
|
||||
|
||||
- http-request (escape hatch — only if no installed tool fits):
|
||||
{"variables": [], "method": "get", "url": "https://example.com",
|
||||
"authorization": {"type": "no-auth", "config": null},
|
||||
"headers": "", "params": "",
|
||||
"body": {"type": "none", "data": []},
|
||||
"ssl_verify": true,
|
||||
"timeout": {"max_connect_timeout": 0, "max_read_timeout": 0,
|
||||
"max_write_timeout": 0},
|
||||
"retry_config": {"retry_enabled": true, "max_retries": 3,
|
||||
"retry_interval": 100}}
|
||||
|
||||
- tool (PREFERRED for external actions when listed in Available tools):
|
||||
{"provider_id": "<provider>", # provider portion of provider/tool
|
||||
"provider_type": "builtin", # exact value from catalogue
|
||||
"provider_name": "<provider>", # usually same as provider_id
|
||||
"tool_name": "<tool>", # tool portion of provider/tool
|
||||
"tool_label": "<Tool>",
|
||||
"tool_node_version": "2",
|
||||
"tool_configurations": {},
|
||||
"tool_parameters": {"<param>": {"type": "mixed",
|
||||
"value": "{{#<src>.<var>#}}"}}}
|
||||
Parameter ``type`` is one of:
|
||||
"mixed" — string template referencing variables ({{#...#}})
|
||||
"variable" — direct reference, value is ["<src>", "<var>"]
|
||||
"constant" — literal value
|
||||
|
||||
- if-else:
|
||||
{"_targetBranches": [{"id": "true", "name": "IF"},
|
||||
{"id": "false", "name": "ELSE"}],
|
||||
"logical_operator": "and",
|
||||
"cases": [
|
||||
{"case_id": "true",
|
||||
"logical_operator": "and",
|
||||
"conditions": [{"id": "c1",
|
||||
"variable_selector": ["<src>", "<var>"],
|
||||
"comparison_operator": "is",
|
||||
"value": "<value>"}]}
|
||||
]}
|
||||
Source handle for downstream edges = the case_id ("true" / "false").
|
||||
|
||||
- question-classifier:
|
||||
{"query_variable_selector": ["<src>", "<var>"],
|
||||
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
|
||||
"completion_params": {"temperature": 0.7}},
|
||||
"classes": [{"id": "1", "name": "Topic A", "label": "CLASS 1"},
|
||||
{"id": "2", "name": "Topic B", "label": "CLASS 2"}],
|
||||
"_targetBranches": [{"id": "1", "name": ""}, {"id": "2", "name": ""}],
|
||||
"vision": {"enabled": false},
|
||||
"instruction": ""}
|
||||
Source handle for downstream edges = the class_id ("1" / "2" / ...).
|
||||
|
||||
- parameter-extractor:
|
||||
{"query": [["<src>", "<var>"]], # array of value_selector arrays
|
||||
"model": {"provider": "<p>", "name": "<m>", "mode": "chat",
|
||||
"completion_params": {"temperature": 0.7}},
|
||||
"parameters": [{"name": "topic", "type": "string",
|
||||
"description": "<purpose>", "required": true}],
|
||||
"reasoning_mode": "prompt",
|
||||
"vision": {"enabled": false},
|
||||
"instruction": ""}
|
||||
|
||||
- document-extractor:
|
||||
{"variable_selector": ["<src>", "<file-var>"], # a file / file-list input
|
||||
"is_array_file": false} # true when the input is a
|
||||
# file-list (array of files)
|
||||
Single output variable ``text``: a string when ``is_array_file`` is false,
|
||||
an array of strings (one per file) when it is true. ``variable_selector``
|
||||
MUST point at a ``start`` variable declared with type "file" / "file-list"
|
||||
(or ``sys.files`` in Advanced-Chat mode).
|
||||
|
||||
- variable-aggregator (merge mutually-exclusive branches into one output):
|
||||
{"output_type": "string", # VarType of the merged value — one of
|
||||
# string | number | object | array[string] |
|
||||
# array[number] | array[object] | file |
|
||||
# array[file] | any. Match the branch vars.
|
||||
"variables": [["<branchA-node>", "<var>"],
|
||||
["<branchB-node>", "<var>"]]}
|
||||
Output variable: ``output`` (the first branch that actually ran). Place it
|
||||
after an ``if-else`` / ``question-classifier`` to rejoin paths before the
|
||||
``end`` / ``answer`` node. Each entry of ``variables`` is a value_selector
|
||||
array, NOT a placeholder string.
|
||||
|
||||
- list-operator (filter / sort / slice an array variable):
|
||||
{"variable": ["<src>", "<array-var>"],
|
||||
"filter_by": {"enabled": false, "conditions": []},
|
||||
"extract_by": {"enabled": false, "serial": "1"},
|
||||
"order_by": {"enabled": false, "key": "", "value": "asc"},
|
||||
"limit": {"enabled": false, "size": 10}}
|
||||
Enable only the sub-features you need; ``conditions`` reuse the if-else
|
||||
condition shape (key / comparison_operator / value). Outputs: ``result``
|
||||
(the processed array), ``first_record``, ``last_record``.
|
||||
|
||||
## Containers — iteration / loop
|
||||
|
||||
These are SUBGRAPH nodes. To use one you MUST emit, in order:
|
||||
|
||||
1. The container node itself, e.g. for iteration:
|
||||
id: "nodeK"
|
||||
type: "custom"
|
||||
data: {"type": "iteration",
|
||||
"title": "<label>",
|
||||
"desc": "",
|
||||
"selected": false,
|
||||
"start_node_id": "nodeKstart",
|
||||
"iterator_selector": ["<src>", "<list-var>"],
|
||||
"output_selector": ["<inner-last-node>", "<out-var>"],
|
||||
"is_parallel": false,
|
||||
"parallel_nums": 10,
|
||||
"error_handle_mode": "terminated",
|
||||
"flatten_output": true}
|
||||
width: 808
|
||||
height: 204
|
||||
zIndex: 1
|
||||
|
||||
For loop, swap "iteration" → "loop" and use:
|
||||
data: {"type": "loop", "title": "...", "desc": "",
|
||||
"selected": false, "start_node_id": "nodeKstart",
|
||||
"break_conditions": [], "loop_count": 10,
|
||||
"logical_operator": "and"}
|
||||
|
||||
2. The auto-start child (one per container):
|
||||
id: "nodeKstart"
|
||||
type: "custom-iteration-start" # loop → "custom-loop-start"
|
||||
parentId: "nodeK"
|
||||
extent: "parent"
|
||||
draggable: false
|
||||
selectable: false
|
||||
zIndex: 1002
|
||||
position: {"x": 60, "y": 78} # relative to parent
|
||||
data: {"type": "iteration-start", # loop → "loop-start"
|
||||
"title": "", "desc": "",
|
||||
"isInIteration": true, # loop → "isInLoop": true
|
||||
"selected": false}
|
||||
|
||||
3. Each inner-pipeline node (any node type, follows normal data rules) MUST add:
|
||||
parentId: "nodeK"
|
||||
extent: "parent"
|
||||
zIndex: 1002
|
||||
position: {x, y} # relative to parent
|
||||
data: {..., "isInIteration": true, # loop → "isInLoop": true
|
||||
"iteration_id": "nodeK"} # loop → "loop_id"
|
||||
|
||||
4. Edges INSIDE a container must add to ``data``:
|
||||
"isInIteration": true # loop → "isInLoop": true
|
||||
"iteration_id": "nodeK" # loop → "loop_id"
|
||||
and use ``zIndex: 1002``. Edges OUTSIDE containers use the default
|
||||
``isInIteration: false`` / ``isInLoop: false``.
|
||||
|
||||
5. The container's incoming/outgoing edges connect to the container's id
|
||||
(``nodeK``), NOT to inner nodes. The first inner edge connects from
|
||||
``nodeKstart``.
|
||||
|
||||
## Edge handles
|
||||
|
||||
- Most nodes: sourceHandle "source", targetHandle "target".
|
||||
- if-else cases: sourceHandle is the case_id ("true" / "false" / ...).
|
||||
- question-classifier: sourceHandle is the class_id ("1" / "2" / ...).
|
||||
- iteration-start / sourceHandle "source"; the edge from the *start node
|
||||
loop-start: is what kicks off the first inner step.
|
||||
"""
|
||||
|
||||
|
||||
_BASE_SYSTEM_PROMPT_HEAD = """You are a Dify workflow builder.
|
||||
|
||||
You are given:
|
||||
1. A user instruction (what the workflow should do).
|
||||
2. A node plan from the planner (which nodes to use, in execution order).
|
||||
|
||||
Your job: emit a complete Dify workflow graph as JSON. The graph will be written
|
||||
directly into a Studio draft, so it must be syntactically valid and structurally
|
||||
correct.
|
||||
|
||||
# Hard rules
|
||||
|
||||
1. The output is a single JSON object — no prose, no Markdown, no code fences.
|
||||
2. NODE IDs MUST USE ONLY ALPHANUMERICS + UNDERSCORES — never hyphens.
|
||||
Dify's run-time placeholder regex (see ``variable_pool.VARIABLE_PATTERN``)
|
||||
is ``\\{\\{#([a-zA-Z0-9_]{1,50}(?:\\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\\}\\}``,
|
||||
so any placeholder pointing at a hyphenated id (e.g. ``{{#node-1.text#}}``)
|
||||
silently fails to match at run time and the literal string survives into
|
||||
the prompt — the user then sees ``{{#node-1.text#}}`` in their output.
|
||||
Use the EXACT ids from the plan, formatted as ``node1``, ``node2``, ... in
|
||||
plan order. Edge ``source`` / ``target`` must reference these ids.
|
||||
3. Every node has top-level fields: id, type, position, data.
|
||||
- "type" is always "custom" (ReactFlow node renderer).
|
||||
- "data.type" is the actual node type ("llm", "start", etc.).
|
||||
4. Every edge has top-level fields: id, source, target, type, sourceHandle, targetHandle.
|
||||
- "type" is always "custom".
|
||||
- "sourceHandle"/"targetHandle" follow the cheatsheet (default: "source"/"target").
|
||||
- Edge id format: "<source>-<sourceHandle>-<target>-<targetHandle>".
|
||||
5. Use the model from the planner context for ALL "llm" / "question-classifier" /
|
||||
"parameter-extractor" nodes (provider, name, mode, completion_params).
|
||||
6. Reference upstream outputs with the literal placeholder syntax
|
||||
``{{#<node-id>.<output-var>#}}`` — that's DOUBLE curly braces with ``#``
|
||||
markers inside (matching Dify's runtime placeholder regex
|
||||
``\\{\\{#[^#]+#\\}\\}``). NEVER emit single-brace ``{#…#}`` — Dify will
|
||||
not interpolate it, so the LLM at run time would see the literal
|
||||
placeholder string in its prompt and echo it back as output. Use
|
||||
``["<node-id>", "<output-var>"]`` for ``value_selector`` /
|
||||
``query_variable_selector`` / etc.
|
||||
7. The "start" node owns input variables; downstream nodes reference them as
|
||||
``["<start-node-id>", "<var-name>"]`` for selectors or
|
||||
``{{#<start-node-id>.<var-name>#}}`` inside prompt strings.
|
||||
8. NEVER emit "code" or "http-request" nodes if a tool from the "Available tools"
|
||||
section below covers the same task — replace them with a "tool" node referencing
|
||||
the exact provider/tool identifier from the catalogue. "code" / "http-request"
|
||||
are last-resort escape hatches for arbitrary transformations and APIs that no
|
||||
installed tool can express.
|
||||
9. EVERY variable reference MUST resolve to a real, declared variable on the
|
||||
source node — never invent a variable name. Specifically:
|
||||
- ``{{#<node-id>.<var>#}}`` inside a prompt / ``answer`` / ``template-transform``
|
||||
template (DOUBLE braces — single ``{#…#}`` is NOT a Dify placeholder
|
||||
and will NOT be substituted), AND ``["<node-id>", "<var>"]`` inside a
|
||||
``value_selector`` /
|
||||
``query_variable_selector`` / ``iterator_selector`` / ``output_selector`` /
|
||||
``tool_parameters[*].value`` (when ``type: "variable"``), MUST point at a
|
||||
value that the source node actually exposes:
|
||||
* ``start`` → one of the ``data.variables[*].variable`` entries you
|
||||
declared on the start node. Add an entry if you need a new input.
|
||||
* ``llm`` → ``text`` (the default LLM output) or, when structured
|
||||
output is enabled, a key from its schema.
|
||||
* ``code`` → a key in ``data.outputs``.
|
||||
* ``knowledge-retrieval`` → ``result`` (the standard array output).
|
||||
* ``parameter-extractor`` → one of the ``data.parameters[*].name``.
|
||||
* ``document-extractor`` → ``text`` (extracted file text; an array of
|
||||
strings when ``is_array_file`` is true).
|
||||
* ``variable-aggregator`` → ``output``.
|
||||
* ``list-operator`` → ``result`` (array), ``first_record``,
|
||||
``last_record``.
|
||||
* ``tool`` → any parameter declared by the tool — the run time
|
||||
validates these, so you can name them freely, but pick from the
|
||||
documented provider/tool.
|
||||
If the planner's "Start inputs" list (see user prompt) is non-empty,
|
||||
copy each entry verbatim into ``start.data.variables`` so the
|
||||
downstream references resolve.
|
||||
- In Advanced-Chat mode you may also reference ``sys.query`` and
|
||||
``sys.files`` without declaring them.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
_BASE_SYSTEM_PROMPT_TAIL = """\
|
||||
|
||||
# Layout
|
||||
|
||||
- Place nodes left-to-right with x=80 + 320 * index, y=280.
|
||||
- Viewport: {"x": 0, "y": 0, "zoom": 0.7}.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
_BASE_SYSTEM_PROMPT_FOOTER = """
|
||||
|
||||
# Output schema
|
||||
|
||||
{
|
||||
"nodes": [...],
|
||||
"edges": [...],
|
||||
"viewport": {"x": 0, "y": 0, "zoom": 0.7}
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
_WORKFLOW_MODE_RULES = """# Mode-specific rules — Workflow
|
||||
|
||||
- The graph MUST start with exactly one "start" node and end with exactly one "end" node.
|
||||
- Do NOT use "answer" nodes (those are for Advanced Chat only).
|
||||
- The "end" node's outputs[].value_selector must point at a real upstream output.
|
||||
"""
|
||||
|
||||
|
||||
_ADVANCED_CHAT_MODE_RULES = """# Mode-specific rules — Advanced Chat (Chatflow)
|
||||
|
||||
- The graph MUST start with exactly one "start" node and end with exactly one "answer" node.
|
||||
- Do NOT use "end" nodes (those are for plain Workflow apps).
|
||||
- The "start" node should expose "sys.query" / "sys.files" automatically; user-defined
|
||||
variables go in start.data.variables.
|
||||
- The "answer" node's "answer" field references upstream outputs as
|
||||
{{#<node-id>.<var>#}} and is what the user sees in chat.
|
||||
"""
|
||||
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW = (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ _WORKFLOW_MODE_RULES
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ NODE_CONFIG_CHEATSHEET
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT = (
|
||||
_BASE_SYSTEM_PROMPT_HEAD
|
||||
+ _ADVANCED_CHAT_MODE_RULES
|
||||
+ _BASE_SYSTEM_PROMPT_TAIL
|
||||
+ NODE_CONFIG_CHEATSHEET
|
||||
+ _BASE_SYSTEM_PROMPT_FOOTER
|
||||
)
|
||||
|
||||
|
||||
BUILDER_USER_PROMPT = """# User instruction
|
||||
|
||||
{instruction}
|
||||
|
||||
{ideal_output_section}\
|
||||
{existing_graph_section}\
|
||||
# Selected model (use for all LLM-based nodes)
|
||||
|
||||
provider={provider}, name={name}, mode={mode_label}
|
||||
|
||||
{tool_catalogue_section}\
|
||||
{start_inputs_section}\
|
||||
# Node plan (from planner — use these labels and node_types in this order)
|
||||
|
||||
{plan_block}
|
||||
|
||||
Now emit the complete workflow graph JSON.
|
||||
"""
|
||||
|
||||
|
||||
def format_builder_existing_graph_section(current_graph: dict | None) -> str:
|
||||
"""
|
||||
Refine mode: give the builder the FULL existing graph JSON so it can keep
|
||||
every node and edge the user's change does not touch byte-for-byte — same
|
||||
ids, same config, same prompt templates. Without the full config the
|
||||
builder would regenerate untouched nodes from scratch and silently drop
|
||||
the user's hand-tuned settings.
|
||||
|
||||
Returns an empty string in create mode (no ``current_graph``); the builder
|
||||
then behaves exactly as before, constructing the graph purely from the
|
||||
planner's node plan.
|
||||
"""
|
||||
if not current_graph:
|
||||
return ""
|
||||
graph_json = json.dumps(current_graph, ensure_ascii=False, separators=(",", ":"))
|
||||
return (
|
||||
"# Existing graph to refine (JSON)\n\n"
|
||||
"You are REFINING this existing graph, NOT building from scratch. Apply "
|
||||
"ONLY the change the user instruction describes. Every node and edge the "
|
||||
"change does not affect MUST be preserved verbatim — keep the same node "
|
||||
"ids, the same `data` config, and the same prompt templates. The node "
|
||||
"plan below is the target node set after your change; use the existing "
|
||||
"graph as the source of truth for the config of nodes that carry over.\n\n"
|
||||
f"```json\n{graph_json}\n```\n\n"
|
||||
)
|
||||
|
||||
|
||||
def format_start_inputs_section(start_inputs: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Surface the planner's ``start_inputs`` list to the builder so it can
|
||||
populate ``start.data.variables`` with the exact set of inputs every
|
||||
downstream variable reference will need. Empty list → empty section,
|
||||
because the builder may then declare no input variables (e.g. an
|
||||
Advanced-Chat workflow that only consumes ``sys.query``).
|
||||
"""
|
||||
if not start_inputs:
|
||||
return ""
|
||||
lines = ["# Start inputs (copy each entry verbatim into start.data.variables)"]
|
||||
lines.append("")
|
||||
for inp in start_inputs:
|
||||
variable = str(inp.get("variable") or "").strip()
|
||||
label = str(inp.get("label") or "").strip()
|
||||
type_ = str(inp.get("type") or "paragraph").strip()
|
||||
if not variable:
|
||||
continue
|
||||
lines.append(f"- variable={variable!r} label={label!r} type={type_!r}")
|
||||
lines.append("")
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def format_builder_tool_catalogue_section(catalogue_text: str) -> str:
|
||||
"""
|
||||
Builder-facing catalogue block. The builder needs the same identifiers
|
||||
the planner saw, plus a stern reminder that ``tool`` nodes MUST set
|
||||
``provider_id`` / ``provider_name`` / ``tool_name`` to entries that
|
||||
actually exist in this list — hallucinated tools fail at draft sync.
|
||||
"""
|
||||
if not catalogue_text.strip():
|
||||
return ""
|
||||
return (
|
||||
"# Available tools (use these exact provider/tool identifiers — "
|
||||
"for each 'tool' node, set provider_id and provider_name to the "
|
||||
"provider portion and tool_name to the tool portion)\n\n"
|
||||
f"{catalogue_text}\n\n"
|
||||
)
|
||||
|
||||
|
||||
def format_plan_block(plan_nodes: list[dict[str, Any]]) -> str:
|
||||
"""
|
||||
Render the planner output as a numbered list the builder can quote.
|
||||
|
||||
Node IDs use no separator (``node1``, ``node2``, ...) because Dify's
|
||||
run-time placeholder regex requires ``[a-zA-Z0-9_]`` in the node-id
|
||||
slot — a hyphenated id like ``node-1`` would silently fail to match
|
||||
at run time and the literal ``{{#node-1.var#}}`` survives into the
|
||||
LLM prompt.
|
||||
|
||||
For container children (planner emitted a ``"parent": "<label>"`` key),
|
||||
we resolve the parent label to its ``nodeN`` id and surface it on the
|
||||
same line so the builder knows to set ``parentId`` and the
|
||||
``isInIteration`` / ``isInLoop`` markers on inner nodes.
|
||||
"""
|
||||
# First pass: label → node-id so we can resolve "parent" hints.
|
||||
label_to_id: dict[str, str] = {}
|
||||
for idx, node in enumerate(plan_nodes, start=1):
|
||||
label = str(node.get("label") or "")
|
||||
if label and label not in label_to_id:
|
||||
label_to_id[label] = f"node{idx}"
|
||||
|
||||
lines = []
|
||||
for idx, node in enumerate(plan_nodes, start=1):
|
||||
node_id = f"node{idx}"
|
||||
label = node.get("label", "")
|
||||
node_type = node.get("node_type", "")
|
||||
purpose = node.get("purpose", "")
|
||||
parent_label = str(node.get("parent") or "")
|
||||
parent_clause = ""
|
||||
if parent_label:
|
||||
parent_id = label_to_id.get(parent_label, "")
|
||||
if parent_id:
|
||||
parent_clause = f" parent={parent_id}"
|
||||
else:
|
||||
parent_clause = f" parent={parent_label!r}"
|
||||
lines.append(f"{idx}. id={node_id} type={node_type} label={label!r}{parent_clause}\n purpose: {purpose}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def get_builder_system_prompt(mode: str) -> str:
|
||||
"""Pick the system prompt branch for Workflow vs Advanced Chat."""
|
||||
if mode == "advanced-chat":
|
||||
return BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
|
||||
return BUILDER_SYSTEM_PROMPT_WORKFLOW
|
||||
@ -1,189 +0,0 @@
|
||||
"""
|
||||
Planner prompts.
|
||||
|
||||
The planner is the lightweight first step in the slim planner→builder pipeline.
|
||||
It receives the user's natural-language instruction and emits a high-level
|
||||
node plan in JSON. The builder later turns that plan into the final graph.
|
||||
|
||||
We keep the planner deliberately short — the heavy lifting (config schemas,
|
||||
edge wiring, default values) belongs in the builder. The planner only commits
|
||||
to the *which-node-types* decision so the builder gets a tight scaffold.
|
||||
"""
|
||||
|
||||
PLANNER_SYSTEM_PROMPT = """You are a Dify workflow planner.
|
||||
|
||||
Given a user's natural-language description of an automation, you choose the
|
||||
minimum set of Dify workflow nodes needed to fulfil it, in execution order.
|
||||
|
||||
# Available node types
|
||||
|
||||
- "start" — workflow entry point. Always present. Holds input form variables.
|
||||
- "end" — workflow exit point (Workflow mode only). Returns variables.
|
||||
- "answer" — chat reply (Advanced Chat mode only). Streams a message.
|
||||
- "llm" — call an LLM with a prompt.
|
||||
- "knowledge-retrieval" — query Dify knowledge bases.
|
||||
- "code" — run a Python/JavaScript snippet.
|
||||
- "template-transform" — Jinja2 string templating.
|
||||
- "http-request" — call an external HTTP API.
|
||||
- "tool" — call a Dify built-in / plugin tool (e.g. web search, time, audio).
|
||||
- "if-else" — conditional branch on a value.
|
||||
- "iteration" — run a sub-pipeline over each item of a list (parallel-friendly map).
|
||||
- "loop" — repeat a sub-pipeline until an exit condition is met.
|
||||
- "question-classifier" — route to a labelled branch based on free-text intent.
|
||||
- "parameter-extractor" — extract structured params from free text using LLM.
|
||||
- "document-extractor" — extract plain text from uploaded files (PDF, Word, PPT,
|
||||
Markdown, etc.). Feed its "text" output into an "llm" /
|
||||
"code" node. Requires a "file" or "file-list" input.
|
||||
- "variable-aggregator" — merge several branch outputs into one "output" variable;
|
||||
use after "if-else" / "question-classifier" to rejoin
|
||||
mutually-exclusive paths before "end" / "answer".
|
||||
- "list-operator" — filter / sort / slice an array variable (e.g. the items
|
||||
fed into or produced by an "iteration").
|
||||
|
||||
# Rules
|
||||
|
||||
1. Always start with exactly one "start" node.
|
||||
2. End with exactly one "end" (Workflow mode) or "answer" (Advanced Chat mode).
|
||||
3. Keep it minimal — prefer 3–6 nodes for simple flows. Do NOT add nodes "just in case".
|
||||
4. For COMPLEX scenes, reach for control-flow nodes instead of stuffing logic into
|
||||
prompts:
|
||||
- branching / mutually-exclusive paths → "if-else" (deterministic value check) or
|
||||
"question-classifier" (semantic / intent routing)
|
||||
- "for each item in a list" → "iteration"
|
||||
- "keep going until condition" → "loop"
|
||||
5. PREFER "tool" over "http-request" or "code" whenever an installed tool from the
|
||||
"Available tools" section below covers the task (e.g. web search, time lookup,
|
||||
scraping, audio, translation, etc.). Only fall back to "http-request" for
|
||||
arbitrary external APIs not provided by any installed tool, and to "code" for
|
||||
genuine data transformations no tool can express.
|
||||
6. Each node "label" must be a short, human-readable, Title-Case name (≤ 25 chars).
|
||||
7. Each node "purpose" is one sentence explaining what it does in this workflow.
|
||||
For "tool" nodes, name the chosen tool inside the purpose, e.g.
|
||||
"Search the web using google/search.".
|
||||
8. For "iteration" and "loop" nodes (containers), list the container node first
|
||||
and then EACH inner-pipeline step as its own entry tagged with
|
||||
``"parent": "<container-label>"``. Container children execute in declaration
|
||||
order from the container's auto-generated start node. Example:
|
||||
{"label": "Per Item", "node_type": "iteration", "purpose": "..."},
|
||||
{"label": "Summarize Item", "node_type": "llm", "purpose": "...",
|
||||
"parent": "Per Item"},
|
||||
{"label": "Store Item", "node_type": "code", "purpose": "...",
|
||||
"parent": "Per Item"}
|
||||
Nodes without a ``"parent"`` are top-level.
|
||||
9. Pick a short, human-readable ``app_name`` (≤ 30 chars, Title Case) and
|
||||
exactly ONE ``icon`` emoji that captures the workflow's purpose at a
|
||||
glance — these are used as the App's display name and icon when the user
|
||||
applies the generation to a brand-new app. Prefer concise nouns
|
||||
("URL Summarizer", "Translator", "Issue Triage") and a topical emoji
|
||||
(📰 for news/summary, 🌐 for translation, 🐛 for issues, 🎓 for
|
||||
tutoring, 🔎 for search, 🗂️ for routing/classification).
|
||||
10. Declare the workflow's user-supplied inputs in ``start_inputs``. Every
|
||||
user value a downstream node will reference (URLs, queries, topics,
|
||||
file uploads, etc.) MUST appear here so the start node can expose it
|
||||
at run time — otherwise the LLM / code / answer node's ``{#start.<var>#}``
|
||||
reference will fail at run time with "variable not found". Each entry
|
||||
is ``{"variable": "<snake_case>", "label": "<UI label>",
|
||||
"type": "text-input" | "paragraph" | "number" | "select" | "file" |
|
||||
"file-list"}``. Use:
|
||||
- "text-input" for short single-line values (URLs, names),
|
||||
- "paragraph" for free-form multi-line text (descriptions, queries),
|
||||
- "number" / "select" / "file" / "file-list" for the obvious cases.
|
||||
In Advanced-Chat mode the ``sys.query`` / ``sys.files`` system
|
||||
variables are automatic — downstream nodes may reference them without
|
||||
a ``start_inputs`` entry. In Workflow mode there is NO automatic
|
||||
variable; everything the user supplies must be in ``start_inputs``.
|
||||
11. Output strictly the JSON object — no prose, no Markdown, no code fences.
|
||||
|
||||
# Output schema
|
||||
|
||||
{
|
||||
"title": "<≤ 40-char title of the workflow>",
|
||||
"description": "<one-sentence summary>",
|
||||
"app_name": "<≤ 30-char product-style name, e.g. 'URL Summarizer'>",
|
||||
"icon": "<single emoji that captures the workflow's purpose, e.g. '📰'>",
|
||||
"start_inputs": [
|
||||
{"variable": "url", "label": "URL", "type": "text-input"}
|
||||
],
|
||||
"nodes": [
|
||||
{"label": "Start", "node_type": "start", "purpose": "..."},
|
||||
{"label": "Summarize", "node_type": "llm", "purpose": "..."},
|
||||
{"label": "End", "node_type": "end", "purpose": "..."}
|
||||
]
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
PLANNER_USER_PROMPT = """# Mode
|
||||
|
||||
{mode}
|
||||
|
||||
# User instruction
|
||||
|
||||
{instruction}
|
||||
|
||||
{existing_graph_section}{ideal_output_section}{tool_catalogue_section}\
|
||||
Return the JSON plan now.
|
||||
"""
|
||||
|
||||
|
||||
def format_existing_graph_section(current_graph: dict | None) -> str:
|
||||
"""
|
||||
Refine mode: surface a compact summary of the graph the user is editing so
|
||||
the planner amends the existing node set rather than inventing one from
|
||||
scratch. Returns an empty string in create mode (no ``current_graph``), in
|
||||
which case the planner behaves exactly as before.
|
||||
|
||||
We pass only ids / node-types / titles + edge endpoints here — the planner
|
||||
decides *which nodes* exist, so it needs the shape, not the per-node config.
|
||||
The builder gets the full graph JSON to preserve untouched node config.
|
||||
"""
|
||||
if not current_graph:
|
||||
return ""
|
||||
nodes = current_graph.get("nodes") or []
|
||||
edges = current_graph.get("edges") or []
|
||||
node_lines = []
|
||||
for node in nodes:
|
||||
if not isinstance(node, dict):
|
||||
continue
|
||||
data = node.get("data") or {}
|
||||
node_lines.append(f"- id={node.get('id', '')!r} type={data.get('type', '')!r} title={data.get('title', '')!r}")
|
||||
edge_lines = []
|
||||
for edge in edges:
|
||||
if not isinstance(edge, dict):
|
||||
continue
|
||||
edge_lines.append(f"- {edge.get('source', '')} -> {edge.get('target', '')}")
|
||||
nodes_block = "\n".join(node_lines) or "(none)"
|
||||
edges_block = "\n".join(edge_lines) or "(none)"
|
||||
return (
|
||||
"# Existing graph to refine\n\n"
|
||||
"You are REFINING an existing workflow, NOT building one from scratch. "
|
||||
"The user instruction above describes the change they want. Re-plan the "
|
||||
"node list to reflect that change while keeping everything the "
|
||||
"instruction does not mention — preserve existing nodes, their order, "
|
||||
"and their labels wherever the change leaves them untouched. Only add, "
|
||||
"remove, or rename nodes the requested change actually requires.\n\n"
|
||||
f"Current nodes:\n{nodes_block}\n\n"
|
||||
f"Current edges:\n{edges_block}\n\n"
|
||||
)
|
||||
|
||||
|
||||
def format_ideal_output_section(ideal_output: str) -> str:
|
||||
"""Return an empty string when the user did not provide ideal output."""
|
||||
if not ideal_output.strip():
|
||||
return ""
|
||||
return f"# Ideal output\n\n{ideal_output}\n\n"
|
||||
|
||||
|
||||
def format_tool_catalogue_section(catalogue_text: str) -> str:
|
||||
"""
|
||||
Embed the installed-tool catalogue so the planner can pick concrete
|
||||
``tool`` nodes by exact ``provider/tool`` identifier instead of inventing
|
||||
names. Returns an empty string when no tools are installed.
|
||||
"""
|
||||
if not catalogue_text.strip():
|
||||
return ""
|
||||
return (
|
||||
"# Available tools (planner: when picking 'tool' nodes, choose "
|
||||
"from this list and reference them by exact provider/tool name)\n\n"
|
||||
f"{catalogue_text}\n\n"
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,153 +0,0 @@
|
||||
"""
|
||||
Tool catalogue for the workflow generator.
|
||||
|
||||
Returns a compact, LLM-readable inventory of the tools currently installed for
|
||||
a tenant (both hardcoded built-in providers and plugin providers). The planner
|
||||
uses this to recommend ``tool`` nodes by exact ``provider/tool`` identifier;
|
||||
the builder consumes the same list so it can emit a syntactically correct
|
||||
``tool`` node ``data`` block (provider_id, provider_type, tool_name,
|
||||
tool_label).
|
||||
|
||||
Format: one tool per line, ``- <provider>/<tool> — <one-line description>``.
|
||||
|
||||
The list is intentionally capped — if a tenant has hundreds of plugin tools,
|
||||
sending the full catalogue blows past LLM context windows. We sort by
|
||||
provider name and truncate to ``_MAX_TOOLS`` lines so the prompt stays
|
||||
bounded. Tools beyond the cap are dropped silently; if quality suffers, the
|
||||
fix is a planner-time relevance filter, not a bigger dump.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from operator import itemgetter
|
||||
from typing import TypedDict
|
||||
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
from core.tools.plugin_tool.provider import PluginToolProviderController
|
||||
from core.tools.tool_manager import ToolManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_MAX_TOOLS = 80
|
||||
|
||||
|
||||
class ToolCatalogueEntry(TypedDict):
|
||||
provider_name: str
|
||||
provider_type: str # "builtin" | "plugin" — what the workflow tool node uses
|
||||
plugin_id: str # empty string for hardcoded built-ins
|
||||
tool_name: str
|
||||
tool_label: str
|
||||
description: str # one-line LLM-friendly description
|
||||
|
||||
|
||||
def build_tool_catalogue(tenant_id: str) -> list[ToolCatalogueEntry]:
|
||||
"""
|
||||
Enumerate installed tools for the given tenant.
|
||||
|
||||
Failures inside a single provider (mis-declared tool, plugin runtime
|
||||
error) are logged and skipped — one bad provider must not break the
|
||||
whole generator. Returns at most ``_MAX_TOOLS`` entries.
|
||||
"""
|
||||
entries: list[ToolCatalogueEntry] = []
|
||||
|
||||
for provider in ToolManager.list_builtin_providers(tenant_id):
|
||||
provider_name = provider.entity.identity.name
|
||||
plugin_id = ""
|
||||
# Hardcoded built-ins return "builtin"; plugin providers return "plugin".
|
||||
# Use the provider's own declared value so the catalogue matches what
|
||||
# ``tool`` workflow nodes need in their ``data.provider_type`` field.
|
||||
provider_type = provider.provider_type.value
|
||||
if isinstance(provider, PluginToolProviderController):
|
||||
plugin_id = provider.plugin_id or ""
|
||||
elif not isinstance(provider, BuiltinToolProviderController):
|
||||
# Unknown provider class — skip rather than guess.
|
||||
continue
|
||||
|
||||
try:
|
||||
tools = list(provider.get_tools())
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Workflow generator: failed to list tools for provider %s",
|
||||
provider_name,
|
||||
)
|
||||
continue
|
||||
|
||||
for tool in tools:
|
||||
try:
|
||||
tool_name = tool.entity.identity.name
|
||||
tool_label = _i18n_text(tool.entity.identity.label)
|
||||
description = _tool_description(tool.entity.description)
|
||||
entries.append(
|
||||
ToolCatalogueEntry(
|
||||
provider_name=provider_name,
|
||||
provider_type=provider_type,
|
||||
plugin_id=plugin_id,
|
||||
tool_name=tool_name,
|
||||
tool_label=tool_label,
|
||||
description=description,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Workflow generator: failed to describe tool %s in provider %s",
|
||||
getattr(getattr(tool, "entity", None), "identity", None),
|
||||
provider_name,
|
||||
)
|
||||
continue
|
||||
|
||||
entries.sort(key=itemgetter("provider_name", "tool_name"))
|
||||
return entries[:_MAX_TOOLS]
|
||||
|
||||
|
||||
def installed_tool_keys(entries: list[ToolCatalogueEntry]) -> set[tuple[str, str]]:
|
||||
"""
|
||||
Return the set of ``(provider_name, tool_name)`` pairs available for the
|
||||
tenant. The validator in ``runner.py`` consults this set so a planner /
|
||||
builder that hallucinates a tool name fails loudly at generation time
|
||||
instead of producing a runtime-broken graph.
|
||||
|
||||
The set is keyed on ``provider_name`` (not ``provider_id``) because the
|
||||
builder prompt is instructed to put the provider's catalogue name into
|
||||
BOTH ``data.provider_id`` and ``data.provider_name`` on tool nodes —
|
||||
they are the same value for both built-in and plugin providers.
|
||||
"""
|
||||
return {(e["provider_name"], e["tool_name"]) for e in entries}
|
||||
|
||||
|
||||
def format_tool_catalogue(entries: list[ToolCatalogueEntry]) -> str:
|
||||
"""
|
||||
Render the catalogue as a compact multi-line block for prompt injection.
|
||||
Returns an empty string when no tools are installed — callers should skip
|
||||
the section entirely in that case.
|
||||
"""
|
||||
if not entries:
|
||||
return ""
|
||||
lines = []
|
||||
for e in entries:
|
||||
desc = e["description"].replace("\n", " ").strip()
|
||||
if len(desc) > 120:
|
||||
desc = desc[:117] + "..."
|
||||
line = f"- {e['provider_name']}/{e['tool_name']}"
|
||||
if e["tool_label"] and e["tool_label"] != e["tool_name"]:
|
||||
line += f" ({e['tool_label']})"
|
||||
if desc:
|
||||
line += f" — {desc}"
|
||||
lines.append(line)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _i18n_text(label) -> str:
|
||||
"""Pull the English label out of an I18nObject (falls back to .name)."""
|
||||
if label is None:
|
||||
return ""
|
||||
en = getattr(label, "en_US", None)
|
||||
if en:
|
||||
return en
|
||||
return getattr(label, "zh_Hans", "") or ""
|
||||
|
||||
|
||||
def _tool_description(description) -> str:
|
||||
"""Pull the LLM-facing description (``.llm``) from a ToolDescription."""
|
||||
if description is None:
|
||||
return ""
|
||||
return getattr(description, "llm", "") or ""
|
||||
@ -1,147 +0,0 @@
|
||||
"""
|
||||
Typed payloads for workflow generation.
|
||||
|
||||
These TypedDicts describe the shape that the planner and builder LLM calls are
|
||||
required to return after ``json_repair`` parsing. They mirror the runtime
|
||||
``graph`` shape consumed by ``WorkflowService.sync_draft_workflow`` so the output
|
||||
can be written straight into a draft workflow without further translation.
|
||||
"""
|
||||
|
||||
from typing import Final, Literal, NotRequired, TypedDict
|
||||
|
||||
WorkflowGenerationMode = Literal["workflow", "advanced-chat"]
|
||||
|
||||
|
||||
# Machine-readable error codes returned in ``WorkflowGenerateResultDict.errors``.
|
||||
# Frontend maps these to localised copy via ``workflow.generator.errors.<code>``
|
||||
# i18n keys, so any change here MUST be mirrored in the FE i18n map.
|
||||
class WorkflowGenerateErrorCode:
|
||||
INVALID_JSON: Final = "INVALID_JSON"
|
||||
INVALID_SCHEMA: Final = "INVALID_SCHEMA"
|
||||
EMPTY_INSTRUCTION: Final = "EMPTY_INSTRUCTION"
|
||||
EMPTY_PLAN: Final = "EMPTY_PLAN"
|
||||
UNKNOWN_NODE_REFERENCE: Final = "UNKNOWN_NODE_REFERENCE"
|
||||
INVALID_CONTAINER: Final = "INVALID_CONTAINER"
|
||||
UNRESOLVED_REFERENCE: Final = "UNRESOLVED_REFERENCE"
|
||||
UNKNOWN_TOOL: Final = "UNKNOWN_TOOL"
|
||||
MISSING_TERMINAL: Final = "MISSING_TERMINAL"
|
||||
MISSING_START: Final = "MISSING_START"
|
||||
DANGLING_EDGE: Final = "DANGLING_EDGE"
|
||||
MODEL_ERROR: Final = "MODEL_ERROR"
|
||||
|
||||
|
||||
class WorkflowGenerateErrorDict(TypedDict):
|
||||
"""One structured error from the workflow generator.
|
||||
|
||||
``code`` is the stable machine-readable identifier listed in
|
||||
``WorkflowGenerateErrorCode``. ``detail`` is the raw human-readable
|
||||
diagnostic (English). ``node_id`` is set when the error is tied to a
|
||||
specific node so the frontend can highlight it on the preview canvas.
|
||||
"""
|
||||
|
||||
code: str
|
||||
detail: str
|
||||
node_id: NotRequired[str]
|
||||
|
||||
|
||||
class PlannerNodeDict(TypedDict):
|
||||
"""One node from the planner's high-level plan."""
|
||||
|
||||
label: str
|
||||
node_type: str
|
||||
purpose: str
|
||||
|
||||
|
||||
class PlannerStartInputDict(TypedDict):
|
||||
"""One user-supplied input the start node will declare.
|
||||
|
||||
The planner emits this list so the builder can populate
|
||||
``start.data.variables`` and downstream ``{#start.<var>#}`` references
|
||||
resolve at run time. Optional — older prompts may omit it; the runner's
|
||||
postprocess walker still auto-fixes missing references.
|
||||
"""
|
||||
|
||||
variable: str
|
||||
label: str
|
||||
type: str # "text-input" | "paragraph" | "number" | "select" | "file" | "file-list"
|
||||
|
||||
|
||||
class PlannerResultDict(TypedDict):
|
||||
"""Top-level planner response."""
|
||||
|
||||
title: str
|
||||
description: str
|
||||
app_name: NotRequired[str]
|
||||
icon: NotRequired[str]
|
||||
start_inputs: NotRequired[list[PlannerStartInputDict]]
|
||||
nodes: list[PlannerNodeDict]
|
||||
|
||||
|
||||
class GraphNodePositionDict(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
|
||||
|
||||
class GraphNodeDict(TypedDict):
|
||||
"""A workflow graph node as serialised in the draft graph JSON."""
|
||||
|
||||
id: str
|
||||
type: str # ReactFlow custom-node key, e.g. "custom"
|
||||
position: GraphNodePositionDict
|
||||
data: dict
|
||||
width: NotRequired[int]
|
||||
height: NotRequired[int]
|
||||
positionAbsolute: NotRequired[GraphNodePositionDict]
|
||||
sourcePosition: NotRequired[str]
|
||||
targetPosition: NotRequired[str]
|
||||
selected: NotRequired[bool]
|
||||
dragging: NotRequired[bool]
|
||||
|
||||
|
||||
class GraphEdgeDict(TypedDict):
|
||||
"""A workflow graph edge as serialised in the draft graph JSON."""
|
||||
|
||||
id: str
|
||||
source: str
|
||||
target: str
|
||||
type: str # always "custom" for Dify's custom-edge renderer
|
||||
sourceHandle: NotRequired[str]
|
||||
targetHandle: NotRequired[str]
|
||||
data: NotRequired[dict]
|
||||
|
||||
|
||||
class GraphViewportDict(TypedDict):
|
||||
x: float
|
||||
y: float
|
||||
zoom: float
|
||||
|
||||
|
||||
class GraphDict(TypedDict):
|
||||
"""Full graph payload — matches ``WorkflowService.sync_draft_workflow``."""
|
||||
|
||||
nodes: list[GraphNodeDict]
|
||||
edges: list[GraphEdgeDict]
|
||||
viewport: GraphViewportDict
|
||||
|
||||
|
||||
class WorkflowGenerateResultDict(TypedDict):
|
||||
"""What the runner returns. ``error`` is "" on success.
|
||||
|
||||
``app_name`` and ``icon`` are populated from the planner output when the
|
||||
LLM emits them (newer prompts) and default to empty strings when it
|
||||
doesn't. The frontend's ``applyToNewApp`` consumes them with its own
|
||||
fallback so old prompts and missing fields stay safe.
|
||||
|
||||
``errors`` is the structured-error sibling of ``error``. ``error`` is a
|
||||
human-readable concatenation kept for backwards compat with the original
|
||||
envelope; ``errors`` carries the machine-readable codes so the frontend
|
||||
can localise the message and tie failures to specific nodes. On success
|
||||
both ``error == ""`` and ``errors == []``.
|
||||
"""
|
||||
|
||||
graph: GraphDict
|
||||
message: str
|
||||
app_name: str
|
||||
icon: str
|
||||
error: str
|
||||
errors: list[WorkflowGenerateErrorDict]
|
||||
@ -40,11 +40,7 @@ class RateLimit:
|
||||
LIMIT_DEVICE_CODE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,))
|
||||
LIMIT_SSO_INITIATE_PER_IP = RateLimit(60, timedelta(hours=1), (RateLimitScope.IP,))
|
||||
LIMIT_APPROVE_EXT_PER_EMAIL = RateLimit(10, timedelta(hours=1), (RateLimitScope.SUBJECT_EMAIL,))
|
||||
LIMIT_DEVICE_FLOW_APPROVE = RateLimit(
|
||||
limit=dify_config.DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR,
|
||||
window=timedelta(hours=1),
|
||||
scopes=(RateLimitScope.SESSION,),
|
||||
)
|
||||
LIMIT_APPROVE_CONSOLE = RateLimit(10, timedelta(hours=1), (RateLimitScope.SESSION,))
|
||||
LIMIT_LOOKUP_PUBLIC = RateLimit(60, timedelta(minutes=5), (RateLimitScope.IP,))
|
||||
LIMIT_ME_PER_ACCOUNT = RateLimit(60, timedelta(minutes=1), (RateLimitScope.ACCOUNT,))
|
||||
LIMIT_ME_PER_EMAIL = RateLimit(60, timedelta(minutes=1), (RateLimitScope.SUBJECT_EMAIL,))
|
||||
|
||||
@ -8493,27 +8493,6 @@ Get website crawl status
|
||||
| 400 | Invalid provider |
|
||||
| 404 | Crawl job not found |
|
||||
|
||||
### /workflow-generate
|
||||
|
||||
#### POST
|
||||
##### Description
|
||||
|
||||
Generate a Dify workflow graph from natural language
|
||||
|
||||
##### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| payload | body | | Yes | [WorkflowGeneratePayload](#workflowgeneratepayload) |
|
||||
|
||||
##### Responses
|
||||
|
||||
| Code | Description |
|
||||
| ---- | ----------- |
|
||||
| 200 | Workflow graph generated successfully |
|
||||
| 400 | Invalid request parameters |
|
||||
| 402 | Provider quota exceeded |
|
||||
|
||||
### /workflow/{workflow_run_id}/events
|
||||
|
||||
#### GET
|
||||
@ -16679,22 +16658,6 @@ How a workflow node is bound to an Agent.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| features | object | Workflow feature configuration | Yes |
|
||||
|
||||
#### WorkflowGeneratePayload
|
||||
|
||||
Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
|
||||
|
||||
See ``services/workflow_generator_service.py`` for behaviour. Errors are
|
||||
surfaced through the same envelope as ``/rule-generate`` so the frontend
|
||||
can reuse its existing handler.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| current_graph | object | Existing draft graph to refine (cmd+k `/refine`); omit for create-from-scratch | No |
|
||||
| ideal_output | string | Optional sample output for grounding | No |
|
||||
| instruction | string | Natural-language workflow description | Yes |
|
||||
| mode | string | Target app mode for the generated graph<br>*Enum:* `"advanced-chat"`, `"workflow"` | Yes |
|
||||
| model_config | [ModelConfig](#modelconfig) | Model configuration | Yes |
|
||||
|
||||
#### WorkflowListQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@ -1,96 +0,0 @@
|
||||
"""
|
||||
Workflow generator service.
|
||||
|
||||
Thin facade over ``core.workflow.generator.WorkflowGenerator`` that owns the
|
||||
model-manager / model-instance plumbing. Controllers call this; the pure
|
||||
domain class never touches the model registry directly.
|
||||
|
||||
Pattern mirrors ``LLMGenerator.generate_rule_config`` — see
|
||||
``core/llm_generator/llm_generator.py`` — but lives in ``services/`` because
|
||||
the generator output is consumed at the application layer (sync_draft_workflow,
|
||||
createApp) rather than from inside another workflow.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from core.model_manager import ModelManager
|
||||
from core.workflow.generator import WorkflowGenerator
|
||||
from core.workflow.generator.tool_catalogue import build_tool_catalogue, format_tool_catalogue, installed_tool_keys
|
||||
from core.workflow.generator.types import WorkflowGenerateResultDict, WorkflowGenerationMode
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorkflowGeneratorService:
|
||||
"""
|
||||
Coordinates model resolution with the workflow generator domain logic.
|
||||
|
||||
Single public method (``generate_workflow_graph``) keeps the surface area
|
||||
minimal — the cmd+k `/create` flow is the only caller today.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def generate_workflow_graph(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
mode: WorkflowGenerationMode,
|
||||
instruction: str,
|
||||
model_config: ModelConfig,
|
||||
ideal_output: str = "",
|
||||
current_graph: dict[str, Any] | None = None,
|
||||
) -> WorkflowGenerateResultDict:
|
||||
"""
|
||||
Resolve a model instance for the tenant and run the generator.
|
||||
|
||||
``current_graph`` is the existing draft graph for the cmd+k `/refine`
|
||||
flow — when present the generator refines it instead of creating a new
|
||||
graph from scratch. ``None`` is the `/create` path.
|
||||
|
||||
Errors from the LLM call (auth, quota, invoke) propagate so the
|
||||
controller can map them to existing HTTP error envelopes (same
|
||||
envelope as ``/rule-generate``).
|
||||
"""
|
||||
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
||||
model_instance = model_manager.get_model_instance(
|
||||
tenant_id=tenant_id,
|
||||
model_type=ModelType.LLM,
|
||||
provider=model_config.provider,
|
||||
model=model_config.name,
|
||||
)
|
||||
|
||||
model_parameters: dict[str, Any] = dict(model_config.completion_params or {})
|
||||
|
||||
# Build the installed-tool catalogue for this tenant so the planner/
|
||||
# builder can pick concrete tools instead of inventing names, AND so
|
||||
# the runner's validator can reject hallucinated tool names BEFORE
|
||||
# the user clicks Apply. A failure here (plugin daemon unreachable,
|
||||
# etc.) must not block generation — log and fall back to the no-tool
|
||||
# path, which also disables tool validation in the runner (None
|
||||
# sentinel rather than empty set, so we don't reject every tool
|
||||
# node just because we couldn't enumerate the catalogue).
|
||||
tool_catalogue_text = ""
|
||||
installed_tools: set[tuple[str, str]] | None = None
|
||||
try:
|
||||
entries = build_tool_catalogue(tenant_id)
|
||||
tool_catalogue_text = format_tool_catalogue(entries)
|
||||
installed_tools = installed_tool_keys(entries)
|
||||
except Exception:
|
||||
logger.exception("Workflow generator: failed to build tool catalogue for tenant %s", tenant_id)
|
||||
|
||||
return WorkflowGenerator.generate_workflow_graph(
|
||||
model_instance=model_instance,
|
||||
model_parameters=model_parameters,
|
||||
provider=model_config.provider,
|
||||
model_name=model_config.name,
|
||||
model_mode=model_config.mode.value,
|
||||
mode=mode,
|
||||
instruction=instruction,
|
||||
ideal_output=ideal_output,
|
||||
tool_catalogue_text=tool_catalogue_text,
|
||||
installed_tools=installed_tools,
|
||||
current_graph=current_graph,
|
||||
)
|
||||
@ -254,208 +254,3 @@ def test_instruction_template_invalid_type(app) -> None:
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
method()
|
||||
|
||||
|
||||
# ─ /workflow-generate ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _workflow_generate_payload() -> dict:
|
||||
return {
|
||||
"mode": "workflow",
|
||||
"instruction": "Summarize a URL",
|
||||
"ideal_output": "A 3-sentence summary.",
|
||||
"model_config": _model_config_payload(),
|
||||
}
|
||||
|
||||
|
||||
def _stub_workflow_service(monkeypatch: pytest.MonkeyPatch, returns=None, raises: Exception | None = None):
|
||||
def _call(**_kwargs):
|
||||
if raises is not None:
|
||||
raise raises
|
||||
return returns or {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _call)
|
||||
|
||||
|
||||
def test_workflow_generate_returns_service_result(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
expected = {
|
||||
"graph": {"nodes": [{"id": "node-1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "Summarize",
|
||||
"error": "",
|
||||
}
|
||||
_stub_workflow_service(monkeypatch, returns=expected)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
response = method("t1")
|
||||
|
||||
assert response == expected
|
||||
|
||||
|
||||
def test_workflow_generate_maps_provider_token_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ProviderTokenNotInitError → ProviderNotInitializeError so the frontend
|
||||
can render the same "provider missing" UX as /rule-generate."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=ProviderTokenNotInitError("missing token"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_quota_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import ProviderQuotaExceededError
|
||||
from core.errors.error import QuotaExceededError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=QuotaExceededError())
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_model_not_support_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import ProviderModelCurrentlyNotSupportError
|
||||
from core.errors.error import ModelCurrentlyNotSupportError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=ModelCurrentlyNotSupportError("not supported"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_invoke_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import CompletionRequestError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=InvokeError("LLM unreachable"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(CompletionRequestError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_accepts_advanced_chat_mode(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The payload Literal must accept advanced-chat as well as workflow."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
payload = _workflow_generate_payload()
|
||||
payload["mode"] = "advanced-chat"
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=payload,
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["mode"] == "advanced-chat"
|
||||
assert captured["instruction"] == "Summarize a URL"
|
||||
assert captured["ideal_output"] == "A 3-sentence summary."
|
||||
|
||||
|
||||
def test_workflow_generate_forwards_current_graph_for_refine(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cmd+k `/refine`: the optional current_graph field reaches the service."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
|
||||
payload = _workflow_generate_payload()
|
||||
payload["current_graph"] = graph
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=payload,
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["current_graph"] == graph
|
||||
|
||||
|
||||
def test_workflow_generate_current_graph_defaults_to_none(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Omitting current_graph (the `/create` path) forwards None to the service."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["current_graph"] is None
|
||||
|
||||
@ -129,34 +129,3 @@ def test_builtin_tool_summary_short_and_long_content_paths():
|
||||
|
||||
assert result
|
||||
assert "S" in result
|
||||
|
||||
|
||||
def test_builtin_tool_summary_does_not_duplicate_lines_when_merging_chunks():
|
||||
"""Each line must be placed into exactly one summarization chunk.
|
||||
|
||||
The chunk-merge loop used two adjacent, non-mutually-exclusive ``if`` blocks, so a
|
||||
line that fit the character budget was concatenated onto the current chunk AND then
|
||||
appended a second time. That duplicated content in the text sent to the model.
|
||||
"""
|
||||
tool = _build_tool()
|
||||
|
||||
captured_chunks: list[str] = []
|
||||
|
||||
def _record_invoke(user_id, prompt_messages, stop):
|
||||
captured_chunks.append(prompt_messages[-1].content)
|
||||
return SimpleNamespace(message=SimpleNamespace(content="S"))
|
||||
|
||||
content = "\n".join(["a" * 20, "b" * 20, "c" * 20])
|
||||
|
||||
with patch.object(_BuiltinDummyTool, "get_max_tokens", return_value=100):
|
||||
with patch.object(
|
||||
_BuiltinDummyTool,
|
||||
"get_prompt_tokens",
|
||||
side_effect=lambda prompt_messages: len(prompt_messages[-1].content),
|
||||
):
|
||||
with patch.object(_BuiltinDummyTool, "invoke_model", side_effect=_record_invoke):
|
||||
tool.summary(user_id="u1", content=content)
|
||||
|
||||
combined = "".join(captured_chunks)
|
||||
for line in ("a" * 20, "b" * 20, "c" * 20):
|
||||
assert combined.count(line) == 1, f"line was sent to the model {combined.count(line)} times, expected 1"
|
||||
|
||||
@ -1,129 +0,0 @@
|
||||
"""
|
||||
Unit tests for the planner / builder prompt format helpers.
|
||||
|
||||
These helpers are pure string-shaping functions that wrap conditional sections
|
||||
into the LLM prompts. We assert they (1) emit empty strings when the source
|
||||
data is empty so the prompt stays tight, (2) include the relevant header text
|
||||
when data is present, and (3) round-trip the raw catalogue text unchanged.
|
||||
"""
|
||||
|
||||
from core.workflow.generator.prompts.builder_prompts import (
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT,
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW,
|
||||
format_builder_tool_catalogue_section,
|
||||
format_plan_block,
|
||||
get_builder_system_prompt,
|
||||
)
|
||||
from core.workflow.generator.prompts.planner_prompts import (
|
||||
format_ideal_output_section,
|
||||
format_tool_catalogue_section,
|
||||
)
|
||||
|
||||
|
||||
class TestFormatIdealOutputSection:
|
||||
def test_returns_empty_string_for_blank_input(self):
|
||||
assert format_ideal_output_section("") == ""
|
||||
assert format_ideal_output_section(" \n\t ") == ""
|
||||
|
||||
def test_wraps_content_in_a_labelled_section(self):
|
||||
out = format_ideal_output_section("A short summary.")
|
||||
assert out.startswith("# Ideal output")
|
||||
assert "A short summary." in out
|
||||
assert out.endswith("\n\n")
|
||||
|
||||
|
||||
class TestPlannerCatalogueSection:
|
||||
def test_returns_empty_when_catalogue_is_blank(self):
|
||||
# No installed tools — the planner shouldn't see an "Available tools"
|
||||
# heading at all; an empty string keeps the prompt tight.
|
||||
assert format_tool_catalogue_section("") == ""
|
||||
assert format_tool_catalogue_section(" ") == ""
|
||||
|
||||
def test_emits_a_planner_facing_header_with_the_catalogue(self):
|
||||
out = format_tool_catalogue_section("- google/search — Search.")
|
||||
assert "# Available tools" in out
|
||||
assert "planner" in out.lower()
|
||||
assert "- google/search — Search." in out
|
||||
|
||||
|
||||
class TestBuilderCatalogueSection:
|
||||
def test_returns_empty_when_catalogue_is_blank(self):
|
||||
assert format_builder_tool_catalogue_section("") == ""
|
||||
|
||||
def test_includes_strict_provider_tool_guidance(self):
|
||||
out = format_builder_tool_catalogue_section("- google/search — Search.")
|
||||
# The builder must be told to use the *exact* identifiers — hallucinated
|
||||
# tools fail at sync time.
|
||||
assert "exact" in out.lower()
|
||||
assert "provider_id" in out
|
||||
assert "tool_name" in out
|
||||
assert "- google/search — Search." in out
|
||||
|
||||
|
||||
class TestFormatPlanBlock:
|
||||
def test_renders_one_line_per_node(self):
|
||||
out = format_plan_block(
|
||||
[
|
||||
{"label": "Start", "node_type": "start", "purpose": "Take input"},
|
||||
{"label": "Summarize", "node_type": "llm", "purpose": "Summarize"},
|
||||
]
|
||||
)
|
||||
lines = out.split("\n")
|
||||
# Two nodes → 4 lines (each entry takes id-line + purpose-line).
|
||||
assert any(line.startswith("1.") and "node1" in line for line in lines)
|
||||
assert any(line.startswith("2.") and "node2" in line for line in lines)
|
||||
assert "purpose: Take input" in out
|
||||
assert "purpose: Summarize" in out
|
||||
|
||||
def test_handles_missing_fields_gracefully(self):
|
||||
out = format_plan_block([{"node_type": "llm"}])
|
||||
# Missing label/purpose must not raise — they degrade to empty strings.
|
||||
assert "node1" in out
|
||||
assert "type=llm" in out
|
||||
|
||||
|
||||
class TestGetBuilderSystemPrompt:
|
||||
def test_returns_workflow_prompt_for_workflow_mode(self):
|
||||
# The two prompts are structurally similar but differ in their
|
||||
# mode-specific rules block.
|
||||
prompt = get_builder_system_prompt("workflow")
|
||||
assert prompt is BUILDER_SYSTEM_PROMPT_WORKFLOW
|
||||
assert 'exactly one "end" node' in prompt
|
||||
|
||||
def test_returns_advanced_chat_prompt_for_advanced_chat_mode(self):
|
||||
prompt = get_builder_system_prompt("advanced-chat")
|
||||
assert prompt is BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
|
||||
assert 'exactly one "answer" node' in prompt
|
||||
|
||||
|
||||
class TestFormatPlanBlockParentHints:
|
||||
def test_resolves_parent_label_to_node_id(self):
|
||||
# The planner emits parent="Per Item" as a hint; the builder needs the
|
||||
# resolved id ("node-N") to set parentId on the inner node.
|
||||
from core.workflow.generator.prompts.builder_prompts import format_plan_block
|
||||
|
||||
out = format_plan_block(
|
||||
[
|
||||
{"label": "Start", "node_type": "start", "purpose": "x"},
|
||||
{"label": "Per Item", "node_type": "iteration", "purpose": "iterate"},
|
||||
{"label": "Sum Item", "node_type": "llm", "purpose": "summarize one", "parent": "Per Item"},
|
||||
]
|
||||
)
|
||||
# The inner line should mention parent=node2 (the iteration node).
|
||||
assert "parent=node2" in out
|
||||
# Top-level nodes must not have a parent clause.
|
||||
first_line = out.splitlines()[0]
|
||||
assert "parent=" not in first_line
|
||||
|
||||
def test_omits_parent_clause_when_label_is_unknown(self):
|
||||
# A typo / unknown parent label should degrade to quoting the raw
|
||||
# label string rather than fabricating a node id.
|
||||
from core.workflow.generator.prompts.builder_prompts import format_plan_block
|
||||
|
||||
out = format_plan_block(
|
||||
[
|
||||
{"label": "Start", "node_type": "start", "purpose": "x"},
|
||||
{"label": "Step", "node_type": "code", "purpose": "x", "parent": "Ghost Container"},
|
||||
]
|
||||
)
|
||||
assert "parent='Ghost Container'" in out
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,349 +0,0 @@
|
||||
"""Unit tests for the tool catalogue helpers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.workflow.generator.tool_catalogue import (
|
||||
ToolCatalogueEntry,
|
||||
_i18n_text,
|
||||
_tool_description,
|
||||
build_tool_catalogue,
|
||||
format_tool_catalogue,
|
||||
installed_tool_keys,
|
||||
)
|
||||
|
||||
|
||||
def _entry(provider: str, tool: str, *, label: str = "", description: str = "") -> ToolCatalogueEntry:
|
||||
return ToolCatalogueEntry(
|
||||
provider_name=provider,
|
||||
provider_type="builtin",
|
||||
plugin_id="",
|
||||
tool_name=tool,
|
||||
tool_label=label,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
class TestInstalledToolKeys:
|
||||
"""The validator in ``runner.py`` looks up tool nodes against this set.
|
||||
|
||||
Keys MUST be ``(provider_name, tool_name)`` tuples — the builder prompt
|
||||
is instructed to put ``provider_name`` into both ``data.provider_id``
|
||||
and ``data.provider_name`` on tool nodes, so the runner's check accepts
|
||||
either field. The set therefore keys on ``provider_name``, not
|
||||
``plugin_id`` or any other identifier.
|
||||
"""
|
||||
|
||||
def test_empty_input_returns_empty_set(self):
|
||||
assert installed_tool_keys([]) == set()
|
||||
|
||||
def test_returns_provider_tool_tuples(self):
|
||||
keys = installed_tool_keys(
|
||||
[
|
||||
_entry("google", "search"),
|
||||
_entry("github", "list_issues"),
|
||||
]
|
||||
)
|
||||
assert keys == {("google", "search"), ("github", "list_issues")}
|
||||
|
||||
def test_dedupes_duplicate_entries(self):
|
||||
# Defensive — the catalogue builder dedupes on read, but a duplicate
|
||||
# entry slipping through should collapse rather than break the set
|
||||
# type contract.
|
||||
keys = installed_tool_keys([_entry("x", "y"), _entry("x", "y")])
|
||||
assert keys == {("x", "y")}
|
||||
|
||||
|
||||
class TestFormatToolCatalogue:
|
||||
def test_empty_input_returns_empty_string(self):
|
||||
assert format_tool_catalogue([]) == ""
|
||||
|
||||
def test_renders_provider_slash_tool_per_line(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("google", "search", description="Search the web with Google."),
|
||||
_entry("time", "current_time", description="Return the current time."),
|
||||
]
|
||||
)
|
||||
lines = out.split("\n")
|
||||
assert lines == [
|
||||
"- google/search — Search the web with Google.",
|
||||
"- time/current_time — Return the current time.",
|
||||
]
|
||||
|
||||
def test_includes_label_when_different_from_tool_name(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("google", "search", label="Google Search", description="Search."),
|
||||
]
|
||||
)
|
||||
assert out == "- google/search (Google Search) — Search."
|
||||
|
||||
def test_omits_label_when_identical_to_tool_name(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("time", "current_time", label="current_time", description="Now."),
|
||||
]
|
||||
)
|
||||
assert out == "- time/current_time — Now."
|
||||
|
||||
def test_truncates_long_descriptions(self):
|
||||
long_desc = "x" * 200
|
||||
out = format_tool_catalogue([_entry("p", "t", description=long_desc)])
|
||||
# Truncated to 117 chars + "..."
|
||||
assert out.endswith("...")
|
||||
assert len(out.split(" — ", 1)[1]) == 120
|
||||
|
||||
def test_strips_newlines_from_descriptions(self):
|
||||
out = format_tool_catalogue([_entry("p", "t", description="line1\nline2\nline3")])
|
||||
assert "\n" not in out.split(" — ", 1)[1]
|
||||
assert "line1 line2 line3" in out
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeI18n(SimpleNamespace):
|
||||
"""Minimal stand-in for ``I18nObject`` — only the attrs we read."""
|
||||
|
||||
|
||||
class _FakeToolEntity(SimpleNamespace):
|
||||
"""Tool entity exposing ``identity`` + ``description`` like the real thing."""
|
||||
|
||||
|
||||
class _FakeToolIdentity(SimpleNamespace):
|
||||
"""Identity holding ``name`` + ``label`` like ``ToolIdentity``."""
|
||||
|
||||
|
||||
class _FakeToolDescription(SimpleNamespace):
|
||||
"""Description with the ``llm`` attribute we read for prompts."""
|
||||
|
||||
|
||||
class _FakeTool:
|
||||
"""Tool stand-in: ``.entity`` is the only attribute the catalogue reads."""
|
||||
|
||||
def __init__(self, entity):
|
||||
self.entity = entity
|
||||
|
||||
|
||||
def _make_tool(name: str, label_en: str = "", description_llm: str = "") -> _FakeTool:
|
||||
return _FakeTool(
|
||||
entity=_FakeToolEntity(
|
||||
identity=_FakeToolIdentity(
|
||||
name=name,
|
||||
label=_FakeI18n(en_US=label_en, zh_Hans=""),
|
||||
),
|
||||
description=_FakeToolDescription(llm=description_llm),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _FakeProviderType(SimpleNamespace):
|
||||
"""Stand-in for ``ToolProviderType`` — only ``.value`` is read."""
|
||||
|
||||
|
||||
def _make_builtin_provider(name: str, tools: list, raises_on_get_tools: bool = False):
|
||||
"""
|
||||
Build something ``isinstance(..., BuiltinToolProviderController)`` will
|
||||
answer True to without actually constructing one (those require real
|
||||
on-disk plugin metadata). We patch the isinstance call sites instead.
|
||||
"""
|
||||
provider = SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="builtin"),
|
||||
get_tools=((lambda: (_ for _ in ()).throw(RuntimeError("boom"))) if raises_on_get_tools else (lambda: tools)),
|
||||
)
|
||||
provider._is_builtin = True
|
||||
return provider
|
||||
|
||||
|
||||
def _make_plugin_provider(name: str, plugin_id: str, tools: list):
|
||||
provider = SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="plugin"),
|
||||
plugin_id=plugin_id,
|
||||
get_tools=lambda: tools,
|
||||
)
|
||||
provider._is_plugin = True
|
||||
return provider
|
||||
|
||||
|
||||
def _make_unknown_provider(name: str):
|
||||
"""A provider matching neither class — must be skipped."""
|
||||
return SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="weird"),
|
||||
get_tools=lambda: [_make_tool("ghost")],
|
||||
)
|
||||
|
||||
|
||||
def _patched_isinstance(obj, cls):
|
||||
"""
|
||||
Reroute isinstance checks the catalogue uses to the fake providers built
|
||||
above. Anything else falls through to the real isinstance.
|
||||
"""
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
from core.tools.plugin_tool.provider import PluginToolProviderController
|
||||
|
||||
if cls is BuiltinToolProviderController:
|
||||
return bool(getattr(obj, "_is_builtin", False))
|
||||
if cls is PluginToolProviderController:
|
||||
return bool(getattr(obj, "_is_plugin", False))
|
||||
import builtins as _b
|
||||
|
||||
return _b.isinstance(obj, cls)
|
||||
|
||||
|
||||
# ── _i18n_text / _tool_description ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestI18nText:
|
||||
def test_returns_empty_string_when_label_is_none(self):
|
||||
assert _i18n_text(None) == ""
|
||||
|
||||
def test_returns_en_us_when_present(self):
|
||||
assert _i18n_text(_FakeI18n(en_US="Search", zh_Hans="搜索")) == "Search"
|
||||
|
||||
def test_falls_back_to_zh_hans_when_en_us_blank(self):
|
||||
# Some plugins ship only Chinese metadata; falling back keeps the
|
||||
# planner aware of those tools instead of dropping them silently.
|
||||
assert _i18n_text(_FakeI18n(en_US="", zh_Hans="搜索")) == "搜索"
|
||||
|
||||
def test_returns_empty_when_both_locales_missing(self):
|
||||
assert _i18n_text(_FakeI18n()) == ""
|
||||
|
||||
|
||||
class TestToolDescription:
|
||||
def test_returns_empty_string_for_none_description(self):
|
||||
# ToolEntity.description is Optional — must not raise on absent.
|
||||
assert _tool_description(None) == ""
|
||||
|
||||
def test_returns_llm_attribute(self):
|
||||
assert _tool_description(_FakeToolDescription(llm="Web search")) == "Web search"
|
||||
|
||||
def test_returns_empty_when_llm_missing(self):
|
||||
assert _tool_description(SimpleNamespace()) == ""
|
||||
|
||||
|
||||
# ── build_tool_catalogue ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildToolCatalogue:
|
||||
"""
|
||||
The builder iterates the ``ToolManager.list_builtin_providers`` generator
|
||||
(which already covers both hardcoded and plugin providers in production).
|
||||
We patch the generator + isinstance so the tests can exercise every branch
|
||||
without standing up real plugin daemon state.
|
||||
"""
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_returns_empty_list_for_tenant_with_no_tools(self, mock_list, mock_isinstance):
|
||||
mock_list.return_value = iter([])
|
||||
|
||||
assert build_tool_catalogue("tenant-1") == []
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_collects_hardcoded_and_plugin_tools(self, mock_list, mock_isinstance):
|
||||
# Mixed-tenant scenario: hardcoded provider plus a plugin provider,
|
||||
# each carrying one tool. The catalogue must include all four fields
|
||||
# the workflow tool node will need (provider_name / provider_type /
|
||||
# plugin_id / tool_name).
|
||||
hardcoded = _make_builtin_provider(
|
||||
"time",
|
||||
[_make_tool("current_time", label_en="Current Time", description_llm="Return now.")],
|
||||
)
|
||||
plugin = _make_plugin_provider(
|
||||
"google",
|
||||
plugin_id="langgenius/google",
|
||||
tools=[_make_tool("search", label_en="Google Search", description_llm="Search the web.")],
|
||||
)
|
||||
mock_list.return_value = iter([hardcoded, plugin])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
# Sorted alphabetically by provider_name.
|
||||
assert [(e["provider_name"], e["tool_name"]) for e in entries] == [
|
||||
("google", "search"),
|
||||
("time", "current_time"),
|
||||
]
|
||||
google = entries[0]
|
||||
assert google["provider_type"] == "plugin"
|
||||
assert google["plugin_id"] == "langgenius/google"
|
||||
assert google["tool_label"] == "Google Search"
|
||||
assert google["description"] == "Search the web."
|
||||
time_entry = entries[1]
|
||||
assert time_entry["provider_type"] == "builtin"
|
||||
assert time_entry["plugin_id"] == ""
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_skips_unknown_provider_classes(self, mock_list, mock_isinstance):
|
||||
# If ToolManager ever yields a provider the catalogue doesn't know how
|
||||
# to label, we must continue (not raise) and leave it out of the
|
||||
# output rather than guessing at provider_type.
|
||||
unknown = _make_unknown_provider("mystery")
|
||||
hardcoded = _make_builtin_provider("time", [_make_tool("now")])
|
||||
mock_list.return_value = iter([unknown, hardcoded])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["provider_name"] for e in entries] == ["time"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_continues_when_a_provider_get_tools_raises(self, mock_list, mock_isinstance):
|
||||
# A buggy plugin must not break the whole catalogue. Resilient
|
||||
# per-provider try/except is what keeps generation usable in tenants
|
||||
# with broken installs.
|
||||
bad = _make_builtin_provider("broken", [], raises_on_get_tools=True)
|
||||
good = _make_builtin_provider("time", [_make_tool("now")])
|
||||
mock_list.return_value = iter([bad, good])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["provider_name"] for e in entries] == ["time"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_skips_individual_tools_when_their_metadata_is_broken(self, mock_list, mock_isinstance):
|
||||
# Per-tool try/except — a single mis-declared tool inside an otherwise
|
||||
# healthy provider gets dropped, the rest still surface.
|
||||
good_tool = _make_tool("ok", label_en="Ok", description_llm="Healthy tool.")
|
||||
# Bad tool: accessing .entity.identity raises because entity is None.
|
||||
bad_tool = SimpleNamespace(entity=None)
|
||||
hardcoded = _make_builtin_provider("p", [bad_tool, good_tool])
|
||||
mock_list.return_value = iter([hardcoded])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["tool_name"] for e in entries] == ["ok"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_truncates_to_max_tools_to_keep_prompt_bounded(self, mock_list, mock_isinstance):
|
||||
# A tenant with hundreds of plugin tools would blow the LLM context
|
||||
# window. The catalogue caps the output at ``_MAX_TOOLS``.
|
||||
big_provider = _make_builtin_provider(
|
||||
"p",
|
||||
[_make_tool(f"t{i:03d}") for i in range(200)],
|
||||
)
|
||||
mock_list.return_value = iter([big_provider])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert len(entries) == 80
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_defaults_plugin_id_to_empty_string_when_missing(self, mock_list, mock_isinstance):
|
||||
# Plugin provider whose plugin_id is None should serialise to "" so
|
||||
# the consumer can safely index ``e["plugin_id"]`` without a None
|
||||
# check at every callsite.
|
||||
plugin = _make_plugin_provider("p", plugin_id=None, tools=[_make_tool("t")])
|
||||
mock_list.return_value = iter([plugin])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert entries[0]["plugin_id"] == ""
|
||||
@ -1,201 +0,0 @@
|
||||
"""
|
||||
Unit tests for ``WorkflowGeneratorService``.
|
||||
|
||||
The service is a thin facade — its job is (1) hand the tenant model_config to
|
||||
``ModelManager`` to get a model_instance, (2) build the tool catalogue, and
|
||||
(3) delegate to ``WorkflowGenerator``. We mock both dependencies so the tests
|
||||
stay fast and focus on the wiring itself.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
|
||||
|
||||
def _model_config() -> ModelConfig:
|
||||
return ModelConfig(
|
||||
provider="openai",
|
||||
name="gpt-4o",
|
||||
mode=LLMMode.CHAT,
|
||||
completion_params={"temperature": 0.4},
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowGeneratorService:
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_forwards_model_instance_and_catalogue_text_to_generator(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Happy path: model_instance + catalogue text + payload flow through."""
|
||||
# Arrange
|
||||
instance = MagicMock(name="model_instance")
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = instance
|
||||
mock_build_catalogue.return_value = [{"provider_name": "google"}]
|
||||
mock_format_catalogue.return_value = "- google/search — Search."
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "ok",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# Act
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
ideal_output="A 3-sentence summary",
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_model_manager.for_tenant.assert_called_once_with(tenant_id="t-1")
|
||||
mock_workflow_generator.generate_workflow_graph.assert_called_once()
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["model_instance"] is instance
|
||||
assert call_kwargs["provider"] == "openai"
|
||||
assert call_kwargs["model_name"] == "gpt-4o"
|
||||
assert call_kwargs["mode"] == "workflow"
|
||||
assert call_kwargs["instruction"] == "Summarize a URL"
|
||||
assert call_kwargs["ideal_output"] == "A 3-sentence summary"
|
||||
assert call_kwargs["tool_catalogue_text"] == "- google/search — Search."
|
||||
assert call_kwargs["model_parameters"] == {"temperature": 0.4}
|
||||
assert result["error"] == ""
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
def test_catalogue_build_failure_falls_back_to_empty_text(
|
||||
self,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""
|
||||
A plugin-daemon outage must not block generation — the catalogue helper
|
||||
is wrapped in try/except so a failure downgrades to an empty catalogue.
|
||||
"""
|
||||
# Arrange
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.side_effect = RuntimeError("plugin daemon unreachable")
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# Act
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
# Assert: generation still ran, catalogue text was empty.
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["tool_catalogue_text"] == ""
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_defaults_ideal_output_to_empty_string(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Callers can omit ideal_output; the runner should still receive ""."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="advanced-chat",
|
||||
instruction="A chat bot",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["ideal_output"] == ""
|
||||
assert call_kwargs["mode"] == "advanced-chat"
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_forwards_current_graph_for_refine(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""The cmd+k `/refine` path passes the existing draft graph through to the runner."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
current_graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Add a translation step",
|
||||
model_config=_model_config(),
|
||||
current_graph=current_graph,
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["current_graph"] is current_graph
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_defaults_current_graph_to_none_for_create(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Omitting current_graph (the `/create` path) forwards None to the runner."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["current_graph"] is None
|
||||
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=
|
||||
7
cli/.gitignore
vendored
7
cli/.gitignore
vendored
@ -5,7 +5,14 @@ node_modules/
|
||||
.vitest-cache/
|
||||
docs/specs/
|
||||
context/
|
||||
# E2E test env (contains tokens/credentials — use .env.e2e.example instead)
|
||||
.env.e2e
|
||||
# Generated / runtime artifacts
|
||||
oclif.manifest.json
|
||||
npm-shrinkwrap.json
|
||||
tmp/
|
||||
test/**/*.ts.map
|
||||
test/**/*.js.map
|
||||
test/**/*.js
|
||||
test/**/*.d.ts
|
||||
.token-cache.json
|
||||
|
||||
@ -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": "tsgo",
|
||||
|
||||
355
cli/scripts/e2e-provision.ts
Normal file
355
cli/scripts/e2e-provision.ts
Normal file
@ -0,0 +1,355 @@
|
||||
#!/usr/bin/env bun
|
||||
import { Buffer } from 'node:buffer'
|
||||
/**
|
||||
* e2e-provision.ts
|
||||
*
|
||||
* Standalone pre-flight script for CI parallel e2e jobs.
|
||||
*
|
||||
* What it does (mirrors global-setup.ts, but without vitest):
|
||||
* 1. Console login → cookie + CSRF token
|
||||
* 2. Mint a primary bearer token (or validate a cached/pre-set one)
|
||||
* 3. Discover primary + secondary workspaces
|
||||
* 4. Provision all DSL fixture apps (idempotent — reuses existing ones)
|
||||
* 5. Write GITHUB_OUTPUT (token, workspace IDs, all app IDs)
|
||||
* so downstream jobs can skip re-minting and re-provisioning.
|
||||
*
|
||||
* Usage (in CI):
|
||||
* bun scripts/e2e-provision.ts
|
||||
*
|
||||
* Required env vars:
|
||||
* DIFY_E2E_HOST, DIFY_E2E_EMAIL, DIFY_E2E_PASSWORD
|
||||
*
|
||||
* Optional:
|
||||
* DIFY_E2E_EDITION (ee | ce, default: ee)
|
||||
* DIFY_E2E_TOKEN pre-minted token — skips device-flow mint
|
||||
*
|
||||
* Output file:
|
||||
* .provision-output.json (also written to GITHUB_OUTPUT if set)
|
||||
*/
|
||||
|
||||
import { appendFile, readFile } from 'node:fs/promises'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
// ── Env ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const host = process.env.DIFY_E2E_HOST ?? ''
|
||||
const email = process.env.DIFY_E2E_EMAIL ?? ''
|
||||
const password = process.env.DIFY_E2E_PASSWORD ?? ''
|
||||
const edition = ((process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase()) as 'ee' | 'ce'
|
||||
const preToken = process.env.DIFY_E2E_TOKEN ?? ''
|
||||
|
||||
if (!host || !email || !password) {
|
||||
console.warn('[provision] Missing required env: DIFY_E2E_HOST, DIFY_E2E_EMAIL, DIFY_E2E_PASSWORD')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const base = host.replace(/\/$/, '')
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function sleep(ms: number) {
|
||||
return new Promise(r => setTimeout(r, ms))
|
||||
}
|
||||
|
||||
async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string }> {
|
||||
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
|
||||
const res = 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(15_000),
|
||||
})
|
||||
if (!res.ok)
|
||||
throw new Error(`console/api/login failed: HTTP ${res.status}`)
|
||||
|
||||
const setCookies = res.headers.getSetCookie?.() ?? []
|
||||
const cookieString = setCookies.map(c => c.split(';')[0]).join('; ')
|
||||
// cookie names may have __Host- prefix on HTTPS deployments
|
||||
const csrfPair = setCookies.map(c => c.split(';')[0]).find(p => p.includes('csrf_token='))
|
||||
const csrfToken = csrfPair ? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length) : ''
|
||||
return { cookieString, csrfToken }
|
||||
}
|
||||
|
||||
async function validateToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch(`${base}/openapi/v1/account/sessions`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
return res.ok
|
||||
}
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
async function mintToken(cookieStr: string, csrf: string, label: string): Promise<string> {
|
||||
// Step 1: 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 2: approve (with retry)
|
||||
let approveRes: Response | undefined
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Cookie': cookieStr, 'X-CSRFToken': csrf },
|
||||
body: JSON.stringify({ user_code }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (approveRes.ok)
|
||||
break
|
||||
if (approveRes.status !== 429 && approveRes.status < 500)
|
||||
break
|
||||
console.warn(`[provision] device/approve HTTP ${approveRes.status}; retry ${i}/5 in ${i * 2}s`)
|
||||
await sleep(i * 2_000)
|
||||
}
|
||||
if (!approveRes?.ok)
|
||||
throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
|
||||
|
||||
// Step 3: exchange 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 body = await tokenRes.json() as { token?: string }
|
||||
if (!body.token)
|
||||
throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
|
||||
return body.token
|
||||
}
|
||||
|
||||
async function discoverWorkspaces(cookieStr: string, csrf: string) {
|
||||
const res = await fetch(`${base}/console/api/workspaces`, {
|
||||
headers: { 'Cookie': cookieStr, 'X-CSRF-Token': csrf },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!res.ok)
|
||||
throw new Error(`list workspaces failed: HTTP ${res.status}`)
|
||||
const data = await res.json() as { workspaces?: Array<{ id: string, name: string }> }
|
||||
const all = data.workspaces ?? []
|
||||
|
||||
if (edition === 'ee') {
|
||||
const ws0 = all.find(w => w.name === 'auto_test0')
|
||||
const ws1 = all.find(w => w.name === 'auto_test1')
|
||||
if (!ws0)
|
||||
throw new Error('[provision] EE: workspace "auto_test0" not found')
|
||||
console.warn(`[provision] EE primary: ${ws0.name} (${ws0.id})`)
|
||||
console.warn(`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`)
|
||||
return { primaryWsId: ws0.id, primaryWsName: ws0.name, secondaryWsId: ws1?.id ?? ws0.id }
|
||||
}
|
||||
|
||||
const auto = all.filter(w => w.name.toLowerCase().includes('auto')).sort((a, b) => a.name.localeCompare(b.name))
|
||||
const primary = auto[0] ?? all[0]
|
||||
if (!primary)
|
||||
throw new Error('[provision] No workspaces found')
|
||||
return { primaryWsId: primary.id, primaryWsName: primary.name, secondaryWsId: auto[1]?.id ?? primary.id }
|
||||
}
|
||||
|
||||
async function provisionApps(
|
||||
cookieStr: string,
|
||||
csrf: string,
|
||||
primaryWsId: string,
|
||||
secondaryWsId: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const mkH = (extra: Record<string, string> = {}) => ({
|
||||
'Cookie': cookieStr,
|
||||
'X-CSRF-Token': csrf,
|
||||
...extra,
|
||||
})
|
||||
|
||||
const scriptDir = dirname(fileURLToPath(import.meta.url))
|
||||
const fixturesDir = join(scriptDir, '..', 'test', 'e2e', 'fixtures', 'apps')
|
||||
|
||||
const NEEDS_PUBLISH = new Set(['workflow', 'advanced-chat', 'agent-chat'])
|
||||
const APP_SPECS: Array<[string, string, string]> = [
|
||||
['echo-chat.yml', 'DIFY_E2E_CHAT_APP_ID', primaryWsId],
|
||||
['echo-workflow.yml', 'DIFY_E2E_WORKFLOW_APP_ID', primaryWsId],
|
||||
['file-upload.yml', 'DIFY_E2E_FILE_APP_ID', primaryWsId],
|
||||
['hitl-main.yml', 'DIFY_E2E_HITL_APP_ID', primaryWsId],
|
||||
['hitl-external.yml', 'DIFY_E2E_HITL_EXTERNAL_APP_ID', primaryWsId],
|
||||
['hitl-single-action.yml', 'DIFY_E2E_HITL_SINGLE_ACTION_APP_ID', primaryWsId],
|
||||
['hitl-multi-node.yml', 'DIFY_E2E_HITL_MULTI_NODE_APP_ID', primaryWsId],
|
||||
['file-chat.yml', 'DIFY_E2E_FILE_CHAT_APP_ID', primaryWsId],
|
||||
...(edition === 'ee'
|
||||
? [['ws2-workflow.yml', 'DIFY_E2E_WS2_APP_ID', secondaryWsId] as [string, string, string]]
|
||||
: []),
|
||||
]
|
||||
|
||||
let currentWs = ''
|
||||
const results: Record<string, string> = {}
|
||||
|
||||
for (const [dslFile, envVar, wsId] of APP_SPECS) {
|
||||
try {
|
||||
// Switch workspace if needed
|
||||
if (wsId !== currentWs) {
|
||||
await fetch(`${base}/console/api/workspaces/switch`, {
|
||||
method: 'POST',
|
||||
headers: { ...mkH(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ tenant_id: wsId }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
currentWs = wsId
|
||||
}
|
||||
|
||||
const dsl = await readFile(join(fixturesDir, dslFile), 'utf8')
|
||||
const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
|
||||
?.trim()
|
||||
.replace(/^['"]|['"]$/g, '') ?? dslFile
|
||||
const appMode = (dsl.match(/^[ \t]+mode:[ \t]*(\S+)/m) ?? [])[1] ?? ''
|
||||
|
||||
// Find existing or import
|
||||
const searchRes = await fetch(
|
||||
`${base}/console/api/apps?name=${encodeURIComponent(appName)}&limit=50&page=1`,
|
||||
{ headers: mkH(), signal: AbortSignal.timeout(10_000) },
|
||||
)
|
||||
const searchData = await searchRes.json() as { data?: Array<{ id: string, name: string }> }
|
||||
let appId = searchData.data?.find(a => a.name === appName)?.id
|
||||
|
||||
if (appId) {
|
||||
console.warn(`[provision] ${dslFile}: exists id=${appId}`)
|
||||
}
|
||||
else {
|
||||
const importRes = await fetch(`${base}/console/api/apps/imports`, {
|
||||
method: 'POST',
|
||||
headers: { ...mkH(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: 'yaml-content', yaml_content: dsl }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
const importData = await importRes.json() as { app_id?: string, import_id?: string }
|
||||
if (importRes.status === 202 && importData.import_id) {
|
||||
const confirmRes = await fetch(`${base}/console/api/apps/imports/${importData.import_id}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: mkH(),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
const confirmData = await confirmRes.json() as { app_id?: string }
|
||||
appId = confirmData.app_id
|
||||
}
|
||||
else {
|
||||
appId = importData.app_id
|
||||
}
|
||||
if (!appId)
|
||||
throw new Error(`import failed: ${JSON.stringify(importData)}`)
|
||||
console.warn(`[provision] ${dslFile}: imported id=${appId}`)
|
||||
}
|
||||
|
||||
// Enable API
|
||||
await fetch(`${base}/console/api/apps/${appId}/api-enable`, {
|
||||
method: 'POST',
|
||||
headers: { ...mkH(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enable_api: true }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
|
||||
// Set public
|
||||
await fetch(`${base}/console/api/enterprise/webapp/app/access-mode`, {
|
||||
method: 'POST',
|
||||
headers: { ...mkH(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appId, accessMode: 'public' }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch(() => {})
|
||||
|
||||
// Publish workflow
|
||||
if (NEEDS_PUBLISH.has(appMode)) {
|
||||
await fetch(`${base}/console/api/apps/${appId}/workflows/publish`, {
|
||||
method: 'POST',
|
||||
headers: { ...mkH(), 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ marked_name: 'e2e-provision', marked_comment: '' }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
}).catch(() => {})
|
||||
}
|
||||
|
||||
results[envVar] = appId
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`[provision] ${dslFile} skipped: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
async function writeOutputs(outputs: Record<string, string>) {
|
||||
const ghOutput = process.env.GITHUB_OUTPUT
|
||||
const lines = `${Object.entries(outputs).map(([k, v]) => `${k}=${v}`).join('\n')}\n`
|
||||
|
||||
// Always write local JSON for debugging
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
await writeFile('.provision-output.json', `${JSON.stringify(outputs, null, 2)}\n`, 'utf8')
|
||||
console.warn('[provision] Written .provision-output.json')
|
||||
|
||||
if (ghOutput) {
|
||||
await appendFile(ghOutput, lines, 'utf8')
|
||||
console.warn(`[provision] Written ${Object.keys(outputs).length} outputs to GITHUB_OUTPUT`)
|
||||
}
|
||||
|
||||
// Also print to stdout for visibility
|
||||
console.warn('\n[provision] Outputs:')
|
||||
for (const [k, v] of Object.entries(outputs))
|
||||
console.warn(` ${k}=${v.slice(0, 30)}${v.length > 30 ? '…' : ''}`)
|
||||
}
|
||||
|
||||
// ── Main ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
console.warn(`[provision] Host=${base} Email=${email} Edition=${edition}`)
|
||||
|
||||
// 1. Login
|
||||
const { cookieString, csrfToken } = await consoleLogin()
|
||||
console.warn('[provision] Login OK')
|
||||
|
||||
// 2. Token
|
||||
let primaryToken = preToken
|
||||
if (primaryToken && await validateToken(primaryToken)) {
|
||||
console.warn(`[provision] Using pre-set token: ${primaryToken.slice(0, 20)}…`)
|
||||
}
|
||||
else {
|
||||
if (primaryToken)
|
||||
console.warn('[provision] Pre-set token invalid, minting fresh…')
|
||||
primaryToken = await mintToken(cookieString, csrfToken, 'e2e-provision')
|
||||
console.warn(`[provision] Minted token: ${primaryToken.slice(0, 20)}…`)
|
||||
}
|
||||
|
||||
// 3. Discover workspaces
|
||||
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(cookieString, csrfToken)
|
||||
|
||||
// 4. Provision apps
|
||||
const appIds = await provisionApps(cookieString, csrfToken, primaryWsId, secondaryWsId)
|
||||
console.warn(`[provision] Provisioned ${Object.keys(appIds).length} apps`)
|
||||
|
||||
// 4b. Switch back to primaryWsId so the session ends in the correct workspace.
|
||||
// provisionApps processes ws2-workflow.yml last (EE mode), leaving the server
|
||||
// session in secondaryWsId. Suite jobs that share this token would then have
|
||||
// their describe calls rejected with "workspace_id does not match app's workspace".
|
||||
await fetch(`${base}/console/api/workspaces/switch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ tenant_id: primaryWsId }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
}).catch((err: unknown) => console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`))
|
||||
console.warn(`[provision] Session workspace reset to primary: ${primaryWsId}`)
|
||||
|
||||
// 5. Write outputs
|
||||
await writeOutputs({
|
||||
DIFY_E2E_TOKEN: primaryToken,
|
||||
DIFY_E2E_WORKSPACE_ID: primaryWsId,
|
||||
DIFY_E2E_WORKSPACE_NAME: primaryWsName,
|
||||
DIFY_E2E_WS2_ID: secondaryWsId,
|
||||
...appIds,
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.warn('[provision] Fatal:', err)
|
||||
process.exit(1)
|
||||
})
|
||||
@ -53,6 +53,10 @@ export type GetTokenStoreOptions = {
|
||||
export function getTokenStore(opts: GetTokenStoreOptions = {}): { store: Store, mode: StorageMode } {
|
||||
const fileFactory = opts.factory?.file ?? (() => getStore(join(resolveConfigDir(), TOKENS_FILE)))
|
||||
const keyringFactory = opts.factory?.keyring ?? (() => new KeyringBasedStore(KEYRING_SERVICE))
|
||||
// DIFY_E2E_NO_KEYRING=1 forces file-based storage in E2E tests to avoid
|
||||
// macOS keychain UI prompts blocking child processes spawned by vitest.
|
||||
if (process.env.DIFY_E2E_NO_KEYRING === '1')
|
||||
return { store: fileFactory(), mode: 'file' }
|
||||
try {
|
||||
const k = keyringFactory()
|
||||
k.set(PROBE_KEY, PROBE_VALUE)
|
||||
|
||||
@ -69,6 +69,9 @@ abstract class FileBasedStore implements Store {
|
||||
}
|
||||
|
||||
lock(): void {
|
||||
// Ensure the parent directory exists before creating the lock file.
|
||||
// On a fresh CI runner /home/runner/.cache/difyctl/ may not exist yet.
|
||||
fs.mkdirSync(dirname(`${this.filePath}.lock`), { recursive: true, mode: DIR_PERM })
|
||||
try {
|
||||
lockfile.lockSync(`${this.filePath}.lock`, {
|
||||
stale: 30_000,
|
||||
|
||||
69
cli/test/e2e/.env.e2e.example
Normal file
69
cli/test/e2e/.env.e2e.example
Normal file
@ -0,0 +1,69 @@
|
||||
# E2E test environment template — copy to ../.env.e2e or cli/.env.e2e
|
||||
# depending on the test command working directory, then fill in values.
|
||||
#
|
||||
# .env.e2e is git-ignored; this file is safe to commit.
|
||||
#
|
||||
# ── Run mode ────────────────────────────────────────────────────────────────
|
||||
# Leave unset for real staging E2E. Set to "local" only for local-mode suites.
|
||||
# DIFY_E2E_MODE=local
|
||||
|
||||
# ── Edition selector ────────────────────────────────────────────────────────
|
||||
# ce = Community Edition (default)
|
||||
# ee = Enterprise Edition; requires workspaces named exactly auto_test0/auto_test1.
|
||||
DIFY_E2E_EDITION=ee
|
||||
|
||||
# ── Required for real staging E2E ───────────────────────────────────────────
|
||||
# DIFY_E2E_HOST is the OpenAPI / console base URL unless DIFY_E2E_CONSOLE_URL is set.
|
||||
DIFY_E2E_HOST=
|
||||
DIFY_E2E_EMAIL=
|
||||
DIFY_E2E_PASSWORD=
|
||||
|
||||
# ── Enterprise workspace contract ───────────────────────────────────────────
|
||||
# For DIFY_E2E_EDITION=ee, the logged-in account must already have:
|
||||
# auto_test0 → primary workspace; global-setup checks 8 fixture apps here.
|
||||
# auto_test1 → secondary workspace; global-setup checks 1 fixture app here.
|
||||
#
|
||||
# If either workspace is missing, global-setup does not import DSL fixtures.
|
||||
# If a fixture app is missing, global-setup imports it from test/e2e/fixtures/apps.
|
||||
# If it already exists by exact app name, import is skipped.
|
||||
#
|
||||
# auto_test0 fixture apps:
|
||||
# echo-chat.yml → echo-bot
|
||||
# echo-workflow.yml → basic_auto_test
|
||||
# file-upload.yml → file_auto_test
|
||||
# hitl-main.yml → hitl_auto_test
|
||||
# hitl-external.yml → DIFY_E2E_HITL_EXTERNAL
|
||||
# hitl-single-action.yml → DIFY_E2E_HITL_SINGLE_ACTION
|
||||
# hitl-multi-node.yml → DIFY_E2E_HITL_MULTI_NODE
|
||||
# file-chat.yml → DIFY_E2E_FILE_CHAT
|
||||
#
|
||||
# auto_test1 fixture app:
|
||||
# ws2-workflow.yml → auto_test_workspace2
|
||||
|
||||
# ── Optional host override ──────────────────────────────────────────────────
|
||||
# Use only when console login/provisioning URL differs from DIFY_E2E_HOST.
|
||||
# DIFY_E2E_CONSOLE_URL=
|
||||
|
||||
# ── Optional tokens ─────────────────────────────────────────────────────────
|
||||
# Primary internal bearer token (dfoa_). When unset, global-setup mints/caches one.
|
||||
# DIFY_E2E_TOKEN=
|
||||
#
|
||||
# External SSO bearer token (dfoe_). Required only for SSO-specific cases.
|
||||
# DIFY_E2E_SSO_TOKEN=
|
||||
|
||||
# ── Optional manual fallbacks ────────────────────────────────────────────────
|
||||
# Normally leave these blank: global-setup discovers workspaces and fixture app IDs.
|
||||
# They are fallback values for debugging or when provisioning is intentionally skipped.
|
||||
# DIFY_E2E_WORKSPACE_ID=
|
||||
# DIFY_E2E_WORKSPACE_NAME=auto_test0
|
||||
# DIFY_E2E_WS2_ID=
|
||||
#
|
||||
# DIFY_E2E_CHAT_APP_ID=
|
||||
# DIFY_E2E_WORKFLOW_APP_ID=
|
||||
# DIFY_E2E_FILE_APP_ID=
|
||||
# DIFY_E2E_FILE_CHAT_APP_ID=
|
||||
# DIFY_E2E_HITL_APP_ID=
|
||||
# DIFY_E2E_HITL_EXTERNAL_APP_ID=
|
||||
# DIFY_E2E_HITL_SINGLE_ACTION_APP_ID=
|
||||
# DIFY_E2E_HITL_MULTI_NODE_APP_ID=
|
||||
# DIFY_E2E_WS2_APP_ID=
|
||||
190
cli/test/e2e/README.md
Normal file
190
cli/test/e2e/README.md
Normal file
@ -0,0 +1,190 @@
|
||||
# Dify CLI — E2E Test Suite
|
||||
|
||||
End-to-end tests that exercise the **real `difyctl` binary** against a live
|
||||
Dify server. Every test uses an isolated temporary config directory so no
|
||||
state leaks between test files.
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
test/e2e/
|
||||
├── setup/
|
||||
│ ├── env.ts — Load & validate DIFY_E2E_* env vars (CE + EE)
|
||||
│ ├── global-setup.ts — CE/EE-aware bootstrap: account creation, token
|
||||
│ │ minting, workspace provisioning, DSL import
|
||||
│ └── global-teardown.ts — Delete conversations created during the run
|
||||
│
|
||||
├── helpers/
|
||||
│ ├── cli.ts — run(), withAuthFixture(), mintFreshToken(),
|
||||
│ │ injectAuth(), spawn_background()
|
||||
│ ├── assert.ts — assertExitCode, assertJson, assertErrorEnvelope,
|
||||
│ │ assertNoAnsi, assertPipeFriendlyJson, ...
|
||||
│ ├── cleanup-registry.ts — registerConversation() / cleanupRegisteredConversations()
|
||||
│ ├── retry.ts — withRetry(fn, { attempts, delayMs })
|
||||
│ └── skip.ts — optionalIt(), optionalDescribe(),
|
||||
│ enterpriseOnlyIt(), enterpriseOnlyDescribe(), isEE()
|
||||
│
|
||||
└── suites/
|
||||
├── auth/
|
||||
│ ├── status.e2e.ts — auth status (text + JSON + SSO)
|
||||
│ ├── use.e2e.ts — workspace switching ([EE] cases require 2 workspaces)
|
||||
│ ├── whoami.e2e.ts — whoami + external SSO session checks
|
||||
│ ├── devices.e2e.ts — devices list + revoke (runs near-last)
|
||||
│ └── logout.e2e.ts — logout + local credential cleanup (runs last)
|
||||
├── config/
|
||||
│ └── config.e2e.ts — config path/get/set/unset/view, env override
|
||||
├── discovery/
|
||||
│ ├── get-app-list.e2e.ts — basic get app list
|
||||
│ ├── get-app-single.e2e.ts — get single app by ID
|
||||
│ ├── describe-app.e2e.ts — describe app
|
||||
│ └── get-app-all-workspaces.e2e.ts — get app -A ([EE] multi-workspace cases)
|
||||
└── run/
|
||||
├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming,
|
||||
│ conversation, CI mode
|
||||
├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing
|
||||
├── run-app-file.e2e.ts — --file upload (local + remote URL)
|
||||
└── run-app-hitl.e2e.ts — HITL pause + resume
|
||||
```
|
||||
|
||||
## Edition support
|
||||
|
||||
`difyctl` supports two Dify editions. The test suite adapts automatically:
|
||||
|
||||
| Edition | `DIFY_E2E_EDITION` | Workspaces | EE-only cases |
|
||||
| ----------------------- | ------------------ | ---------------- | ------------- |
|
||||
| Community Edition (CE) | `ce` (default) | 1 | Skipped |
|
||||
| Enterprise Edition (EE) | `ee` | 2 (auto-created) | Active |
|
||||
|
||||
### EE-only test cases
|
||||
|
||||
Tests that require Enterprise Edition features (workspace switching between
|
||||
independent workspaces, cross-workspace app query, etc.) are tagged `[EE]`
|
||||
in their names and wrapped with `enterpriseOnlyIt()` / `enterpriseOnlyDescribe()`
|
||||
from `helpers/skip.ts`. In CE mode these tests are automatically skipped.
|
||||
|
||||
```ts
|
||||
// helpers/skip.ts usage
|
||||
const eeIt = enterpriseOnlyIt(caps)
|
||||
eeIt('[EE][P0] cross-workspace query returns apps from all workspaces', async () => {
|
||||
// test body
|
||||
})
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Copy the credential template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp cli/test/e2e/.env.e2e.example cli/.env.e2e
|
||||
# edit cli/.env.e2e with real credentials
|
||||
```
|
||||
|
||||
### Community Edition (CE) — minimum 3 vars
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------- | ----------------------------------------------------- |
|
||||
| `DIFY_E2E_HOST` | Server base URL (`http://localhost`) |
|
||||
| `DIFY_E2E_EMAIL` | Account email — created automatically by global-setup |
|
||||
| `DIFY_E2E_PASSWORD` | Account password |
|
||||
|
||||
global-setup will:
|
||||
|
||||
1. Register the account (idempotent — safe to rerun)
|
||||
1. Login and mint a bearer token via the device flow
|
||||
1. Import all DSL fixtures into the single workspace
|
||||
1. Publish apps and set access_mode → public
|
||||
|
||||
### Enterprise Edition (EE) — 5 required vars
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------ | ------------------------------------------------------- |
|
||||
| `DIFY_E2E_EDITION` | Must be `ee` |
|
||||
| `DIFY_E2E_HOST` | Console/API base URL |
|
||||
| `DIFY_E2E_EMAIL` | Member account email — created via enterprise API |
|
||||
| `DIFY_E2E_PASSWORD` | Member account password |
|
||||
| `DIFY_E2E_ENTERPRISE_API_URL` | Enterprise admin API base URL (`https://.../inner/api`) |
|
||||
| `DIFY_E2E_ENTERPRISE_API_SECRET_KEY` | Enterprise admin API secret key |
|
||||
|
||||
Optional:
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | --------------------------------------------- |
|
||||
| `DIFY_E2E_CONSOLE_URL` | Console URL if different from `DIFY_E2E_HOST` |
|
||||
|
||||
global-setup will:
|
||||
|
||||
1. Create the member account via the enterprise admin API (idempotent)
|
||||
1. Login and obtain a session cookie
|
||||
1. Create two workspaces (`e2e-primary-auto`, `e2e-secondary-auto`) via the enterprise API
|
||||
1. Import DSL fixtures into both workspaces
|
||||
1. Publish apps and set access_mode → public via the enterprise API
|
||||
|
||||
### Optional overrides (both editions)
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------------------ | ------------------------------------------------ |
|
||||
| `DIFY_E2E_TOKEN` | Pre-minted bearer token — skips device-flow mint |
|
||||
| `DIFY_E2E_SSO_TOKEN` | External SSO bearer token (`dfoe_...`) |
|
||||
| `DIFY_E2E_WORKSPACE_ID` | Override primary workspace ID |
|
||||
| `DIFY_E2E_WORKSPACE_NAME` | Override primary workspace name |
|
||||
| `DIFY_E2E_WS2_ID` | Override secondary workspace ID (EE) |
|
||||
| `DIFY_E2E_CHAT_APP_ID` | Override echo-chat app ID |
|
||||
| `DIFY_E2E_WORKFLOW_APP_ID` | Override echo-workflow app ID |
|
||||
| `DIFY_E2E_FILE_APP_ID` | Override file-upload app ID |
|
||||
| `DIFY_E2E_FILE_CHAT_APP_ID` | Override file-chat app ID |
|
||||
| `DIFY_E2E_HITL_APP_ID` | Override HITL main app ID |
|
||||
| `DIFY_E2E_HITL_EXTERNAL_APP_ID` | |
|
||||
| `DIFY_E2E_HITL_SINGLE_ACTION_APP_ID` | |
|
||||
| `DIFY_E2E_HITL_MULTI_NODE_APP_ID` | |
|
||||
| `DIFY_E2E_WS2_APP_ID` | Override secondary workspace app ID (EE) |
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
cd cli
|
||||
|
||||
# Community Edition (default)
|
||||
bun run test:e2e
|
||||
|
||||
# Enterprise Edition
|
||||
DIFY_E2E_EDITION=ee bun run test:e2e
|
||||
|
||||
# Run only [P0] smoke cases
|
||||
bun run test:e2e:smoke
|
||||
|
||||
# Run only EE-tagged cases (P0 smoke)
|
||||
DIFY_E2E_EDITION=ee bun run test:e2e:smoke --testNamePattern "\[EE\]"
|
||||
|
||||
# Run offline-safe config tests only (no network required)
|
||||
bun run test:e2e:local
|
||||
|
||||
# Run a single file
|
||||
bun vitest --config vitest.e2e.config.ts test/e2e/suites/auth/status.e2e.ts
|
||||
```
|
||||
|
||||
## Test execution order
|
||||
|
||||
Files run sequentially (`fileParallelism: false`) in this order:
|
||||
|
||||
```
|
||||
login → status → use → whoami → help → config → output → error-handling
|
||||
→ framework → discovery → run (basic / streaming / file / HITL)
|
||||
→ devices → logout
|
||||
```
|
||||
|
||||
`devices` and `logout` run last because they revoke real server sessions.
|
||||
|
||||
## Design decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **CE/EE edition flag** | `DIFY_E2E_EDITION=ce/ee` controls global-setup bootstrap path and activates/skips `[EE]`-tagged tests. |
|
||||
| **`[EE]` tag convention** | Test names include `[EE]` to make skipped cases visible in the report and to allow `--testNamePattern "\[EE\]"` filtering. |
|
||||
| **`enterpriseOnlyIt(caps)`** | Returns `it` in EE mode, `it.skip` in CE mode — no runtime assertions needed, skip is declarative. |
|
||||
| **No mocking** | All HTTP traffic goes to the real server — this catches real integration regressions. |
|
||||
| **Isolated config dirs** | Each test creates a fresh `withTempConfig()` dir; session state never leaks between tests. |
|
||||
| **`withAuthFixture()`** | Combines `withTempConfig` + `injectAuth` into a single fixture; reduces beforeEach boilerplate. |
|
||||
| **`injectAuth()` bypasses Device Flow** | Non-auth tests skip the browser step; only `auth/` suites exercise the real flow. |
|
||||
| **`mintFreshToken()`** | `logout` and `devices-revoke` tests mint a disposable `dfoa_` token via the device flow API. |
|
||||
| **Global `retry: 0`** | Flaky network calls use `withRetry()` locally; global retry masks non-idempotent failures. |
|
||||
| **Conversation cleanup** | `registerConversation()` + global-teardown delete staging conversations after the run. |
|
||||
180
cli/test/e2e/fixtures/apps/echo-chat.yml
Normal file
180
cli/test/e2e/fixtures/apps/echo-chat.yml
Normal file
@ -0,0 +1,180 @@
|
||||
app:
|
||||
description: e2e-test
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: advanced-chat
|
||||
name: echo-bot
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies:
|
||||
- current_identifier: null
|
||||
type: marketplace
|
||||
value:
|
||||
marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46
|
||||
version: null
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
sourceType: start
|
||||
targetType: llm
|
||||
id: 1779690795511-llm
|
||||
source: '1779690795511'
|
||||
sourceHandle: source
|
||||
target: llm
|
||||
targetHandle: target
|
||||
type: custom
|
||||
- data:
|
||||
sourceType: llm
|
||||
targetType: answer
|
||||
id: llm-answer
|
||||
source: llm
|
||||
sourceHandle: source
|
||||
target: answer
|
||||
targetHandle: target
|
||||
type: custom
|
||||
nodes:
|
||||
- data:
|
||||
selected: false
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables:
|
||||
- default: ''
|
||||
hint: ''
|
||||
label: input
|
||||
max_length: 256
|
||||
options: []
|
||||
placeholder: ''
|
||||
required: false
|
||||
type: text-input
|
||||
variable: input
|
||||
height: 109
|
||||
id: '1779690795511'
|
||||
position:
|
||||
x: 79
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 79
|
||||
y: 282
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
context:
|
||||
enabled: false
|
||||
variable_selector: []
|
||||
memory:
|
||||
query_prompt_template: '{{#sys.query#}}
|
||||
|
||||
{{#sys.files#}}'
|
||||
role_prefix:
|
||||
assistant: ''
|
||||
user: ''
|
||||
window:
|
||||
enabled: false
|
||||
size: 10
|
||||
model:
|
||||
completion_params:
|
||||
temperature: 0.7
|
||||
mode: chat
|
||||
name: qwen3.6-plus
|
||||
provider: langgenius/tongyi/tongyi
|
||||
prompt_template:
|
||||
- id: 9b866a63-3619-4f5c-a46f-0aed04078587
|
||||
role: system
|
||||
text: 'User says: {{{#sys.query#}} Reply exactly: echo:{{#sys.query#}}'
|
||||
selected: false
|
||||
title: LLM
|
||||
type: llm
|
||||
vision:
|
||||
enabled: false
|
||||
height: 88
|
||||
id: llm
|
||||
position:
|
||||
x: 380
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 380
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
answer: '{{#llm.text#}}'
|
||||
selected: false
|
||||
title: 直接回复
|
||||
type: answer
|
||||
variables: []
|
||||
height: 103
|
||||
id: answer
|
||||
position:
|
||||
x: 680
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 680
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: 151
|
||||
y: 125
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
207
cli/test/e2e/fixtures/apps/echo-workflow.yml
Normal file
207
cli/test/e2e/fixtures/apps/echo-workflow.yml
Normal file
@ -0,0 +1,207 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: workflow
|
||||
name: basic_auto_test
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies:
|
||||
- current_identifier: null
|
||||
type: marketplace
|
||||
value:
|
||||
marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46
|
||||
version: null
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: start
|
||||
targetType: llm
|
||||
id: 1779097154262-source-1779097204645-target
|
||||
source: '1779097154262'
|
||||
sourceHandle: source
|
||||
target: '1779097204645'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInLoop: false
|
||||
sourceType: llm
|
||||
targetType: end
|
||||
id: 1779097204645-source-1779171097399-target
|
||||
source: '1779097204645'
|
||||
sourceHandle: source
|
||||
target: '1779171097399'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
nodes:
|
||||
- data:
|
||||
selected: true
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables:
|
||||
- default: ''
|
||||
hint: ''
|
||||
label: x
|
||||
options: []
|
||||
placeholder: ''
|
||||
required: true
|
||||
type: text-input
|
||||
variable: x
|
||||
- default: ''
|
||||
hint: ''
|
||||
label: num
|
||||
options: []
|
||||
placeholder: ''
|
||||
required: true
|
||||
type: number
|
||||
variable: num
|
||||
- hint: ''
|
||||
label: enum_var
|
||||
options:
|
||||
- A
|
||||
- B
|
||||
- C
|
||||
placeholder: ''
|
||||
required: true
|
||||
type: select
|
||||
variable: enum_var
|
||||
- default: ''
|
||||
hint: ''
|
||||
label: paragraph
|
||||
max_length: 100
|
||||
options: []
|
||||
placeholder: ''
|
||||
required: true
|
||||
type: text-input
|
||||
variable: paragraph
|
||||
height: 187
|
||||
id: '1779097154262'
|
||||
position:
|
||||
x: 80
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 80
|
||||
y: 282
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
context:
|
||||
enabled: false
|
||||
variable_selector: []
|
||||
model:
|
||||
completion_params:
|
||||
temperature: 0.7
|
||||
mode: chat
|
||||
name: qwen3.6-plus
|
||||
provider: langgenius/tongyi/tongyi
|
||||
prompt_template:
|
||||
- id: 1ddb3202-d84c-4faf-afe3-424eedc9049a
|
||||
role: system
|
||||
text: 'User says:{{#1779097154262.x#}}. Reply exactly: echo:{{#1779097154262.x#}}
|
||||
|
||||
'
|
||||
selected: false
|
||||
title: LLM
|
||||
type: llm
|
||||
vision:
|
||||
enabled: false
|
||||
height: 88
|
||||
id: '1779097204645'
|
||||
position:
|
||||
x: 382
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 382
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1779097204645'
|
||||
- text
|
||||
value_type: string
|
||||
variable: x
|
||||
selected: false
|
||||
title: 输出
|
||||
type: end
|
||||
height: 88
|
||||
id: '1779171097399'
|
||||
position:
|
||||
x: 752
|
||||
y: 259
|
||||
positionAbsolute:
|
||||
x: 752
|
||||
y: 259
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: 68
|
||||
y: 131
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
186
cli/test/e2e/fixtures/apps/file-chat.yml
Normal file
186
cli/test/e2e/fixtures/apps/file-chat.yml
Normal file
@ -0,0 +1,186 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: advanced-chat
|
||||
name: DIFY_E2E_FILE_CHAT
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies:
|
||||
- current_identifier: null
|
||||
type: marketplace
|
||||
value:
|
||||
marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46
|
||||
version: null
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
sourceType: start
|
||||
targetType: llm
|
||||
id: 1780453002656-llm
|
||||
source: '1780453002656'
|
||||
sourceHandle: source
|
||||
target: llm
|
||||
targetHandle: target
|
||||
type: custom
|
||||
- data:
|
||||
sourceType: llm
|
||||
targetType: answer
|
||||
id: llm-answer
|
||||
source: llm
|
||||
sourceHandle: source
|
||||
target: answer
|
||||
targetHandle: target
|
||||
type: custom
|
||||
nodes:
|
||||
- data:
|
||||
selected: false
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables:
|
||||
- allowed_file_extensions: []
|
||||
allowed_file_types:
|
||||
- document
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
default: ''
|
||||
hide: false
|
||||
hint: ''
|
||||
label: file_input
|
||||
options: []
|
||||
placeholder: ''
|
||||
required: true
|
||||
type: file
|
||||
variable: file_input
|
||||
height: 109
|
||||
id: '1780453002656'
|
||||
position:
|
||||
x: 80
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 80
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
context:
|
||||
enabled: false
|
||||
variable_selector: []
|
||||
memory:
|
||||
query_prompt_template: '{{#sys.query#}}
|
||||
|
||||
{{#sys.files#}}'
|
||||
role_prefix:
|
||||
assistant: ''
|
||||
user: ''
|
||||
window:
|
||||
enabled: false
|
||||
size: 10
|
||||
model:
|
||||
completion_params:
|
||||
temperature: 0.7
|
||||
mode: chat
|
||||
name: qwen3.6-plus
|
||||
provider: langgenius/tongyi/tongyi
|
||||
prompt_template:
|
||||
- id: ebc516ad-be6b-4a78-af32-77f447305b34
|
||||
role: system
|
||||
text: 输出固定内容:""hello
|
||||
selected: false
|
||||
title: LLM
|
||||
type: llm
|
||||
vision:
|
||||
enabled: false
|
||||
height: 88
|
||||
id: llm
|
||||
position:
|
||||
x: 380
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 380
|
||||
y: 282
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
answer: '{{#llm.text#}}'
|
||||
selected: false
|
||||
title: 直接回复
|
||||
type: answer
|
||||
variables: []
|
||||
height: 103
|
||||
id: answer
|
||||
position:
|
||||
x: 680
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 680
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: 0
|
||||
y: 0
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
201
cli/test/e2e/fixtures/apps/file-upload.yml
Normal file
201
cli/test/e2e/fixtures/apps/file-upload.yml
Normal file
@ -0,0 +1,201 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: workflow
|
||||
name: file_auto_test
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies:
|
||||
- current_identifier: null
|
||||
type: marketplace
|
||||
value:
|
||||
marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46
|
||||
version: null
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: start
|
||||
targetType: llm
|
||||
id: 1779693724732-source-1779693759949-target
|
||||
source: '1779693724732'
|
||||
sourceHandle: source
|
||||
target: '1779693759949'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: llm
|
||||
targetType: end
|
||||
id: 1779693759949-source-1779693765299-target
|
||||
source: '1779693759949'
|
||||
sourceHandle: source
|
||||
target: '1779693765299'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
nodes:
|
||||
- data:
|
||||
selected: true
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables:
|
||||
- allowed_file_extensions: []
|
||||
allowed_file_types:
|
||||
- document
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
default: ''
|
||||
hide: false
|
||||
hint: ''
|
||||
label: doc
|
||||
options: []
|
||||
placeholder: ''
|
||||
required: true
|
||||
type: file
|
||||
variable: doc
|
||||
- allowed_file_extensions: []
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
default: ''
|
||||
hide: false
|
||||
hint: ''
|
||||
label: picture
|
||||
options: []
|
||||
placeholder: ''
|
||||
required: true
|
||||
type: file
|
||||
variable: picture
|
||||
height: 135
|
||||
id: '1779693724732'
|
||||
position:
|
||||
x: 80
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 80
|
||||
y: 282
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
context:
|
||||
enabled: false
|
||||
variable_selector: []
|
||||
model:
|
||||
completion_params:
|
||||
temperature: 0.7
|
||||
mode: chat
|
||||
name: qwen3.6-plus
|
||||
provider: langgenius/tongyi/tongyi
|
||||
prompt_template:
|
||||
- id: bb929f8f-5fa9-415b-91c3-c30228488dcf
|
||||
role: system
|
||||
text: 直接输出内容:hello
|
||||
selected: false
|
||||
title: LLM
|
||||
type: llm
|
||||
vision:
|
||||
enabled: false
|
||||
height: 88
|
||||
id: '1779693759949'
|
||||
position:
|
||||
x: 382
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 382
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1779693759949'
|
||||
- text
|
||||
value_type: string
|
||||
variable: x
|
||||
selected: false
|
||||
title: 输出
|
||||
type: end
|
||||
height: 88
|
||||
id: '1779693765299'
|
||||
position:
|
||||
x: 684
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 684
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: 49
|
||||
y: 23
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
204
cli/test/e2e/fixtures/apps/hitl-external.yml
Normal file
204
cli/test/e2e/fixtures/apps/hitl-external.yml
Normal file
@ -0,0 +1,204 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: workflow
|
||||
name: DIFY_E2E_HITL_EXTERNAL
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies: []
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: start
|
||||
targetType: human-input
|
||||
id: 1780458810652-source-1780467012278-target
|
||||
source: '1780458810652'
|
||||
sourceHandle: source
|
||||
target: '1780467012278'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: human-input
|
||||
targetType: end
|
||||
id: 1780467012278-__timeout-1780467075179-target
|
||||
source: '1780467012278'
|
||||
sourceHandle: __timeout
|
||||
target: '1780467075179'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInLoop: false
|
||||
sourceType: human-input
|
||||
targetType: end
|
||||
id: 1780467012278-action_1-1780467098495-target
|
||||
source: '1780467012278'
|
||||
sourceHandle: action_1
|
||||
target: '1780467098495'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
nodes:
|
||||
- data:
|
||||
selected: true
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables: []
|
||||
height: 73
|
||||
id: '1780458810652'
|
||||
position:
|
||||
x: 79
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 79
|
||||
y: 282
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
delivery_methods:
|
||||
- config:
|
||||
body: '{{#url#}}'
|
||||
debug_mode: false
|
||||
recipients:
|
||||
items: []
|
||||
whole_workspace: true
|
||||
subject: TEST
|
||||
enabled: true
|
||||
id: 74e23f16-04ad-45cd-9984-55d491b47ae7
|
||||
type: email
|
||||
form_content: TEST
|
||||
inputs: []
|
||||
selected: false
|
||||
timeout: 1
|
||||
timeout_unit: hour
|
||||
title: 人工介入
|
||||
type: human-input
|
||||
user_actions:
|
||||
- button_style: default
|
||||
id: action_1
|
||||
title: Button Text 1
|
||||
height: 164
|
||||
id: '1780467012278'
|
||||
position:
|
||||
x: 382
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 382
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1780467012278'
|
||||
- __action_id
|
||||
value_type: string
|
||||
variable: X
|
||||
selected: false
|
||||
title: 输出
|
||||
type: end
|
||||
height: 88
|
||||
id: '1780467075179'
|
||||
position:
|
||||
x: 684
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 684
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- sys
|
||||
- user_id
|
||||
value_type: string
|
||||
variable: X
|
||||
selected: false
|
||||
title: 输出 2
|
||||
type: end
|
||||
height: 89
|
||||
id: '1780467098495'
|
||||
position:
|
||||
x: 684
|
||||
y: 409
|
||||
positionAbsolute:
|
||||
x: 684
|
||||
y: 409
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: -153
|
||||
y: -2
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
239
cli/test/e2e/fixtures/apps/hitl-main.yml
Normal file
239
cli/test/e2e/fixtures/apps/hitl-main.yml
Normal file
@ -0,0 +1,239 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: workflow
|
||||
name: hitl_auto_test
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies: []
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: start
|
||||
targetType: human-input
|
||||
id: 1779694031897-source-1779786788846-target
|
||||
source: '1779694031897'
|
||||
sourceHandle: source
|
||||
target: '1779786788846'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: human-input
|
||||
targetType: end
|
||||
id: 1779786788846-__timeout-1779786805561-target
|
||||
source: '1779786788846'
|
||||
sourceHandle: __timeout
|
||||
target: '1779786805561'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: human-input
|
||||
targetType: end
|
||||
id: 1779786788846-action_2-1780468501391-target
|
||||
source: '1779786788846'
|
||||
sourceHandle: action_2
|
||||
target: '1780468501391'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: human-input
|
||||
targetType: end
|
||||
id: 1779786788846-action_3-1780468504241-target
|
||||
source: '1779786788846'
|
||||
sourceHandle: action_3
|
||||
target: '1780468504241'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
nodes:
|
||||
- data:
|
||||
selected: false
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables: []
|
||||
height: 73
|
||||
id: '1779694031897'
|
||||
position:
|
||||
x: 80
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 80
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
delivery_methods:
|
||||
- enabled: true
|
||||
id: d3e67c3d-1779-478a-af2e-17d6da044175
|
||||
type: webapp
|
||||
form_content: ''
|
||||
inputs: []
|
||||
selected: false
|
||||
timeout: 3
|
||||
timeout_unit: day
|
||||
title: 人工介入
|
||||
type: human-input
|
||||
user_actions:
|
||||
- button_style: default
|
||||
id: action_1
|
||||
title: Button Text 1
|
||||
- button_style: default
|
||||
id: action_2
|
||||
title: Button Text 2
|
||||
- button_style: default
|
||||
id: action_3
|
||||
title: Button Text 3
|
||||
height: 216
|
||||
id: '1779786788846'
|
||||
position:
|
||||
x: 382
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 382
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1779786788846'
|
||||
- __action_id
|
||||
value_type: string
|
||||
variable: x
|
||||
selected: false
|
||||
title: 输出
|
||||
type: end
|
||||
height: 88
|
||||
id: '1779786805561'
|
||||
position:
|
||||
x: 685
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 685
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1779786788846'
|
||||
- __action_id
|
||||
value_type: string
|
||||
variable: x
|
||||
selected: false
|
||||
title: 输出 2
|
||||
type: end
|
||||
height: 88
|
||||
id: '1780468501391'
|
||||
position:
|
||||
x: 685
|
||||
y: 409
|
||||
positionAbsolute:
|
||||
x: 685
|
||||
y: 409
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1779786788846'
|
||||
- __action_id
|
||||
value_type: string
|
||||
variable: x
|
||||
selected: true
|
||||
title: 输出 3
|
||||
type: end
|
||||
height: 88
|
||||
id: '1780468504241'
|
||||
position:
|
||||
x: 685
|
||||
y: 500
|
||||
positionAbsolute:
|
||||
x: 685
|
||||
y: 500
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: 149
|
||||
y: 87
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
203
cli/test/e2e/fixtures/apps/hitl-multi-node.yml
Normal file
203
cli/test/e2e/fixtures/apps/hitl-multi-node.yml
Normal file
@ -0,0 +1,203 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: workflow
|
||||
name: DIFY_E2E_HITL_MULTI_NODE
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies: []
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: start
|
||||
targetType: human-input
|
||||
id: 1780469920434-source-1780469926552-target
|
||||
source: '1780469920434'
|
||||
sourceHandle: source
|
||||
target: '1780469926552'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: human-input
|
||||
targetType: human-input
|
||||
id: 1780469926552-action_1-1780469934902-target
|
||||
source: '1780469926552'
|
||||
sourceHandle: action_1
|
||||
target: '1780469934902'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: human-input
|
||||
targetType: end
|
||||
id: 1780469934902-action_1-1780469952268-target
|
||||
source: '1780469934902'
|
||||
sourceHandle: action_1
|
||||
target: '1780469952268'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
nodes:
|
||||
- data:
|
||||
selected: false
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables: []
|
||||
height: 73
|
||||
id: '1780469920434'
|
||||
position:
|
||||
x: 80
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 80
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
delivery_methods:
|
||||
- enabled: true
|
||||
id: bc4c7145-a3df-4d15-835f-4e2ebd7a8c16
|
||||
type: webapp
|
||||
form_content: ''
|
||||
inputs: []
|
||||
selected: false
|
||||
timeout: 3
|
||||
timeout_unit: day
|
||||
title: 人工介入
|
||||
type: human-input
|
||||
user_actions:
|
||||
- button_style: default
|
||||
id: action_1
|
||||
title: Button Text 1
|
||||
height: 164
|
||||
id: '1780469926552'
|
||||
position:
|
||||
x: 382
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 382
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
delivery_methods:
|
||||
- enabled: true
|
||||
id: d6e91d88-30bb-4397-a5a2-21c7c76382ea
|
||||
type: webapp
|
||||
form_content: ''
|
||||
inputs: []
|
||||
selected: false
|
||||
timeout: 3
|
||||
timeout_unit: day
|
||||
title: 人工介入 2
|
||||
type: human-input
|
||||
user_actions:
|
||||
- button_style: default
|
||||
id: action_1
|
||||
title: Button Text 1
|
||||
height: 164
|
||||
id: '1780469934902'
|
||||
position:
|
||||
x: 684
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 684
|
||||
y: 282
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1780469934902'
|
||||
- __action_id
|
||||
value_type: string
|
||||
variable: x
|
||||
selected: true
|
||||
title: 输出
|
||||
type: end
|
||||
height: 88
|
||||
id: '1780469952268'
|
||||
position:
|
||||
x: 986
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 986
|
||||
y: 282
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: 0
|
||||
y: 0
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
163
cli/test/e2e/fixtures/apps/hitl-single-action.yml
Normal file
163
cli/test/e2e/fixtures/apps/hitl-single-action.yml
Normal file
@ -0,0 +1,163 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: workflow
|
||||
name: DIFY_E2E_HITL_SINGLE_ACTION
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies: []
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: start
|
||||
targetType: human-input
|
||||
id: 1780469476206-source-1780469487574-target
|
||||
source: '1780469476206'
|
||||
sourceHandle: source
|
||||
target: '1780469487574'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: human-input
|
||||
targetType: end
|
||||
id: 1780469487574-action_1-1780469495040-target
|
||||
source: '1780469487574'
|
||||
sourceHandle: action_1
|
||||
target: '1780469495040'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
nodes:
|
||||
- data:
|
||||
selected: false
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables: []
|
||||
height: 73
|
||||
id: '1780469476206'
|
||||
position:
|
||||
x: 80
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 80
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
delivery_methods:
|
||||
- enabled: true
|
||||
id: 2375bc8b-8a9e-4ce6-a87c-9354e05735b7
|
||||
type: webapp
|
||||
form_content: ''
|
||||
inputs: []
|
||||
selected: true
|
||||
timeout: 3
|
||||
timeout_unit: day
|
||||
title: 人工介入
|
||||
type: human-input
|
||||
user_actions:
|
||||
- button_style: default
|
||||
id: action_1
|
||||
title: Button Text 1
|
||||
height: 164
|
||||
id: '1780469487574'
|
||||
position:
|
||||
x: 382
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 382
|
||||
y: 282
|
||||
selected: true
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1780469487574'
|
||||
- __action_id
|
||||
value_type: string
|
||||
variable: x
|
||||
selected: false
|
||||
title: 输出
|
||||
type: end
|
||||
height: 88
|
||||
id: '1780469495040'
|
||||
position:
|
||||
x: 684
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 684
|
||||
y: 282
|
||||
selected: false
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: 0
|
||||
y: 0
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
168
cli/test/e2e/fixtures/apps/ws2-workflow.yml
Normal file
168
cli/test/e2e/fixtures/apps/ws2-workflow.yml
Normal file
@ -0,0 +1,168 @@
|
||||
app:
|
||||
description: ''
|
||||
icon: 🤖
|
||||
icon_background: '#FFEAD5'
|
||||
icon_type: emoji
|
||||
mode: workflow
|
||||
name: auto_test_workspace2
|
||||
use_icon_as_answer_icon: false
|
||||
dependencies:
|
||||
- current_identifier: null
|
||||
type: marketplace
|
||||
value:
|
||||
marketplace_plugin_unique_identifier: langgenius/tongyi:0.1.48@966d88dc40611f067311c1c9839139ebc4b55bff471bc5e736dc3e828bc67b46
|
||||
version: null
|
||||
kind: app
|
||||
version: 0.6.0
|
||||
workflow:
|
||||
conversation_variables: []
|
||||
environment_variables: []
|
||||
features:
|
||||
file_upload:
|
||||
allowed_file_extensions:
|
||||
- .JPG
|
||||
- .JPEG
|
||||
- .PNG
|
||||
- .GIF
|
||||
- .WEBP
|
||||
- .SVG
|
||||
allowed_file_types:
|
||||
- image
|
||||
allowed_file_upload_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
enabled: false
|
||||
fileUploadConfig:
|
||||
attachment_image_file_size_limit: 2
|
||||
audio_file_size_limit: 50
|
||||
batch_count_limit: 5
|
||||
file_size_limit: 15
|
||||
file_upload_limit: 20
|
||||
image_file_batch_limit: 10
|
||||
image_file_size_limit: 10
|
||||
single_chunk_attachment_limit: 10
|
||||
video_file_size_limit: 100
|
||||
workflow_file_upload_limit: 10
|
||||
image:
|
||||
enabled: false
|
||||
number_limits: 3
|
||||
transfer_methods:
|
||||
- local_file
|
||||
- remote_url
|
||||
number_limits: 3
|
||||
opening_statement: ''
|
||||
retriever_resource:
|
||||
enabled: true
|
||||
sensitive_word_avoidance:
|
||||
enabled: false
|
||||
speech_to_text:
|
||||
enabled: false
|
||||
suggested_questions: []
|
||||
suggested_questions_after_answer:
|
||||
enabled: false
|
||||
text_to_speech:
|
||||
enabled: false
|
||||
language: ''
|
||||
voice: ''
|
||||
graph:
|
||||
edges:
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: start
|
||||
targetType: llm
|
||||
id: 1780305524693-source-1780305526186-target
|
||||
source: '1780305524693'
|
||||
sourceHandle: source
|
||||
target: '1780305526186'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
- data:
|
||||
isInIteration: false
|
||||
isInLoop: false
|
||||
sourceType: llm
|
||||
targetType: end
|
||||
id: 1780305526186-source-1780305600095-target
|
||||
source: '1780305526186'
|
||||
sourceHandle: source
|
||||
target: '1780305600095'
|
||||
targetHandle: target
|
||||
type: custom
|
||||
zIndex: 0
|
||||
nodes:
|
||||
- data:
|
||||
selected: false
|
||||
title: 用户输入
|
||||
type: start
|
||||
variables: []
|
||||
height: 73
|
||||
id: '1780305524693'
|
||||
position:
|
||||
x: 80
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 80
|
||||
y: 282
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
context:
|
||||
enabled: false
|
||||
variable_selector: []
|
||||
model:
|
||||
completion_params:
|
||||
temperature: 0.7
|
||||
mode: chat
|
||||
name: qwen3.6-plus
|
||||
provider: langgenius/tongyi/tongyi
|
||||
prompt_template:
|
||||
- id: cd753cdd-d950-44bf-99ad-7cb19f42d5b6
|
||||
role: system
|
||||
text: 输出内容:hello
|
||||
selected: false
|
||||
title: LLM
|
||||
type: llm
|
||||
vision:
|
||||
enabled: false
|
||||
height: 88
|
||||
id: '1780305526186'
|
||||
position:
|
||||
x: 382
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 382
|
||||
y: 282
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
- data:
|
||||
outputs:
|
||||
- value_selector:
|
||||
- '1780305526186'
|
||||
- text
|
||||
value_type: string
|
||||
variable: x
|
||||
selected: false
|
||||
title: 输出
|
||||
type: end
|
||||
height: 88
|
||||
id: '1780305600095'
|
||||
position:
|
||||
x: 684
|
||||
y: 282
|
||||
positionAbsolute:
|
||||
x: 684
|
||||
y: 282
|
||||
sourcePosition: right
|
||||
targetPosition: left
|
||||
type: custom
|
||||
width: 242
|
||||
viewport:
|
||||
x: -153
|
||||
y: 125
|
||||
zoom: 1
|
||||
rag_pipeline_variables: []
|
||||
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}`)
|
||||
}
|
||||
}
|
||||
501
cli/test/e2e/helpers/cli.ts
Normal file
501
cli/test/e2e/helpers/cli.ts
Normal file
@ -0,0 +1,501 @@
|
||||
/**
|
||||
* 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',
|
||||
// Force file-based token storage to avoid macOS keychain UI prompts
|
||||
// blocking child processes spawned by vitest workers.
|
||||
DIFY_E2E_NO_KEYRING: '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 ?? 60_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
|
||||
/** Account email — written into hosts.yml and used as the token store key. */
|
||||
email?: string
|
||||
/** Account display name. Defaults to the email local part. */
|
||||
accountName?: string
|
||||
/** Account ID written into hosts.yml when a test needs it. */
|
||||
accountId?: string
|
||||
/** Primary workspace to write into the bundle. */
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
workspaceRole?: string
|
||||
/** Full available workspace list. Defaults to the primary workspace only. */
|
||||
availableWorkspaces?: Array<{ id: string, name: string, role: 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
|
||||
}
|
||||
|
||||
export type SsoAuthInjectionOptions = {
|
||||
host: string
|
||||
bearer: string
|
||||
email?: string
|
||||
issuer?: string
|
||||
}
|
||||
|
||||
function splitHost(host: string): { bare: string, scheme: string } {
|
||||
const bare = (() => {
|
||||
try {
|
||||
return new URL(host).host || host
|
||||
}
|
||||
catch {
|
||||
return host
|
||||
}
|
||||
})()
|
||||
const scheme = (() => {
|
||||
try {
|
||||
return new URL(host).protocol.replace(':', '')
|
||||
}
|
||||
catch {
|
||||
return 'https'
|
||||
}
|
||||
})()
|
||||
return { bare, scheme }
|
||||
}
|
||||
|
||||
async function writeFileToken(configDir: string, host: string, email: string, bearer: string): Promise<void> {
|
||||
const dotParts = `tokens.${host}.${email}`.split('.')
|
||||
let yaml = ''
|
||||
for (let i = 0; i < dotParts.length - 1; i++) {
|
||||
yaml += `${' '.repeat(i) + dotParts[i]}:\n`
|
||||
}
|
||||
yaml += `${' '.repeat(dotParts.length - 1) + (dotParts[dotParts.length - 1] ?? '')}: "${bearer}"\n`
|
||||
await writeFile(join(configDir, 'tokens.yml'), yaml, { mode: 0o600 })
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
|
||||
// ── Derive bare host and scheme ───────────────────────────────────────────
|
||||
// difyctl stores the bare hostname (no scheme) as the registry key.
|
||||
// The scheme is stored separately in the host entry so hostWithScheme()
|
||||
// can reconstruct the full URL. Without scheme, difyctl defaults to https.
|
||||
const { bare, scheme } = splitHost(opts.host)
|
||||
const email = opts.email ?? 'e2e@example.com'
|
||||
const accountName = opts.accountName ?? email.split('@')[0] ?? ''
|
||||
const availableWorkspaces = opts.availableWorkspaces ?? [{
|
||||
id: opts.workspaceId,
|
||||
name: opts.workspaceName,
|
||||
role,
|
||||
}]
|
||||
|
||||
// ── hosts.yml ────────────────────────────────────────────────────────────
|
||||
// difyctl 0.1.0-rc.1 uses a nested registry format:
|
||||
// token_storage / current_host / hosts.<bareHost>.accounts.<email>.(workspace|...)
|
||||
// On macOS (keychain available) difyctl always uses the OS keychain for tokens.
|
||||
// We probe keychain availability the same way difyctl does: try a round-trip.
|
||||
// Always use file-based storage in E2E tests to avoid macOS keychain
|
||||
// UI prompts that block CLI child processes spawned by vitest workers.
|
||||
const canUseKeychain = false
|
||||
const storageMode = 'file' as const
|
||||
|
||||
const hostsYml = `${[
|
||||
`token_storage: ${storageMode}`,
|
||||
`current_host: ${bare}`,
|
||||
`hosts:`,
|
||||
` ${bare}:`,
|
||||
...(scheme !== 'https' ? [` scheme: ${scheme}`] : []),
|
||||
` current_account: ${email}`,
|
||||
` accounts:`,
|
||||
` ${email}:`,
|
||||
` account:`,
|
||||
...(opts.accountId !== undefined ? [` id: ${opts.accountId}`] : []),
|
||||
` email: ${email}`,
|
||||
` name: ${accountName}`,
|
||||
...(opts.tokenId !== undefined ? [` token_id: ${opts.tokenId}`] : []),
|
||||
` workspace:`,
|
||||
` id: ${opts.workspaceId}`,
|
||||
` name: "${opts.workspaceName}"`,
|
||||
` role: ${role}`,
|
||||
` available_workspaces:`,
|
||||
...availableWorkspaces.flatMap(workspace => [
|
||||
` - id: ${workspace.id}`,
|
||||
` name: "${workspace.name}"`,
|
||||
` role: ${workspace.role}`,
|
||||
]),
|
||||
].join('\n')}\n`
|
||||
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
|
||||
// ── Store bearer token ────────────────────────────────────────────────────
|
||||
// Token storage key: tokens.<bareHost>.<email> (dot-path for YamlStore.doGet)
|
||||
if (canUseKeychain) {
|
||||
// Write to OS keychain using the same service+account that difyctl uses:
|
||||
// service = "difyctl", account = tokenKey = "tokens.<bareHost>.<email>"
|
||||
// KeyringBasedStore.set JSON-encodes the value before storing.
|
||||
const { Entry } = await import('@napi-rs/keyring')
|
||||
const account = `tokens.${bare}.${email}`
|
||||
new Entry('difyctl', account).setPassword(JSON.stringify(opts.bearer))
|
||||
}
|
||||
else {
|
||||
// Fall back to tokens.yml.
|
||||
// YamlStore.doGet splits the key on '.' and traverses the nested object,
|
||||
// so "tokens.localhost.user@dify.ai" splits into 4 parts:
|
||||
// tokens -> localhost -> user@dify -> ai
|
||||
// The YAML must mirror that exact nesting.
|
||||
await writeFileToken(configDir, bare, email, opts.bearer)
|
||||
}
|
||||
}
|
||||
|
||||
export async function injectSsoAuth(configDir: string, opts: SsoAuthInjectionOptions): Promise<void> {
|
||||
await mkdir(configDir, { recursive: true, mode: 0o700 })
|
||||
|
||||
const { bare, scheme } = splitHost(opts.host)
|
||||
const email = opts.email ?? 'sso@example.com'
|
||||
const issuer = opts.issuer ?? 'https://issuer.example.com'
|
||||
const hostsYml = `${[
|
||||
`token_storage: file`,
|
||||
`current_host: ${bare}`,
|
||||
`hosts:`,
|
||||
` ${bare}:`,
|
||||
...(scheme !== 'https' ? [` scheme: ${scheme}`] : []),
|
||||
` current_account: ${email}`,
|
||||
` accounts:`,
|
||||
` ${email}:`,
|
||||
` account:`,
|
||||
` email: ""`,
|
||||
` name: ""`,
|
||||
` external_subject:`,
|
||||
` email: ${email}`,
|
||||
` issuer: ${issuer}`,
|
||||
].join('\n')}\n`
|
||||
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
await writeFileToken(configDir, bare, email, opts.bearer)
|
||||
}
|
||||
|
||||
// ── 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 ?? 60_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, email?: string },
|
||||
): Promise<AuthFixture> {
|
||||
const { configDir, cleanup } = await withTempConfig()
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
email: E.email,
|
||||
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(20_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(20_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(20_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))
|
||||
}
|
||||
54
cli/test/e2e/helpers/skip.ts
Normal file
54
cli/test/e2e/helpers/skip.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import type { SuiteAPI, TestAPI } from 'vitest'
|
||||
import type { DifyEdition, E2ECapabilities } from '../setup/env.js'
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
// Explicit casts bridge the ChainableFunction vs SuiteAPI/TestAPI
|
||||
// incompatibility introduced in vite-plus-test@0.1.22 (TS2322 / TS4058).
|
||||
// Using 'unknown' as an intermediate to satisfy strict no-explicit-any rules.
|
||||
export function optionalDescribe(condition: boolean): SuiteAPI {
|
||||
return (condition ? describe : describe.skip) as unknown as SuiteAPI
|
||||
}
|
||||
|
||||
export function optionalIt(condition: boolean): TestAPI {
|
||||
return (condition ? it : it.skip) as unknown as TestAPI
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an `it` variant that only runs in Enterprise Edition (EE) mode.
|
||||
*
|
||||
* Usage:
|
||||
* const eeIt = enterpriseOnlyIt(caps)
|
||||
* eeIt('[EE][P0] workspace switching works across two workspaces', async () => { … })
|
||||
*
|
||||
* In CE mode the test is automatically skipped with a clear label.
|
||||
* The [EE] tag in the test name is purely informational and documents the
|
||||
* requirement in the test report.
|
||||
*/
|
||||
export function enterpriseOnlyIt(caps: E2ECapabilities): TestAPI {
|
||||
return optionalIt(caps.edition === 'ee')
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a `describe` variant that only runs in Enterprise Edition (EE) mode.
|
||||
*
|
||||
* Usage:
|
||||
* const eeDescribe = enterpriseOnlyDescribe(caps)
|
||||
* eeDescribe('[EE] cross-workspace suite', () => { … })
|
||||
*/
|
||||
export function enterpriseOnlyDescribe(caps: E2ECapabilities): SuiteAPI {
|
||||
return optionalDescribe(caps.edition === 'ee')
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: return true when capabilities indicate Enterprise Edition.
|
||||
* Use for inline guards inside regular `it` blocks.
|
||||
*
|
||||
* @example
|
||||
* it('cross-workspace query [EE]', async () => {
|
||||
* if (!isEE(caps)) return // skip silently in CE
|
||||
* …
|
||||
* })
|
||||
*/
|
||||
export function isEE(caps: { edition: DifyEdition }): boolean {
|
||||
return caps.edition === 'ee'
|
||||
}
|
||||
13
cli/test/e2e/helpers/vitest-context.ts
Normal file
13
cli/test/e2e/helpers/vitest-context.ts
Normal file
@ -0,0 +1,13 @@
|
||||
// ProvidedContext augmentation intentionally omitted.
|
||||
//
|
||||
// Both 'vitest' and '@voidzero-dev/vite-plus-test' module augmentation paths
|
||||
// cause errors under the tsgo type-checker used by the Main CI pipeline:
|
||||
// - Augmenting 'vitest' → TS2300 duplicate identifier (re-exported in @0.1.22)
|
||||
// - Augmenting '@voidzero-dev/vite-plus-test' → TS2664 module not found
|
||||
// (tsgo runs in cli/ and cannot resolve pnpm virtual-store symlinks)
|
||||
//
|
||||
// The three call sites (global-setup, devices, logout) use @ts-ignore to
|
||||
// suppress the TS2345 / TS2339 errors locally. Runtime behaviour is correct
|
||||
// because project.provide() / inject() work via string keys at runtime
|
||||
// regardless of the compile-time type constraint.
|
||||
export {}
|
||||
210
cli/test/e2e/setup/env.ts
Normal file
210
cli/test/e2e/setup/env.ts
Normal file
@ -0,0 +1,210 @@
|
||||
/**
|
||||
* E2E environment configuration.
|
||||
*
|
||||
* ── Edition modes ─────────────────────────────────────────────────────────
|
||||
*
|
||||
* Community Edition (CE) — default, set DIFY_E2E_EDITION=ce or leave unset.
|
||||
* Required: DIFY_E2E_HOST, DIFY_E2E_EMAIL, DIFY_E2E_PASSWORD
|
||||
* global-setup registers the account (idempotent), mints tokens, imports
|
||||
* all DSL fixtures into the single workspace, and publishes apps.
|
||||
*
|
||||
* Enterprise Edition (EE) — set DIFY_E2E_EDITION=ee.
|
||||
* Required: DIFY_E2E_HOST, DIFY_E2E_EMAIL, DIFY_E2E_PASSWORD
|
||||
* The operator must pre-create two workspaces for the test account:
|
||||
* primary → named "auto_test0"
|
||||
* secondary → named "auto_test1"
|
||||
* global-setup logs in, discovers the two workspaces by name, imports DSL
|
||||
* fixtures into both, publishes apps, and sets access_mode → public.
|
||||
*
|
||||
* ── EE-only test cases ────────────────────────────────────────────────────
|
||||
* Tests that require multiple workspaces or EE-specific features are tagged
|
||||
* [EE] and wrapped with enterpriseOnlyIt() / enterpriseOnlyDescribe() from
|
||||
* helpers/skip.ts. They are automatically skipped in CE mode.
|
||||
*
|
||||
* ── Optional env-var overrides (both editions) ────────────────────────────
|
||||
* DIFY_E2E_TOKEN Pre-minted bearer token — skips device-flow mint
|
||||
* DIFY_E2E_SSO_TOKEN External SSO bearer token (dfoe_ prefix)
|
||||
* DIFY_E2E_CONSOLE_URL Console URL when different from DIFY_E2E_HOST
|
||||
* DIFY_E2E_WORKSPACE_ID Override primary workspace ID
|
||||
* DIFY_E2E_WORKSPACE_NAME Override primary workspace name
|
||||
* DIFY_E2E_WS2_ID Override secondary workspace ID (EE)
|
||||
* DIFY_E2E_WS2_APP_ID Override secondary workspace app ID (EE)
|
||||
* DIFY_E2E_CHAT_APP_ID Override echo-chat app ID
|
||||
* DIFY_E2E_WORKFLOW_APP_ID Override echo-workflow app ID
|
||||
* DIFY_E2E_FILE_APP_ID Override file-upload app ID
|
||||
* DIFY_E2E_FILE_CHAT_APP_ID Override file-chat app ID
|
||||
* DIFY_E2E_HITL_APP_ID Override HITL main app ID
|
||||
* DIFY_E2E_HITL_EXTERNAL_APP_ID
|
||||
* DIFY_E2E_HITL_SINGLE_ACTION_APP_ID
|
||||
* DIFY_E2E_HITL_MULTI_NODE_APP_ID
|
||||
*/
|
||||
|
||||
/** Supported edition values. */
|
||||
export type DifyEdition = 'ce' | 'ee'
|
||||
|
||||
export type E2EEnv = {
|
||||
/** Staging server base URL (API endpoint) */
|
||||
host: string
|
||||
/**
|
||||
* Edition: "ce" (Community Edition, default) or "ee" (Enterprise Edition).
|
||||
* Controls which global-setup path runs and which test cases are active.
|
||||
*/
|
||||
edition: DifyEdition
|
||||
/** 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 (display_in_ui=true) — empty when not configured */
|
||||
hitlAppId: string
|
||||
/** Workflow app with HITL node (display_in_ui=false) — empty when not configured */
|
||||
hitlExternalAppId: string
|
||||
/** Workflow app with HITL node (display_in_ui=true, exactly 1 action) */
|
||||
hitlSingleActionAppId: string
|
||||
/** Workflow app with 2 serial Human-Input nodes */
|
||||
hitlMultiNodeAppId: string
|
||||
/** Workflow app with file input (doc variable) */
|
||||
fileAppId: string
|
||||
/** Chat app (advanced-chat) with a file input variable */
|
||||
fileChatAppId: string
|
||||
/**
|
||||
* Secondary workspace ID — EE only ("auto_test1").
|
||||
* Empty in CE mode (CE has a single workspace).
|
||||
*/
|
||||
ws2Id: string
|
||||
/** App ID inside the secondary workspace — EE only. Empty in CE mode. */
|
||||
ws2AppId: string
|
||||
/** Console account email */
|
||||
email: string
|
||||
/** Console account password (plain-text; Base64-encoded before sending) */
|
||||
password: string
|
||||
/**
|
||||
* Console URL — defaults to `host` when not set separately.
|
||||
* Useful when the API host and the console host differ.
|
||||
*/
|
||||
consoleUrl: string
|
||||
}
|
||||
|
||||
export type E2ECapabilities = {
|
||||
tokenValid: boolean
|
||||
tokenId?: string
|
||||
/**
|
||||
* Edition resolved by global-setup — "ce" or "ee".
|
||||
* Injected into every test file so helpers/skip.ts can gate EE-only cases.
|
||||
*/
|
||||
edition: DifyEdition
|
||||
/** Primary bearer token minted by global-setup via the device flow. */
|
||||
token: string
|
||||
/**
|
||||
* Per-suite dedicated tokens — each destructive suite (logout, devices)
|
||||
* gets its own fresh dfoa_ token so revoking it never kills the main token.
|
||||
*/
|
||||
logoutToken: string
|
||||
devicesToken: string
|
||||
/** Primary workspace info. */
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
/** Secondary workspace ID (EE only). Empty string in CE mode. */
|
||||
ws2Id: string
|
||||
/** App IDs resolved by provisionApps. Empty = fall back to env var. */
|
||||
chatAppId: string
|
||||
workflowAppId: string
|
||||
fileAppId: string
|
||||
fileChatAppId: string
|
||||
hitlAppId: string
|
||||
hitlExternalAppId: string
|
||||
hitlSingleActionAppId: string
|
||||
hitlMultiNodeAppId: string
|
||||
ws2AppId: string
|
||||
}
|
||||
|
||||
let _cached: E2EEnv | undefined
|
||||
|
||||
/** Return true when running in Enterprise Edition mode. */
|
||||
export function isEnterpriseEdition(): boolean {
|
||||
return (process.env.DIFY_E2E_EDITION ?? 'ce').toLowerCase() === 'ee'
|
||||
}
|
||||
|
||||
/** Load and validate E2E environment variables. Throws if required vars are missing. */
|
||||
export function loadE2EEnv(): E2EEnv {
|
||||
if (_cached !== undefined)
|
||||
return _cached
|
||||
|
||||
const edition: DifyEdition = isEnterpriseEdition() ? 'ee' : 'ce'
|
||||
|
||||
// Same 3 required vars for both CE and EE.
|
||||
const required: Array<[keyof NodeJS.ProcessEnv, string]> = [
|
||||
['DIFY_E2E_HOST', 'Staging server URL'],
|
||||
['DIFY_E2E_EMAIL', 'Console account email'],
|
||||
['DIFY_E2E_PASSWORD', 'Console account password'],
|
||||
]
|
||||
|
||||
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`
|
||||
+ `Edition: ${edition.toUpperCase()}\n`
|
||||
+ 'See test/e2e/setup/env.ts for documentation.',
|
||||
)
|
||||
}
|
||||
|
||||
_cached = {
|
||||
host: process.env.DIFY_E2E_HOST!,
|
||||
edition,
|
||||
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 ?? '',
|
||||
chatAppId: process.env.DIFY_E2E_CHAT_APP_ID ?? '',
|
||||
workflowAppId: process.env.DIFY_E2E_WORKFLOW_APP_ID ?? '',
|
||||
hitlAppId: process.env.DIFY_E2E_HITL_APP_ID ?? '',
|
||||
hitlExternalAppId: process.env.DIFY_E2E_HITL_EXTERNAL_APP_ID ?? '',
|
||||
hitlSingleActionAppId: process.env.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID ?? '',
|
||||
hitlMultiNodeAppId: process.env.DIFY_E2E_HITL_MULTI_NODE_APP_ID ?? '',
|
||||
fileAppId: process.env.DIFY_E2E_FILE_APP_ID ?? '',
|
||||
fileChatAppId: process.env.DIFY_E2E_FILE_CHAT_APP_ID ?? '',
|
||||
ws2Id: process.env.DIFY_E2E_WS2_ID ?? '',
|
||||
ws2AppId: process.env.DIFY_E2E_WS2_APP_ID ?? '',
|
||||
email: process.env.DIFY_E2E_EMAIL!,
|
||||
password: process.env.DIFY_E2E_PASSWORD!,
|
||||
consoleUrl: process.env.DIFY_E2E_CONSOLE_URL ?? process.env.DIFY_E2E_HOST!,
|
||||
}
|
||||
return _cached
|
||||
}
|
||||
|
||||
export function isE2ELocalMode(): boolean {
|
||||
return process.env.DIFY_E2E_MODE === 'local'
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the E2E environment, merging capabilities (from global-setup) on top
|
||||
* of the optional env-var overrides. Capabilities always take priority.
|
||||
*/
|
||||
export function resolveEnv(caps: E2ECapabilities): E2EEnv {
|
||||
const env = loadE2EEnv()
|
||||
return {
|
||||
...env,
|
||||
edition: caps.edition || env.edition,
|
||||
token: caps.token || env.token,
|
||||
workspaceId: caps.workspaceId || env.workspaceId,
|
||||
workspaceName: caps.workspaceName || env.workspaceName,
|
||||
ws2Id: caps.ws2Id || env.ws2Id,
|
||||
chatAppId: caps.chatAppId || env.chatAppId,
|
||||
workflowAppId: caps.workflowAppId || env.workflowAppId,
|
||||
fileAppId: caps.fileAppId || env.fileAppId,
|
||||
fileChatAppId: caps.fileChatAppId || env.fileChatAppId,
|
||||
hitlAppId: caps.hitlAppId || env.hitlAppId,
|
||||
hitlExternalAppId: caps.hitlExternalAppId || env.hitlExternalAppId,
|
||||
hitlSingleActionAppId: caps.hitlSingleActionAppId || env.hitlSingleActionAppId,
|
||||
hitlMultiNodeAppId: caps.hitlMultiNodeAppId || env.hitlMultiNodeAppId,
|
||||
ws2AppId: caps.ws2AppId || env.ws2AppId,
|
||||
}
|
||||
}
|
||||
704
cli/test/e2e/setup/global-setup.ts
Normal file
704
cli/test/e2e/setup/global-setup.ts
Normal file
@ -0,0 +1,704 @@
|
||||
/**
|
||||
* Vitest global setup — runs once before all E2E suites.
|
||||
*
|
||||
* ── CE path (DIFY_E2E_EDITION=ce or unset) ───────────────────────────────
|
||||
* 1. Register a new account with EMAIL/PASSWORD (idempotent).
|
||||
* 2. Login to obtain a session cookie.
|
||||
* 3. Mint the primary bearer token via the device flow.
|
||||
* 4. Validate the token.
|
||||
* 5. Discover the single workspace (falls back to first available).
|
||||
* 6. Mint per-suite dedicated tokens (logout / devices suites).
|
||||
* 7. Import all DSL fixtures into the workspace, publish & set public.
|
||||
*
|
||||
* ── EE path (DIFY_E2E_EDITION=ee) ────────────────────────────────────────
|
||||
* Workspaces are pre-created by the operator and must be named:
|
||||
* primary → "auto_test0"
|
||||
* secondary → "auto_test1"
|
||||
*
|
||||
* 1. Login with EMAIL/PASSWORD to obtain a session cookie.
|
||||
* 2. Mint the primary bearer token via the device flow.
|
||||
* 3. Validate the token.
|
||||
* 4. Discover "auto_test0" (primary) and "auto_test1" (secondary) workspaces.
|
||||
* 5. Mint per-suite dedicated tokens.
|
||||
* 6. Import DSL fixtures into primary workspace; import ws2-workflow.yml
|
||||
* into the secondary workspace. Publish & set access_mode → public.
|
||||
*/
|
||||
|
||||
import type { TestProject } from 'vitest/node'
|
||||
import type { E2ECapabilities } from './env.js'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { readFile, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { loadE2EEnv } from './env.js'
|
||||
|
||||
const TOKEN_MINT_APPROVE_ATTEMPTS = 5
|
||||
const TOKEN_MINT_RETRY_BASE_MS = 2_000
|
||||
|
||||
export async function setup(project: TestProject): Promise<void> {
|
||||
if (process.env.DIFY_E2E_MODE === 'local')
|
||||
return
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const consoleBase = E.consoleUrl.replace(/\/$/, '')
|
||||
const apiBase = E.host.replace(/\/$/, '')
|
||||
|
||||
console.warn(`[E2E global-setup] Edition: ${E.edition.toUpperCase()}`)
|
||||
|
||||
// ── Account bootstrap ────────────────────────────────────────────────────
|
||||
if (E.edition === 'ce') {
|
||||
await ceRegisterAccount(consoleBase, E.email, E.password)
|
||||
}
|
||||
// EE: account & workspaces are pre-provisioned by the operator — just login.
|
||||
|
||||
// ── Login ────────────────────────────────────────────────────────────────
|
||||
const { cookieString, csrfToken } = await consoleLogin(consoleBase, E.email, E.password)
|
||||
|
||||
// ── Mint primary token (with local cache to avoid rate-limit) ──────────
|
||||
// Priority: DIFY_E2E_TOKEN env → .token-cache.json → fresh mint
|
||||
// The cache file lives next to .env.e2e and is git-ignored.
|
||||
// logoutToken/devicesToken are intentionally NOT cached — those suites
|
||||
// revoke their token, so they always need a fresh one.
|
||||
const TOKEN_CACHE = join(process.cwd(), '.token-cache.json')
|
||||
|
||||
async function loadCachedToken(): Promise<string> {
|
||||
try {
|
||||
const raw = await readFile(TOKEN_CACHE, 'utf8')
|
||||
const { token, host } = JSON.parse(raw) as { token?: string, host?: string }
|
||||
// Invalidate if host changed (different staging env)
|
||||
if (!token || host !== E.host)
|
||||
return ''
|
||||
return token
|
||||
}
|
||||
catch { return '' }
|
||||
}
|
||||
|
||||
async function saveCachedToken(token: string): Promise<void> {
|
||||
try {
|
||||
await writeFile(TOKEN_CACHE, JSON.stringify({ token, host: E.host }, null, 2), 'utf8')
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`[E2E] Could not save token cache: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function validateToken(token: string): Promise<boolean> {
|
||||
try {
|
||||
const r = await fetch(`${apiBase}/openapi/v1/account/sessions?page=1&limit=100`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
})
|
||||
return r.ok
|
||||
}
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
let primaryToken = E.token
|
||||
if (primaryToken) {
|
||||
console.warn(`[E2E] primaryToken from env: ${primaryToken.slice(0, 20)}…`)
|
||||
}
|
||||
else {
|
||||
// Try cache first
|
||||
const cached = await loadCachedToken()
|
||||
if (cached && await validateToken(cached)) {
|
||||
primaryToken = cached
|
||||
console.warn(`[E2E] primaryToken from cache: ${primaryToken.slice(0, 20)}…`)
|
||||
}
|
||||
else {
|
||||
if (cached)
|
||||
console.warn('[E2E] Cached token invalid or expired — re-minting…')
|
||||
try {
|
||||
primaryToken = await mintTokenWithSession(consoleBase, cookieString, csrfToken, 'e2e-primary')
|
||||
await saveCachedToken(primaryToken)
|
||||
console.warn(`[E2E] primaryToken minted and cached: ${primaryToken.slice(0, 20)}…`)
|
||||
}
|
||||
catch (err) {
|
||||
throw new Error(
|
||||
`[E2E global-setup] Failed to mint primary token: ${err}\n`
|
||||
+ 'Ensure DIFY_E2E_EMAIL and DIFY_E2E_PASSWORD are correct.',
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Validate primary token ───────────────────────────────────────────────
|
||||
const sessionsUrl = `${apiBase}/openapi/v1/account/sessions?page=1&limit=100`
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(sessionsUrl, {
|
||||
headers: { Authorization: `Bearer ${primaryToken}` },
|
||||
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] Primary token is invalid or expired (HTTP ${res.status}).\n`
|
||||
+ `URL: ${sessionsUrl}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.warn(`[E2E] Server healthy, primary token valid at ${E.host}`)
|
||||
|
||||
// ── Resolve token_id ─────────────────────────────────────────────────────
|
||||
const body = await res.json() as { data: Array<{ id: string, prefix: string }> }
|
||||
const match = body.data.find(s => s.prefix !== '' && primaryToken.startsWith(s.prefix))
|
||||
if (!match) {
|
||||
console.warn('[E2E global-setup] Could not resolve token_id — devicesToken selfHit detection may not work')
|
||||
}
|
||||
else {
|
||||
console.warn(`[E2E] Resolved token_id: ${match.id}`)
|
||||
}
|
||||
|
||||
// ── Discover workspaces ──────────────────────────────────────────────────
|
||||
const workspaces = await discoverWorkspaces(
|
||||
consoleBase,
|
||||
cookieString,
|
||||
csrfToken,
|
||||
E.edition,
|
||||
)
|
||||
if (!workspaces) {
|
||||
project.provide('e2eCapabilities', {
|
||||
tokenValid: true,
|
||||
tokenId: match?.id,
|
||||
edition: E.edition,
|
||||
token: primaryToken,
|
||||
logoutToken: '',
|
||||
devicesToken: '',
|
||||
workspaceId: '',
|
||||
workspaceName: '',
|
||||
ws2Id: '',
|
||||
chatAppId: '',
|
||||
workflowAppId: '',
|
||||
fileAppId: '',
|
||||
fileChatAppId: '',
|
||||
hitlAppId: '',
|
||||
hitlExternalAppId: '',
|
||||
hitlSingleActionAppId: '',
|
||||
hitlMultiNodeAppId: '',
|
||||
ws2AppId: '',
|
||||
} satisfies E2ECapabilities)
|
||||
return
|
||||
}
|
||||
const { primaryWsId, primaryWsName, secondaryWsId } = workspaces
|
||||
|
||||
// ── Mint per-suite dedicated tokens ──────────────────────────────────────
|
||||
let logoutToken = ''
|
||||
let devicesToken = ''
|
||||
|
||||
const mint = (label: string) => mintTokenWithSession(consoleBase, cookieString, csrfToken, label)
|
||||
const [lt, dt] = await Promise.allSettled([
|
||||
mint('e2e-logout-suite'),
|
||||
mint('e2e-devices-suite'),
|
||||
])
|
||||
|
||||
if (lt.status === 'fulfilled') {
|
||||
logoutToken = lt.value
|
||||
console.warn(`[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.warn(`[E2E] devicesToken minted: ${devicesToken.slice(0, 20)}…`)
|
||||
}
|
||||
else {
|
||||
console.warn(`[E2E global-setup] Failed to mint devicesToken: ${dt.reason}`)
|
||||
}
|
||||
|
||||
// ── Provision fixture apps ───────────────────────────────────────────────
|
||||
// Skip provisionApps when app IDs are already injected via DIFY_E2E_*_APP_ID
|
||||
// environment variables (e.g. from the CI provision job). Running provisionApps
|
||||
// in every parallel suite job causes race conditions: multiple jobs query
|
||||
// findAppByName simultaneously, all get "not found", then each imports the DSL
|
||||
// independently — creating duplicate apps per workspace.
|
||||
let provisionedIds: Record<string, string> = {}
|
||||
const preProvisioned = [
|
||||
'DIFY_E2E_CHAT_APP_ID',
|
||||
'DIFY_E2E_WORKFLOW_APP_ID',
|
||||
'DIFY_E2E_FILE_APP_ID',
|
||||
'DIFY_E2E_FILE_CHAT_APP_ID',
|
||||
'DIFY_E2E_HITL_APP_ID',
|
||||
'DIFY_E2E_HITL_EXTERNAL_APP_ID',
|
||||
'DIFY_E2E_HITL_SINGLE_ACTION_APP_ID',
|
||||
'DIFY_E2E_HITL_MULTI_NODE_APP_ID',
|
||||
'DIFY_E2E_WS2_APP_ID',
|
||||
]
|
||||
const envAppIds: Record<string, string> = {}
|
||||
for (const key of preProvisioned) {
|
||||
const val = process.env[key]
|
||||
if (val && val !== '')
|
||||
envAppIds[key] = val
|
||||
}
|
||||
const allPreset = preProvisioned.every(k => envAppIds[k] !== undefined)
|
||||
|
||||
if (allPreset) {
|
||||
// All app IDs already available via env — skip provisioning to avoid
|
||||
// race conditions in parallel CI jobs.
|
||||
provisionedIds = envAppIds
|
||||
console.warn(`[E2E global-setup] App IDs pre-set via env — skipping provisionApps (${Object.keys(provisionedIds).length} apps)`)
|
||||
}
|
||||
else {
|
||||
try {
|
||||
const fixturesDir = join(fileURLToPath(import.meta.url), '..', '..', 'fixtures', 'apps')
|
||||
provisionedIds = await provisionApps(
|
||||
consoleBase,
|
||||
cookieString,
|
||||
csrfToken,
|
||||
primaryWsId,
|
||||
secondaryWsId,
|
||||
fixturesDir,
|
||||
E.edition,
|
||||
)
|
||||
console.warn(`[E2E global-setup] Provisioned ${Object.keys(provisionedIds).length} fixture apps`)
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`[E2E global-setup] provisionApps failed (non-fatal): ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Provide capabilities ─────────────────────────────────────────────────
|
||||
const capabilities: E2ECapabilities = {
|
||||
tokenValid: true,
|
||||
tokenId: match?.id,
|
||||
edition: E.edition,
|
||||
token: primaryToken,
|
||||
logoutToken,
|
||||
devicesToken,
|
||||
workspaceId: primaryWsId,
|
||||
workspaceName: primaryWsName,
|
||||
ws2Id: secondaryWsId,
|
||||
chatAppId: provisionedIds.DIFY_E2E_CHAT_APP_ID || E.chatAppId,
|
||||
workflowAppId: provisionedIds.DIFY_E2E_WORKFLOW_APP_ID || E.workflowAppId,
|
||||
fileAppId: provisionedIds.DIFY_E2E_FILE_APP_ID || E.fileAppId,
|
||||
fileChatAppId: provisionedIds.DIFY_E2E_FILE_CHAT_APP_ID || E.fileChatAppId,
|
||||
hitlAppId: provisionedIds.DIFY_E2E_HITL_APP_ID || E.hitlAppId,
|
||||
hitlExternalAppId: provisionedIds.DIFY_E2E_HITL_EXTERNAL_APP_ID || E.hitlExternalAppId,
|
||||
hitlSingleActionAppId: provisionedIds.DIFY_E2E_HITL_SINGLE_ACTION_APP_ID || E.hitlSingleActionAppId,
|
||||
hitlMultiNodeAppId: provisionedIds.DIFY_E2E_HITL_MULTI_NODE_APP_ID || E.hitlMultiNodeAppId,
|
||||
ws2AppId: provisionedIds.DIFY_E2E_WS2_APP_ID || E.ws2AppId,
|
||||
}
|
||||
|
||||
// @ts-expect-error — ProvidedContext augmentation cannot be expressed without
|
||||
// triggering TS2300 or TS2664 under tsgo; safe at runtime.
|
||||
project.provide('e2eCapabilities', capabilities)
|
||||
}
|
||||
|
||||
export { teardown } from './global-teardown.js'
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// CE — account registration
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/**
|
||||
* Register a CE account idempotently.
|
||||
* Tries /init (fresh server) first, then falls back to /register.
|
||||
* A 409 "already exists" response is treated as success.
|
||||
*/
|
||||
async function ceRegisterAccount(consoleBase: string, email: string, password: string): Promise<void> {
|
||||
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
|
||||
const name = email.split('@')[0] ?? 'e2e-user'
|
||||
|
||||
const initRes = await fetch(`${consoleBase}/console/api/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, name, password: passwordB64 }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (initRes.ok || initRes.status === 409) {
|
||||
console.warn(`[E2E CE] Account ready via /init (status ${initRes.status})`)
|
||||
return
|
||||
}
|
||||
|
||||
const registerRes = await fetch(`${consoleBase}/console/api/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, name, password: passwordB64 }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
|
||||
if (!registerRes.ok && registerRes.status !== 409) {
|
||||
console.warn(
|
||||
`[E2E CE] /register returned HTTP ${registerRes.status} — account may already exist; continuing`,
|
||||
)
|
||||
}
|
||||
else {
|
||||
console.warn(`[E2E CE] Account ready via /register (status ${registerRes.status})`)
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// Shared helpers
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
// ── Console login ─────────────────────────────────────────────────────────
|
||||
|
||||
async function consoleLogin(
|
||||
consoleBase: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ cookieString: string, csrfToken: string }> {
|
||||
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
|
||||
const loginRes = await fetch(`${consoleBase}/console/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (!loginRes.ok)
|
||||
throw new Error(`console/api/login failed: HTTP ${loginRes.status}`)
|
||||
|
||||
const setCookies = loginRes.headers.getSetCookie?.() ?? []
|
||||
const cookieString = setCookies.map(c => c.split(';')[0]).join('; ')
|
||||
const csrfToken = (cookieString.match(/csrf_token=([^;]+)/) ?? [])[1] ?? ''
|
||||
return { cookieString, csrfToken }
|
||||
}
|
||||
|
||||
// ── Workspace discovery ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Discover primary and secondary workspaces.
|
||||
*
|
||||
* CE: looks for any workspace with "auto" in its name; falls back to the
|
||||
* first available workspace. secondaryWsId === primaryWsId when only
|
||||
* one workspace exists.
|
||||
*
|
||||
* EE: looks for workspaces named exactly "auto_test0" (primary) and
|
||||
* "auto_test1" (secondary). These must be pre-created by the operator.
|
||||
* Throws if "auto_test0" is not found.
|
||||
*/
|
||||
async function discoverWorkspaces(
|
||||
consoleBase: string,
|
||||
cookieString: string,
|
||||
csrfToken: string,
|
||||
edition: 'ce' | 'ee',
|
||||
): Promise<{ primaryWsId: string, primaryWsName: string, secondaryWsId: string } | null> {
|
||||
const wsRes = await fetch(`${consoleBase}/console/api/workspaces`, {
|
||||
headers: { 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!wsRes.ok)
|
||||
throw new Error(`list workspaces failed: HTTP ${wsRes.status}`)
|
||||
|
||||
const wsBody = await wsRes.json() as {
|
||||
workspaces?: Array<{ id: string, name: string }>
|
||||
}
|
||||
const all = wsBody.workspaces ?? []
|
||||
|
||||
if (edition === 'ee') {
|
||||
// EE: must find the two pre-created workspaces by exact name
|
||||
const ws0 = all.find(w => w.name === 'auto_test0')
|
||||
const ws1 = all.find(w => w.name === 'auto_test1')
|
||||
|
||||
if (!ws0 || !ws1) {
|
||||
const existing = all.map(w => w.name).join(', ') || '(none)'
|
||||
console.warn(
|
||||
`[E2E EE] Required workspaces not found; expected auto_test0 and auto_test1, got: ${existing}. `
|
||||
+ 'Skip fixture app provisioning.',
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const primaryWsId = ws0.id
|
||||
const primaryWsName = ws0.name
|
||||
const secondaryWsId = ws1.id
|
||||
|
||||
console.warn(`[E2E EE] primary workspace: ${primaryWsName} (${primaryWsId})`)
|
||||
console.warn(`[E2E EE] secondary workspace: ${ws1.name} (${secondaryWsId})`)
|
||||
|
||||
return { primaryWsId, primaryWsName, secondaryWsId }
|
||||
}
|
||||
|
||||
// CE: look for workspaces with "auto" in the name, sorted alphabetically
|
||||
const autoWorkspaces = all
|
||||
.filter(w => w.name.toLowerCase().includes('auto'))
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
|
||||
if (autoWorkspaces.length > 0) {
|
||||
const primaryWsId = autoWorkspaces[0]!.id
|
||||
const primaryWsName = autoWorkspaces[0]!.name
|
||||
const secondaryWsId = autoWorkspaces[1]?.id ?? primaryWsId
|
||||
console.warn(`[E2E CE] primary workspace: ${primaryWsName} (${primaryWsId})`)
|
||||
if (autoWorkspaces[1])
|
||||
console.warn(`[E2E CE] secondary workspace: ${autoWorkspaces[1].name} (${secondaryWsId})`)
|
||||
else
|
||||
console.warn('[E2E CE] only one "auto" workspace found — ws2 reuses primary')
|
||||
return { primaryWsId, primaryWsName, secondaryWsId }
|
||||
}
|
||||
|
||||
// CE fallback: use the first available workspace
|
||||
if (all.length === 0)
|
||||
throw new Error('[E2E CE] No workspaces found for this account')
|
||||
|
||||
const primaryWsId = all[0]!.id
|
||||
const primaryWsName = all[0]!.name
|
||||
console.warn(`[E2E CE] primary workspace (fallback): ${primaryWsName} (${primaryWsId})`)
|
||||
return { primaryWsId, primaryWsName, secondaryWsId: primaryWsId }
|
||||
}
|
||||
|
||||
// ── App provisioning ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Idempotently provision all E2E fixture apps.
|
||||
*
|
||||
* CE: imports all primary-workspace fixtures; skips ws2-workflow.yml
|
||||
* (no real secondary workspace).
|
||||
*
|
||||
* EE: imports primary-workspace fixtures into auto_test0, and
|
||||
* ws2-workflow.yml into auto_test1.
|
||||
*
|
||||
* Per app:
|
||||
* 1. Switch to the target workspace
|
||||
* 2. Search by app name — reuse existing app when found
|
||||
* 3. If not found → import from DSL file
|
||||
* 4. Enable Service API
|
||||
* 5. Publish (workflow / advanced-chat / agent-chat only)
|
||||
* 6. Set access_mode → public
|
||||
*/
|
||||
async function provisionApps(
|
||||
consoleBase: string,
|
||||
cookieString: string,
|
||||
csrfToken: string,
|
||||
primaryWsId: string,
|
||||
secondaryWsId: string,
|
||||
fixturesDir: string,
|
||||
edition: 'ce' | 'ee',
|
||||
): Promise<Record<string, string>> {
|
||||
const NEEDS_PUBLISH = new Set(['workflow', 'advanced-chat', 'agent-chat'])
|
||||
|
||||
const mkHeaders = (extra: Record<string, string> = {}): Record<string, string> => ({
|
||||
'Cookie': cookieString,
|
||||
'X-CSRF-Token': csrfToken,
|
||||
...extra,
|
||||
})
|
||||
|
||||
// ws2-workflow.yml is only provisioned in EE mode (real secondary workspace)
|
||||
const APP_SPECS: Array<[string, string, string]> = [
|
||||
['echo-chat.yml', 'DIFY_E2E_CHAT_APP_ID', primaryWsId],
|
||||
['echo-workflow.yml', 'DIFY_E2E_WORKFLOW_APP_ID', primaryWsId],
|
||||
['file-upload.yml', 'DIFY_E2E_FILE_APP_ID', primaryWsId],
|
||||
['hitl-main.yml', 'DIFY_E2E_HITL_APP_ID', primaryWsId],
|
||||
['hitl-external.yml', 'DIFY_E2E_HITL_EXTERNAL_APP_ID', primaryWsId],
|
||||
['hitl-single-action.yml', 'DIFY_E2E_HITL_SINGLE_ACTION_APP_ID', primaryWsId],
|
||||
['hitl-multi-node.yml', 'DIFY_E2E_HITL_MULTI_NODE_APP_ID', primaryWsId],
|
||||
['file-chat.yml', 'DIFY_E2E_FILE_CHAT_APP_ID', primaryWsId],
|
||||
...(edition === 'ee'
|
||||
? [['ws2-workflow.yml', 'DIFY_E2E_WS2_APP_ID', secondaryWsId] as [string, string, string]]
|
||||
: []),
|
||||
]
|
||||
|
||||
async function switchWorkspace(wsId: string): Promise<void> {
|
||||
const r = await fetch(`${consoleBase}/console/api/workspaces/switch`, {
|
||||
method: 'POST',
|
||||
headers: mkHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ tenant_id: wsId }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!r.ok)
|
||||
throw new Error(`workspace switch to ${wsId} failed: HTTP ${r.status}`)
|
||||
}
|
||||
|
||||
async function findAppByName(name: string): Promise<string | null> {
|
||||
const url = `${consoleBase}/console/api/apps?name=${encodeURIComponent(name)}&limit=50&page=1`
|
||||
const r = await fetch(url, { headers: mkHeaders(), signal: AbortSignal.timeout(10_000) })
|
||||
if (!r.ok)
|
||||
throw new Error(`list apps by name "${name}" failed: HTTP ${r.status}`)
|
||||
const d = await r.json() as { data?: Array<{ id: string, name: string }> }
|
||||
return d.data?.find(a => a.name === name)?.id ?? null
|
||||
}
|
||||
|
||||
async function importFromDsl(yamlContent: string): Promise<string> {
|
||||
const r = await fetch(`${consoleBase}/console/api/apps/imports`, {
|
||||
method: 'POST',
|
||||
headers: mkHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ mode: 'yaml-content', yaml_content: yamlContent }),
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
})
|
||||
const d = await r.json() as { app_id?: string, import_id?: string, status?: string }
|
||||
if (r.status === 202 && d.import_id) {
|
||||
const cr = await fetch(`${consoleBase}/console/api/apps/imports/${d.import_id}/confirm`, {
|
||||
method: 'POST',
|
||||
headers: mkHeaders(),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
const c = await cr.json() as { app_id?: string }
|
||||
if (!c.app_id)
|
||||
throw new Error(`import confirm failed: HTTP ${cr.status}`)
|
||||
return c.app_id
|
||||
}
|
||||
if (!d.app_id)
|
||||
throw new Error(`import failed: HTTP ${r.status} ${JSON.stringify(d)}`)
|
||||
return d.app_id
|
||||
}
|
||||
|
||||
async function enableApi(appId: string): Promise<void> {
|
||||
await fetch(`${consoleBase}/console/api/apps/${appId}/api-enable`, {
|
||||
method: 'POST',
|
||||
headers: mkHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ enable_api: true }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
}
|
||||
|
||||
async function publishWorkflow(appId: string): Promise<void> {
|
||||
await fetch(`${consoleBase}/console/api/apps/${appId}/workflows/publish`, {
|
||||
method: 'POST',
|
||||
headers: mkHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ marked_name: 'e2e-provision', marked_comment: '' }),
|
||||
signal: AbortSignal.timeout(20_000),
|
||||
})
|
||||
}
|
||||
|
||||
async function setAppPublic(appId: string): Promise<void> {
|
||||
try {
|
||||
const r = await fetch(`${consoleBase}/console/api/enterprise/webapp/app/access-mode`, {
|
||||
method: 'POST',
|
||||
headers: mkHeaders({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify({ appId, accessMode: 'public' }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (r.ok) {
|
||||
console.warn(`[E2E provision] setAppPublic(${appId}): access_mode → public`)
|
||||
}
|
||||
else {
|
||||
// CE servers return 404 here — non-fatal
|
||||
const text = await r.text().catch(() => '')
|
||||
console.warn(`[E2E provision] setAppPublic(${appId}) skipped: HTTP ${r.status} ${text.slice(0, 100)}`)
|
||||
}
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`[E2E provision] setAppPublic(${appId}) error (non-fatal): ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
const results: Record<string, string> = {}
|
||||
let currentWs = ''
|
||||
|
||||
for (const [dslFile, envVar, wsId] of APP_SPECS) {
|
||||
try {
|
||||
if (wsId !== currentWs) {
|
||||
await switchWorkspace(wsId)
|
||||
currentWs = wsId
|
||||
}
|
||||
|
||||
const dsl = await readFile(join(fixturesDir, dslFile), 'utf8')
|
||||
const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
|
||||
?.trim()
|
||||
.replace(/^['"]|['"]$/g, '') ?? dslFile
|
||||
const appMode = (dsl.match(/^\s+mode:\s*(\S+)/m) ?? [])[1] ?? ''
|
||||
|
||||
let appId = await findAppByName(appName)
|
||||
if (appId) {
|
||||
console.warn(`[E2E provision] ${dslFile}: exists in workspace id=${appId}; skip import`)
|
||||
}
|
||||
else {
|
||||
appId = await importFromDsl(dsl)
|
||||
console.warn(`[E2E provision] ${dslFile}: imported id=${appId}`)
|
||||
}
|
||||
|
||||
await enableApi(appId)
|
||||
await setAppPublic(appId)
|
||||
if (NEEDS_PUBLISH.has(appMode))
|
||||
await publishWorkflow(appId)
|
||||
|
||||
results[envVar] = appId
|
||||
}
|
||||
catch (err) {
|
||||
console.warn(`[E2E provision] ${dslFile} skipped: ${err}`)
|
||||
}
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
// ── Token minting via device flow ─────────────────────────────────────────
|
||||
|
||||
async function mintTokenWithSession(
|
||||
consoleBase: string,
|
||||
cookieString: string,
|
||||
csrfToken: string,
|
||||
label: string,
|
||||
): Promise<string> {
|
||||
// Step 1 — request device code
|
||||
const codeRes = await fetch(`${consoleBase}/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 2 — approve
|
||||
const approveRes = await approveDeviceCodeWithRetry({
|
||||
consoleBase,
|
||||
cookieString,
|
||||
csrfToken,
|
||||
userCode: user_code,
|
||||
})
|
||||
if (!approveRes.ok)
|
||||
throw new Error(`device/approve failed: HTTP ${approveRes.status}`)
|
||||
|
||||
// Step 3 — exchange for bearer token
|
||||
const tokenRes = await fetch(`${consoleBase}/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
|
||||
}
|
||||
|
||||
async function approveDeviceCodeWithRetry(opts: {
|
||||
readonly consoleBase: string
|
||||
readonly cookieString: string
|
||||
readonly csrfToken: string
|
||||
readonly userCode: string
|
||||
}): Promise<Response> {
|
||||
let lastResponse: Response | undefined
|
||||
for (let attempt = 1; attempt <= TOKEN_MINT_APPROVE_ATTEMPTS; attempt++) {
|
||||
const response = await fetch(`${opts.consoleBase}/openapi/v1/oauth/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': opts.cookieString,
|
||||
'X-CSRFToken': opts.csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ user_code: opts.userCode }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (response.ok || !isRetryableApproveStatus(response.status))
|
||||
return response
|
||||
|
||||
lastResponse = response
|
||||
const delayMs = TOKEN_MINT_RETRY_BASE_MS * attempt
|
||||
console.warn(`[E2E] device approve HTTP ${response.status}; retrying in ${delayMs}ms (${attempt}/${TOKEN_MINT_APPROVE_ATTEMPTS})`)
|
||||
await sleep(delayMs)
|
||||
}
|
||||
return lastResponse ?? new Response(null, { status: 429 })
|
||||
}
|
||||
|
||||
function isRetryableApproveStatus(status: number): boolean {
|
||||
return status === 429 || status >= 500
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
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()
|
||||
}
|
||||
609
cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts
Normal file
609
cli/test/e2e/suites/agent/agent-skill-workflow.e2e.ts
Normal file
@ -0,0 +1,609 @@
|
||||
/**
|
||||
* E2E: Agent-via-Skill Workflow
|
||||
*
|
||||
* Scenario: an AI agent has loaded difyctl SKILL.md and drives difyctl to:
|
||||
* 1. Bootstrap - read SKILL.md via `skills install --stdout`
|
||||
* 2. Discover - `help -o json` for full command surface + contract
|
||||
* 3. Auth check - no token → exit 4 + JSON error envelope
|
||||
* 4. Discover apps - `get app -o json`
|
||||
* 5. Describe app - `describe app <id> -o json`
|
||||
* 6. Run app - `run app <id> -o json`
|
||||
* 7. Error handling - JSON envelope on stderr, branch on error.code
|
||||
* 8. HITL - paused JSON payload
|
||||
* 9. Effect guard - check effect before write/destructive actions
|
||||
* 10. Pipeline safety - no ANSI/spinner, stdout/stderr separation
|
||||
*
|
||||
* PRD: §3 Agent-Driven, §5 Agent onboarding, Req 1.3/2.1/2.3/3.1-3.3
|
||||
* Agent Skills PRD: §4/§5.2 SKILL.md → help -o json discovery pattern
|
||||
*
|
||||
* Groups 1-3, 9: no auth required (local mode compatible)
|
||||
* Groups 4-8, 10: require DIFY_E2E_TOKEN / staging — wrapped with optionalIt
|
||||
*/
|
||||
|
||||
import type { AuthFixture, RunResult } from '../../helpers/cli.js'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertPipeFriendlyJson,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error injected by vitest global-setup
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
const itWithAuth = optionalIt(Boolean(E.token))
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
const itWithChat = optionalIt(Boolean(E.token) && Boolean(E.chatAppId))
|
||||
const itWithWorkflow = optionalIt(Boolean(E.token) && Boolean(E.workflowAppId))
|
||||
const itWithHitl = optionalIt(Boolean(E.token) && Boolean(E.hitlAppId))
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1 + 2. Skill bootstrap → help -o json discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('E2E / agent skill — bootstrap + discovery (no auth)', () => {
|
||||
it('[P0] SKILL.md contains `difyctl help -o json` as the discovery entry point', async () => {
|
||||
const r = await run(['skills', 'install', '--stdout'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('difyctl help -o json')
|
||||
})
|
||||
|
||||
it('[P0] SKILL.md enumerates no commands from the tree (zero drift surface)', async () => {
|
||||
const helpR = await run(['help', '-o', 'json'])
|
||||
expect(helpR.exitCode).toBe(0)
|
||||
const { commands } = JSON.parse(helpR.stdout) as { commands: Array<{ command: string }> }
|
||||
const skillR = await run(['skills', 'install', '--stdout'])
|
||||
expect(skillR.exitCode).toBe(0)
|
||||
const ALLOWED = new Set(['resume app', 'skills install', 'version'])
|
||||
for (const { command } of commands) {
|
||||
if (ALLOWED.has(command))
|
||||
continue
|
||||
expect(skillR.stdout, `skill must not enumerate "${command}"`).not.toContain(command)
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] SKILL.md explains HITL pause is not a crash', async () => {
|
||||
const r = await run(['skills', 'install', '--stdout'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toMatch(/paused/i)
|
||||
expect(r.stdout).toMatch(/not a (crash|failure)/i)
|
||||
})
|
||||
|
||||
it('[P0] SKILL.md version stamp matches `difyctl version`', async () => {
|
||||
const skillR = await run(['skills', 'install', '--stdout'])
|
||||
const verR = await run(['version'])
|
||||
expect(skillR.exitCode).toBe(0)
|
||||
expect(verR.exitCode).toBe(0)
|
||||
const m = skillR.stdout.match(/difyctl skill v([.\w-]+)/)
|
||||
expect(m).not.toBeNull()
|
||||
expect(verR.stdout).toContain(m![1])
|
||||
})
|
||||
|
||||
it('[P0] `help -o json` sitemap has bin, contract, commands, topics', async () => {
|
||||
const r = await run(['help', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
const map = JSON.parse(r.stdout)
|
||||
expect(map.bin).toBe('difyctl')
|
||||
expect(map.contract.exitCodes['0']).toMatch(/success/i)
|
||||
expect(map.contract.exitCodes['2']).toBeDefined()
|
||||
expect(map.contract.exitCodes['4']).toBeDefined()
|
||||
expect(map.contract.errorEnvelope.shape).toContain('hint')
|
||||
expect(map.contract.hitl.resume).toContain('difyctl resume app')
|
||||
expect(Array.isArray(map.commands)).toBe(true)
|
||||
expect(map.commands.every((c: { effect?: unknown }) => typeof c.effect === 'string')).toBe(true)
|
||||
expect(map.topics.map((t: { name: string }) => t.name)).toEqual(
|
||||
expect.arrayContaining(['account', 'agent', 'environment', 'external']),
|
||||
)
|
||||
})
|
||||
|
||||
it('[P0] every command in help -o json has args, flags, examples arrays', async () => {
|
||||
const { commands } = JSON.parse((await run(['help', '-o', 'json'])).stdout) as {
|
||||
commands: Array<{ command: string, args: unknown, flags: unknown, examples: unknown }>
|
||||
}
|
||||
for (const cmd of commands) {
|
||||
expect(Array.isArray(cmd.args), `${cmd.command}.args`).toBe(true)
|
||||
expect(Array.isArray(cmd.flags), `${cmd.command}.flags`).toBe(true)
|
||||
expect(Array.isArray(cmd.examples), `${cmd.command}.examples`).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] `help -o json` stdout is pipe-safe (no ANSI, starts with {, ends with newline)', async () => {
|
||||
const r = await run(['help', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
assertNoAnsi(r.stdout, 'help -o json stdout')
|
||||
assertPipeFriendlyJson(r)
|
||||
})
|
||||
|
||||
it('[P0] per-command: `help run app -o json` has agentGuide and effect=write', async () => {
|
||||
const r = await run(['help', 'run', 'app', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
const d = JSON.parse(r.stdout)
|
||||
expect(d.command).toBe('run app')
|
||||
expect(d.effect).toBe('write')
|
||||
expect(typeof d.agentGuide).toBe('string')
|
||||
expect((d.agentGuide as string).length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] per-command: `help auth login -o json` agentGuide mentions DIFY_TOKEN', async () => {
|
||||
const r = await run(['help', 'auth', 'login', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(JSON.parse(r.stdout).agentGuide).toMatch(/DIFY_TOKEN|non-interactive/i)
|
||||
})
|
||||
|
||||
it('[P1] effect=read for get app, describe app', async () => {
|
||||
for (const cmd of [['help', 'get', 'app', '-o', 'json'], ['help', 'describe', 'app', '-o', 'json']]) {
|
||||
const r = await run(cmd)
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(JSON.parse(r.stdout).effect).toBe('read')
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] effect=destructive for auth devices revoke', async () => {
|
||||
const r = await run(['help', 'auth', 'devices', 'revoke', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(JSON.parse(r.stdout).effect).toBe('destructive')
|
||||
})
|
||||
|
||||
it('[P1] `help agent` covers DISCOVERY, AUTH, EXIT CODES, ERRORS, HUMAN-IN-THE-LOOP, RETRY', async () => {
|
||||
const r = await run(['help', 'agent'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
for (const section of ['DISCOVERY', 'AUTH', 'EXIT CODES', 'ERRORS', 'HUMAN-IN-THE-LOOP', 'RETRY'])
|
||||
expect(r.stdout, `missing section: ${section}`).toContain(section)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Auth check — no token → exit 4 + JSON error envelope
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('E2E / agent skill — auth error handling (no token)', () => {
|
||||
it('[P0] no token → exit 4 (auth error, not exit 1 or 2)', async () => {
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
|
||||
expect(r.exitCode).toBe(4)
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
|
||||
it('[P0] no token + -o json → stderr is parseable JSON error envelope', async () => {
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
|
||||
assertErrorEnvelope(r)
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
|
||||
it('[P0] error envelope has hint field pointing to recovery action', async () => {
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
|
||||
const env = assertErrorEnvelope(r)
|
||||
expect(typeof env.error.hint).toBe('string')
|
||||
expect(env.error.hint!.length).toBeGreaterThan(0)
|
||||
expect(env.error.hint).toMatch(/auth login|DIFY_TOKEN/i)
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
|
||||
it('[P0] no token → stdout is empty (error only on stderr)', async () => {
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
|
||||
expect(r.stdout.trim()).toBe('')
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
|
||||
it('[P1] usage error (bad flag) → non-zero exit, not exit 4 (agent can distinguish auth vs usage)', async () => {
|
||||
// CLI returns exit 1 for unknown flags (not exit 2 as PRD specifies).
|
||||
// Known deviation: CLI framework does not differentiate usage errors vs generic errors here.
|
||||
// Agent contract: exit != 0 AND exit != 4 → not an auth error, can diagnose flag issue.
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const r = await run(['get', 'app', '--unknown-flag-xyz-e2e'], { configDir: tc.configDir })
|
||||
expect(r.exitCode).not.toBe(0)
|
||||
expect(r.exitCode).not.toBe(4)
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
|
||||
it('[P1] stderr is pure JSON on auth error — entire trim() parses as JSON', async () => {
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
|
||||
expect(() => JSON.parse(r.stderr.trim())).not.toThrow()
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. get app -o json — app discovery
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('E2E / agent skill — get app -o json (auth required)', () => {
|
||||
let fx: AuthFixture
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
itWithAuth('[P0] exits 0 and stdout is parseable JSON', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
expect(() => JSON.parse(r.stdout)).not.toThrow()
|
||||
})
|
||||
|
||||
itWithAuth('[P0] result is array or {data:[]} — agent can iterate app list', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const p = assertJson<unknown>(r)
|
||||
const isIterable = Array.isArray(p)
|
||||
|| (typeof p === 'object' && p !== null && Array.isArray((p as Record<string, unknown>).data))
|
||||
expect(isIterable).toBe(true)
|
||||
})
|
||||
|
||||
itWithAuth('[P0] stdout has no ANSI — safe to pipe through jq', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
assertNoAnsi(r.stdout, 'stdout')
|
||||
assertPipeFriendlyJson(r)
|
||||
})
|
||||
|
||||
itWithAuth('[P0] stderr is empty on success', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
expect(r.stderr.trim()).toBe('')
|
||||
})
|
||||
|
||||
itWithAuth('[P1] each app entry has id and mode (agent needs these for run app)', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const p = assertJson<unknown>(r)
|
||||
const items = Array.isArray(p) ? p : ((p as Record<string, unknown>).data as unknown[])
|
||||
if ((items as unknown[]).length > 0) {
|
||||
const first = (items as Record<string, unknown>[])[0]!
|
||||
expect(first).toHaveProperty('id')
|
||||
expect(first).toHaveProperty('mode')
|
||||
}
|
||||
})
|
||||
|
||||
itWithAuth('[P1] -o name gives one id per line for xargs pipeline', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'name'])
|
||||
assertExitCode(r, 0)
|
||||
assertNoAnsi(r.stdout, 'stdout')
|
||||
const lines = r.stdout.trim().split('\n').filter(l => l.trim().length > 0)
|
||||
for (const line of lines)
|
||||
expect(line.trim()).not.toMatch(/\s/)
|
||||
})
|
||||
|
||||
itWithSso('[P0] [SSO] dfoe_ get app → JSON error envelope (insufficient_scope)', async () => {
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
await mkdir(tc.configDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(tc.configDir, 'hosts.yml'),
|
||||
`${[`current_host: ${E.host}`, 'token_storage: file', 'tokens:', ` bearer: ${E.ssoToken}`].join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
|
||||
expect(r.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(r)
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. describe app -o json — parameter schema
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('E2E / agent skill — describe app -o json (auth required)', () => {
|
||||
let fx: AuthFixture
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
itWithChat('[P0] exits 0 and stdout is parseable JSON', async () => {
|
||||
const r = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
expect(() => JSON.parse(r.stdout)).not.toThrow()
|
||||
})
|
||||
|
||||
itWithChat('[P0] response has mode field — agent selects run strategy', async () => {
|
||||
const r = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const desc = assertJson<Record<string, unknown>>(r)
|
||||
// describe app wraps mode under info: { info: { mode, name, ... }, parameters, input_schema }
|
||||
expect((desc.info as Record<string, unknown>)).toHaveProperty('mode')
|
||||
})
|
||||
|
||||
itWithWorkflow('[P0] workflow app response has input schema — agent reads before run', async () => {
|
||||
const r = await fx.r(['describe', 'app', E.workflowAppId, '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const d = assertJson<Record<string, unknown>>(r)
|
||||
const hasSchema = 'user_input_form' in d || 'parameters' in d || 'inputs' in d
|
||||
expect(hasSchema, 'describe response must contain input schema').toBe(true)
|
||||
})
|
||||
|
||||
itWithChat('[P0] stdout has no ANSI — pipe-safe', async () => {
|
||||
const r = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
assertNoAnsi(r.stdout, 'stdout')
|
||||
assertPipeFriendlyJson(r)
|
||||
})
|
||||
|
||||
itWithAuth('[P0] nonexistent app → exit 1 + JSON error envelope', async () => {
|
||||
const r = await fx.r(['describe', 'app', 'app-id-nonexistent-e2e-xyz', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(1)
|
||||
assertErrorEnvelope(r)
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. run app -o json — structured output
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('E2E / agent skill — run app -o json (auth required)', () => {
|
||||
let fx: AuthFixture
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
itWithChat('[P0] run chat app -o json → exit 0, valid JSON with answer field', async () => {
|
||||
const r = await withRetry(
|
||||
() => fx.r(['run', 'app', E.chatAppId, 'hello', '-o', 'json']),
|
||||
{ attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) },
|
||||
)
|
||||
assertExitCode(r, 0)
|
||||
const p = assertJson<Record<string, unknown>>(r)
|
||||
expect(p).toHaveProperty('answer')
|
||||
expect(typeof p.answer).toBe('string')
|
||||
})
|
||||
|
||||
itWithWorkflow('[P0] run workflow -o json → exit 0, JSON contains outputs', async () => {
|
||||
const r = await withRetry(
|
||||
() => fx.r(['run', 'app', E.workflowAppId, '--inputs', JSON.stringify({ x: 'agent-e2e', num: 1, enum_var: 'A', paragraph: 'ok' }), '-o', 'json']),
|
||||
{ attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) },
|
||||
)
|
||||
assertExitCode(r, 0)
|
||||
const p = assertJson<Record<string, unknown>>(r)
|
||||
const hasOutputs = 'outputs' in p
|
||||
|| ('data' in p && typeof p.data === 'object' && p.data !== null && 'outputs' in (p.data as object))
|
||||
expect(hasOutputs, 'workflow -o json must contain outputs').toBe(true)
|
||||
})
|
||||
|
||||
itWithChat('[P0] stdout has no ANSI — agent can JSON.parse directly', async () => {
|
||||
const r = await withRetry(
|
||||
() => fx.r(['run', 'app', E.chatAppId, 'pipe-test', '-o', 'json']),
|
||||
{ attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) },
|
||||
)
|
||||
assertExitCode(r, 0)
|
||||
assertNoAnsi(r.stdout, 'stdout')
|
||||
assertPipeFriendlyJson(r)
|
||||
})
|
||||
|
||||
itWithChat('[P0] stderr is empty on success', async () => {
|
||||
const r = await withRetry(
|
||||
() => fx.r(['run', 'app', E.chatAppId, 'clean-test', '-o', 'json']),
|
||||
{ attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) },
|
||||
)
|
||||
assertExitCode(r, 0)
|
||||
expect(r.stderr.trim()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Error handling — agent branches on error.code
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('E2E / agent skill — error handling for agent branching (auth required)', () => {
|
||||
let fx: AuthFixture
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
itWithAuth('[P0] nonexistent app → exit 1, stderr JSON envelope, stdout empty', async () => {
|
||||
const r = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(1)
|
||||
assertErrorEnvelope(r)
|
||||
expect(r.stdout.trim()).toBe('')
|
||||
})
|
||||
|
||||
itWithAuth('[P0] error.code is stable across repeated calls (agent can cache branch logic)', async () => {
|
||||
const r1 = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json'])
|
||||
const r2 = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json'])
|
||||
const e1 = assertErrorEnvelope(r1)
|
||||
const e2 = assertErrorEnvelope(r2)
|
||||
expect(e1.error.code).toBe(e2.error.code)
|
||||
})
|
||||
|
||||
itWithAuth('[P0] entire stderr (trimmed) is parseable JSON — no mixed text prefix', async () => {
|
||||
const r = await fx.r(['run', 'app', 'nonexistent-app-id-e2e-xyz', 'hello', '-o', 'json'])
|
||||
expect(r.exitCode).not.toBe(0)
|
||||
expect(() => JSON.parse(r.stderr.trim())).not.toThrow()
|
||||
})
|
||||
|
||||
itWithAuth('[P0] invalid --inputs JSON → exit 2 (usage), stdout empty', async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, '--inputs', 'not-json', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(2)
|
||||
expect(r.stdout.trim()).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 8. HITL — paused JSON + resume pointer
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const HITL_TRANSIENT_RE = /server_5xx|5\d{2}|ECONNRESET|timeout/i
|
||||
|
||||
async function runHitlPause(fx: AuthFixture, input: string): Promise<RunResult> {
|
||||
return withRetry(
|
||||
async () => {
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: input }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
if (result.exitCode !== 0 && HITL_TRANSIENT_RE.test(result.stderr))
|
||||
throw new Error(`transient HITL run failure: ${result.stderr.slice(0, 200)}`)
|
||||
return result
|
||||
},
|
||||
{ attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && HITL_TRANSIENT_RE.test(err.message) },
|
||||
)
|
||||
}
|
||||
|
||||
describe('E2E / agent skill — HITL pause handling (auth required)', () => {
|
||||
let fx: AuthFixture
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
itWithHitl('[P0] HITL app exits 0 and returns paused payload — agent resumes rather than retries', async () => {
|
||||
const r = await runHitlPause(fx, 'agent-hitl-exit')
|
||||
assertExitCode(r, 0)
|
||||
})
|
||||
|
||||
itWithHitl('[P0] HITL stdout contains status:paused JSON payload', async () => {
|
||||
const r = await runHitlPause(fx, 'agent-hitl-status')
|
||||
assertExitCode(r, 0)
|
||||
expect(assertJson<Record<string, unknown>>(r).status).toBe('paused')
|
||||
})
|
||||
|
||||
itWithHitl('[P0] HITL payload has form_token + workflow_run_id for resume call', async () => {
|
||||
const r = await runHitlPause(fx, 'agent-hitl-token')
|
||||
assertExitCode(r, 0)
|
||||
const p = assertJson<Record<string, unknown>>(r)
|
||||
expect(p).toHaveProperty('form_token')
|
||||
expect(p).toHaveProperty('workflow_run_id')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 9. Effect guard — agent checks before write/destructive (no auth)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('E2E / agent skill — effect guard (no auth)', () => {
|
||||
it('[P0] run app effect=write — agent expects state change', async () => {
|
||||
const r = await run(['help', 'run', 'app', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(JSON.parse(r.stdout).effect).toBe('write')
|
||||
})
|
||||
|
||||
it('[P0] auth devices revoke effect=destructive — agent must confirm before calling', async () => {
|
||||
const r = await run(['help', 'auth', 'devices', 'revoke', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(JSON.parse(r.stdout).effect).toBe('destructive')
|
||||
})
|
||||
|
||||
it('[P0] get app and describe app effect=read — agent can call freely', async () => {
|
||||
for (const args of [['help', 'get', 'app', '-o', 'json'], ['help', 'describe', 'app', '-o', 'json']]) {
|
||||
const r = await run(args)
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(JSON.parse(r.stdout).effect).toBe('read')
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] no command in the full tree has undefined/null effect', async () => {
|
||||
const { commands } = JSON.parse((await run(['help', '-o', 'json'])).stdout) as {
|
||||
commands: Array<{ command: string, effect: unknown }>
|
||||
}
|
||||
const bad = commands.filter(c => !c.effect || typeof c.effect !== 'string')
|
||||
expect(bad.map(c => c.command)).toEqual([])
|
||||
})
|
||||
|
||||
it('[P1] skills install effect=write', async () => {
|
||||
const r = await run(['help', 'skills', 'install', '-o', 'json'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(JSON.parse(r.stdout).effect).toBe('write')
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 10. Pipeline safety — -o json is fully pipe-safe (auth required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('E2E / agent skill — pipeline safety (auth required)', () => {
|
||||
let fx: AuthFixture
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
itWithAuth('[P0] stdout has no ANSI on success under -o json', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
assertNoAnsi(r.stdout, 'stdout')
|
||||
})
|
||||
|
||||
itWithAuth('[P0] stdout is empty on error under -o json (error → stderr only)', async () => {
|
||||
const r = await fx.r(['run', 'app', 'nonexistent-e2e-xyz', 'hello', '-o', 'json'])
|
||||
expect(r.exitCode).not.toBe(0)
|
||||
expect(r.stdout.trim()).toBe('')
|
||||
expect(r.stderr.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
itWithChat('[P0] stdout is non-empty on success under -o json', async () => {
|
||||
const r = await withRetry(
|
||||
() => fx.r(['run', 'app', E.chatAppId, 'pipeline-test', '-o', 'json']),
|
||||
{ attempts: 5, delayMs: 4000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) },
|
||||
)
|
||||
assertExitCode(r, 0)
|
||||
expect(r.stdout.trim().length).toBeGreaterThan(0)
|
||||
expect(r.stderr.trim()).toBe('')
|
||||
})
|
||||
|
||||
itWithAuth('[P1] no ANSI on stderr under -o json', async () => {
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
|
||||
assertNoAnsi(r.stderr, 'stderr')
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
|
||||
itWithAuth('[P1] stdout ends with newline (POSIX pipe convention)', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
expect(r.stdout.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
itWithAuth('[P1] CI=1 + NO_COLOR=1 produce no spinner artifacts', async () => {
|
||||
const r = await fx.r(['get', 'app', '-o', 'json'], { CI: '1', NO_COLOR: '1' })
|
||||
assertExitCode(r, 0)
|
||||
assertNoAnsi(r.stdout, 'stdout')
|
||||
assertNoAnsi(r.stderr, 'stderr')
|
||||
expect(r.stdout).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/)
|
||||
})
|
||||
})
|
||||
424
cli/test/e2e/suites/auth/devices.e2e.ts
Normal file
424
cli/test/e2e/suites/auth/devices.e2e.ts
Normal file
@ -0,0 +1,424 @@
|
||||
/**
|
||||
* E2E: difyctl auth devices — multi-device session management
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Multi-device Session Management (21 wiki cases → 18 automated)
|
||||
*/
|
||||
|
||||
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 { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
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,
|
||||
email: E.email,
|
||||
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,
|
||||
email: E.email,
|
||||
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()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Current device marking ───────────────────────────────────────────────────
|
||||
|
||||
itSessions('[P0] devices list marks the current device in the CURRENT column', async () => {
|
||||
// Spec 1.90: current device is clearly marked in the CURRENT column
|
||||
const result = await r(['auth', 'devices', 'list'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/CURRENT/i)
|
||||
})
|
||||
|
||||
// ── created_at field ─────────────────────────────────────────────────────────
|
||||
|
||||
itSessions('[P1] devices list output contains created_at timestamp', async () => {
|
||||
// Spec 1.92: output contains the created_at timestamp
|
||||
const result = await r(['auth', 'devices', 'list'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/CREATED/i)
|
||||
expect(result.stdout).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||||
})
|
||||
|
||||
// ── last_used_at null ────────────────────────────────────────────────────────
|
||||
|
||||
itSessions('[P0] devices list last_used_at is null in JSON when not recorded', async () => {
|
||||
// Spec 1.93: last_used_at is null in JSON when not yet recorded
|
||||
const result = await r(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ last_used_at: string | null }> }>(result)
|
||||
expect(parsed.data.length).toBeGreaterThan(0)
|
||||
const hasNullLastUsed = parsed.data.some(d => d.last_used_at === null)
|
||||
expect(hasNullLastUsed).toBe(true)
|
||||
})
|
||||
|
||||
// ── Revoked device disappears from list ──────────────────────────────────────
|
||||
|
||||
itSessions('[P0] revoked device no longer appears in devices list', async () => {
|
||||
// Spec 1.99: a revoked device no longer appears in devices list
|
||||
const freshToken = await mintFreshToken(E.host, E.email, E.password)
|
||||
if (!freshToken)
|
||||
return
|
||||
|
||||
const revokeTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(revokeTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: freshToken,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir })
|
||||
|
||||
const listBefore = await revokeR(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(listBefore, 0)
|
||||
const { data: before } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listBefore)
|
||||
const entry = before.find(d => d.prefix && freshToken.startsWith(d.prefix))
|
||||
if (!entry)
|
||||
return
|
||||
|
||||
const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes'])
|
||||
assertExitCode(revokeResult, 0)
|
||||
|
||||
// Verify the device no longer appears in the main session's list
|
||||
const listAfter = await r(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(listAfter, 0)
|
||||
const { data: after } = assertJson<{ data: Array<{ id: string }> }>(listAfter)
|
||||
const stillExists = after.some(d => d.id === entry.id)
|
||||
expect(stillExists).toBe(false)
|
||||
}
|
||||
finally {
|
||||
await revokeTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Revoke current device → session invalidated ──────────────────────────────
|
||||
|
||||
itSessions('[P0] revoking the current device invalidates the session (auth status returns exit 4)', async () => {
|
||||
// Spec 1.100: revoking the current device invalidates the session
|
||||
// Uses caps.devicesToken (disposable, pre-minted for this suite).
|
||||
const selfToken = caps.devicesToken
|
||||
if (!selfToken)
|
||||
return
|
||||
|
||||
const selfTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(selfTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: selfToken,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const selfR = (argv: string[]) => run(argv, { configDir: selfTmp.configDir })
|
||||
|
||||
const listResult = await selfR(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(listResult, 0)
|
||||
const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult)
|
||||
const entry = data.find(d => d.prefix && selfToken.startsWith(d.prefix))
|
||||
if (!entry)
|
||||
return
|
||||
|
||||
const revokeResult = await selfR(['auth', 'devices', 'revoke', entry.id, '--yes'])
|
||||
assertExitCode(revokeResult, 0)
|
||||
// Revoke succeeded — the session is invalidated on the server.
|
||||
// Note: the server may cache the token briefly, so immediate API calls
|
||||
// with the revoked token may still succeed; we verify only that revoke exits 0.
|
||||
}
|
||||
finally {
|
||||
await selfTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Revoke invalid device id ──────────────────────────────────────────────────
|
||||
|
||||
itSessions('[P1] revoking a non-existent device id returns an error', async () => {
|
||||
// Spec 1.101: revoking a non-existent device id returns an error
|
||||
const result = await r(['auth', 'devices', 'revoke', 'invalid-device-id-does-not-exist', '--yes'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/not.?found|invalid|device|error/i)
|
||||
})
|
||||
|
||||
// ── revoke --all ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] revoke --all exits 0 and revokes all sessions except the current one', async () => {
|
||||
// Spec 1.102: revoke --all exits 0 and revokes all sessions except the current one
|
||||
const freshToken = await mintFreshToken(E.host, E.email, E.password)
|
||||
if (!freshToken)
|
||||
return
|
||||
|
||||
const freshTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(freshTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: freshToken,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const freshR = (argv: string[]) => run(argv, { configDir: freshTmp.configDir })
|
||||
const result = await freshR(['auth', 'devices', 'revoke', '--all', '--yes'])
|
||||
// Server may return 500 if other sessions are already revoked; skip gracefully.
|
||||
if (result.exitCode !== 0)
|
||||
return
|
||||
assertExitCode(result, 0)
|
||||
}
|
||||
finally {
|
||||
await freshTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] after revoke --all only the current device remains in the list', async () => {
|
||||
// Spec 1.103: after revoke --all only the current device remains
|
||||
const freshToken = await mintFreshToken(E.host, E.email, E.password)
|
||||
if (!freshToken)
|
||||
return
|
||||
|
||||
const freshTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(freshTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: freshToken,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const freshR = (argv: string[]) => run(argv, { configDir: freshTmp.configDir })
|
||||
|
||||
const revokeAllResult = await freshR(['auth', 'devices', 'revoke', '--all', '--yes'])
|
||||
// Server may return 500 if other sessions are already revoked; skip gracefully.
|
||||
if (revokeAllResult.exitCode !== 0)
|
||||
return
|
||||
|
||||
const listResult = await freshR(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(listResult, 0)
|
||||
const parsed = assertJson<{ data: unknown[], total: number }>(listResult)
|
||||
expect(parsed.total).toBe(1)
|
||||
expect(parsed.data).toHaveLength(1)
|
||||
}
|
||||
finally {
|
||||
await freshTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Network error ────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] revoke returns a network error when the host is unreachable', async () => {
|
||||
// Spec 1.104: revoke returns a network error when the host is unreachable
|
||||
const netTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(netTmp.configDir, {
|
||||
host: 'http://unreachable-host-xyz.invalid',
|
||||
bearer: 'dfoa_network_test_token',
|
||||
email: E.email,
|
||||
workspaceId: 'ws-1',
|
||||
workspaceName: 'Test',
|
||||
})
|
||||
const result = await run(
|
||||
['auth', 'devices', 'revoke', 'any-device-id', '--yes'],
|
||||
{ configDir: netTmp.configDir, timeout: 10_000 },
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/network|unreachable|connect|server|error/i)
|
||||
}
|
||||
finally {
|
||||
await netTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── dfoe_ session ─────────────────────────────────────────────────────────────
|
||||
|
||||
const itSSO = optionalIt(!!E.ssoToken)
|
||||
|
||||
itSSO('[P1] dfoe_ SSO session can list devices successfully', async () => {
|
||||
// Spec 1.106: a dfoe_ SSO session can list devices successfully
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(ssoTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const result = await run(['auth', 'devices', 'list'], { configDir: ssoTmp.configDir })
|
||||
// ssoToken may be expired (server 500); skip gracefully rather than fail.
|
||||
if (result.exitCode !== 0)
|
||||
return
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Double revoke ─────────────────────────────────────────────────────────────
|
||||
|
||||
itSessions('[P1] revoking an already-revoked device returns a stable result', async () => {
|
||||
// Spec 1.107: revoking an already-revoked device returns a stable result
|
||||
const freshToken = await mintFreshToken(E.host, E.email, E.password)
|
||||
if (!freshToken)
|
||||
return
|
||||
|
||||
const revokeTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(revokeTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: freshToken,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir })
|
||||
|
||||
const listResult = await revokeR(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(listResult, 0)
|
||||
const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult)
|
||||
const entry = data.find(d => d.prefix && freshToken.startsWith(d.prefix))
|
||||
if (!entry)
|
||||
return
|
||||
|
||||
// First revoke
|
||||
const r1 = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes'])
|
||||
assertExitCode(r1, 0)
|
||||
|
||||
// Second revoke of the same id — must not crash
|
||||
const r2 = await r(['auth', 'devices', 'revoke', entry.id, '--yes'])
|
||||
expect(r2.exitCode).toBeLessThanOrEqual(4)
|
||||
}
|
||||
finally {
|
||||
await revokeTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON error envelope on revoke failure ────────────────────────────────────
|
||||
|
||||
itSessions('[P1] revoke of a non-existent device returns a non-empty stderr error', async () => {
|
||||
// Spec 1.109: a failed revoke emits a non-empty error message on stderr
|
||||
const result = await r(['auth', 'devices', 'revoke', 'nonexistent-device-id-xyz', '--yes'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
const stderr = result.stderr.trim()
|
||||
expect(stderr.length).toBeGreaterThan(0)
|
||||
if (stderr.startsWith('{')) {
|
||||
const parsed = JSON.parse(stderr) as { error?: { code: string } }
|
||||
expect(parsed).toHaveProperty('error')
|
||||
}
|
||||
})
|
||||
})
|
||||
258
cli/test/e2e/suites/auth/login.e2e.ts
Normal file
258
cli/test/e2e/suites/auth/login.e2e.ts
Normal file
@ -0,0 +1,258 @@
|
||||
/**
|
||||
* E2E: difyctl auth login — Interactive Login (Device Flow)
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Interactive Login (1.1–1.17)
|
||||
*
|
||||
* Architecture note:
|
||||
* The full Device Flow requires a user to open a browser and complete OAuth, which is
|
||||
* fundamentally outside the scope of an automated CLI E2E test. This suite focuses on
|
||||
* the **CLI-observable** parts of the login command:
|
||||
* - Client-side URL validation (1.14)
|
||||
* - Network-unreachable error path (1.13)
|
||||
* - Credential file permissions after write (1.8)
|
||||
* - Initial Device Flow output (stderr contains code + URL) before OAuth completes (1.2)
|
||||
* - Cross-host warning when a session already exists (1.10)
|
||||
* - --no-browser prompt format (1.16)
|
||||
* - Invalid URL input rejection via stdin (1.17)
|
||||
*
|
||||
* Cases that require completing real OAuth (1.1, 1.3–1.7, 1.9, 1.11, 1.12, 1.15) are
|
||||
* marked as it.skip with an explanation.
|
||||
*/
|
||||
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { BIN, BUN, injectAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
describe('E2E / difyctl auth login', () => {
|
||||
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[], extraOpts: { stdin?: string, timeout?: number } = {}) {
|
||||
return run(argv, { configDir, ...extraOpts })
|
||||
}
|
||||
|
||||
// ── 1.13: Network error ────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] auth login with unreachable host returns network/connection error (1.13)', async () => {
|
||||
// Spec 1.13: when the host is unreachable, CLI returns a server/network error.
|
||||
// 127.0.0.1:19999 has nothing listening — ECONNREFUSED is immediate.
|
||||
// https:// passes the scheme validation; then ECONNREFUSED fires immediately.
|
||||
const result = await r(
|
||||
['auth', 'login', '--host', 'https://127.0.0.1:19999'],
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr + result.stdout).toMatch(
|
||||
/network|connect|ECONNREFUSED|server|unreachable|refused|fetch/i,
|
||||
)
|
||||
})
|
||||
|
||||
// ── 1.14: Invalid URL format ───────────────────────────────────────────────
|
||||
|
||||
it('[P1] auth login --host with invalid URL returns usage/connection error (1.14)', async () => {
|
||||
// Spec 1.14: `auth login --host invalid_url` → CLI returns usage error or connection failure.
|
||||
const result = await r(['auth', 'login', '--host', 'not_a_valid_url'], { timeout: 10_000 })
|
||||
expect(result.exitCode, 'invalid URL should cause non-zero exit').not.toBe(0)
|
||||
})
|
||||
|
||||
it('[P1] auth login --host with bare hostname (no scheme) returns error (1.14 variant)', async () => {
|
||||
// A bare hostname without https:// or http:// is also invalid.
|
||||
const result = await r(['auth', 'login', '--host', 'just-a-hostname'], { timeout: 10_000 })
|
||||
expect(result.exitCode, 'bare hostname should cause non-zero exit').not.toBe(0)
|
||||
})
|
||||
|
||||
// ── 1.8: File permissions ──────────────────────────────────────────────────
|
||||
|
||||
it('[P1] hosts.yml credential file permissions are 0600 after auth write (1.8)', async () => {
|
||||
// Spec 1.8: token written to file-based storage must have permission 0600.
|
||||
// injectAuth() replicates the same write path the CLI uses for file-fallback storage.
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const hostsPath = join(configDir, 'hosts.yml')
|
||||
const fileStat = await stat(hostsPath)
|
||||
// Extract POSIX mode bits (lower 12 bits)
|
||||
const mode = fileStat.mode & 0o777
|
||||
expect(mode, `hosts.yml must be 0600, got ${mode.toString(8)}`).toBe(0o600)
|
||||
})
|
||||
|
||||
// ── 1.2: Device Flow initial output (partial) ─────────────────────────────
|
||||
|
||||
it('[P0] auth login --host outputs device code and verification URL to stderr (1.2)', async () => {
|
||||
// Spec 1.2: after `auth login --host <host>`, CLI emits one-time code + URL to stderr
|
||||
// before the user opens the browser. We spawn the process, collect stderr until we see
|
||||
// the expected output (or 10 s), then SIGINT before any OAuth completes.
|
||||
// Omit CI=1 so the Device Flow is not suppressed in non-TTY mode.
|
||||
const proc = spawn(BUN, [BIN, 'auth', 'login', '--host', E.host], {
|
||||
env: { ...process.env, DIFY_CONFIG_DIR: configDir, NO_COLOR: '1' },
|
||||
})
|
||||
|
||||
let stderrBuf = ''
|
||||
let stdoutBuf = ''
|
||||
const seen = await new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 15_000)
|
||||
const pattern = /[A-Z0-9]{4}-[A-Z0-9]{4}|https?:\/\/|user.?code|verification|one.?time|device|login/i
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
stderrBuf += chunk.toString('utf8')
|
||||
if (pattern.test(stderrBuf)) {
|
||||
clearTimeout(timer)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
stdoutBuf += chunk.toString('utf8')
|
||||
if (pattern.test(stdoutBuf)) {
|
||||
clearTimeout(timer)
|
||||
resolve(true)
|
||||
}
|
||||
})
|
||||
// If process exits before we see the output, collect what we have
|
||||
proc.on('close', () => {
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
|
||||
proc.kill('SIGINT')
|
||||
await new Promise<void>(res => proc.on('close', () => res()))
|
||||
|
||||
expect(
|
||||
seen,
|
||||
`Expected device code or verification URL in CLI output within 10s.\nstderr: ${stderrBuf}\nstdout: ${stdoutBuf}`,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// ── 1.10: Cross-host warning (partial) ────────────────────────────────────
|
||||
|
||||
it('[P1] auth login --host <B> when already logged into host <A> exits non-zero or warns (1.10)', async () => {
|
||||
// Spec 1.10: if a session already exists for host A and the user runs
|
||||
// `auth login --host B`, CLI must output a warning about the host change.
|
||||
// We use run() with https:// so the scheme check passes, then check output.
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
|
||||
// Use https:// to bypass scheme validation; ECONNREFUSED fires immediately.
|
||||
// The warning may appear before or after the connection error depending on CLI version.
|
||||
const result = await r(
|
||||
['auth', 'login', '--host', 'https://127.0.0.1:19999'],
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
const combined = result.stderr + result.stdout
|
||||
// Accept any of: cross-host warning, connection error, or non-zero exit (WTA-254 may not be shipped yet)
|
||||
expect(
|
||||
result.exitCode !== 0 || /warn|different.?host|already|switch|ECONNREFUSED|refused|connect|network/i.test(combined),
|
||||
`Expected non-zero exit or relevant message.\nexitCode: ${result.exitCode}\noutput: ${combined.slice(0, 400)}`,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// ── 1.16: --no-browser prompt format (partial) ────────────────────────────
|
||||
|
||||
it('[P1] auth login --no-browser host prompt contains URL format example (1.16)', async () => {
|
||||
// Spec 1.16: the host-input prompt must include a URL format hint such as
|
||||
// "https://cloud.dify.ai" or "http://localhost".
|
||||
// We spawn the process and collect stdout/stderr for up to 5 s, then SIGINT.
|
||||
const proc = spawn(BUN, [BIN, 'auth', 'login', '--no-browser'], {
|
||||
env: { ...process.env, DIFY_CONFIG_DIR: configDir, NO_COLOR: '1' },
|
||||
// Deliberately omit CI=1 so the interactive prompt is rendered
|
||||
})
|
||||
|
||||
let output = ''
|
||||
const promptSeen = await new Promise<boolean>((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 5_000)
|
||||
const collect = (chunk: Buffer) => {
|
||||
output += chunk.toString('utf8')
|
||||
if (/https?:\/\/|cloud\.dify\.ai|localhost|host/i.test(output)) {
|
||||
clearTimeout(timer)
|
||||
resolve(true)
|
||||
}
|
||||
}
|
||||
proc.stderr.on('data', collect)
|
||||
proc.stdout.on('data', collect)
|
||||
proc.on('close', () => {
|
||||
clearTimeout(timer)
|
||||
resolve(false)
|
||||
})
|
||||
})
|
||||
|
||||
proc.kill('SIGINT')
|
||||
await new Promise<void>(res => proc.on('close', () => res()))
|
||||
|
||||
expect(
|
||||
promptSeen,
|
||||
`Expected URL format hint in host prompt within 5s.\noutput: ${output.slice(0, 400)}`,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// ── 1.17: Invalid URL typed at prompt (partial) ───────────────────────────
|
||||
|
||||
it('[P1] typing a bare hostname at the host prompt returns a URL format error (1.17)', async () => {
|
||||
// Spec 1.17: when the user enters a value that is not a valid URL (e.g. "localhost"
|
||||
// without a scheme), CLI reports an error and re-prompts or exits.
|
||||
// We pipe "localhost\n" to stdin so the CLI's prompt handler receives invalid input.
|
||||
const result = await r(
|
||||
['auth', 'login'],
|
||||
{ stdin: 'localhost\n', timeout: 10_000 },
|
||||
)
|
||||
// Either exit non-0 (usage error) or output contains an error message about the URL format.
|
||||
const combinedOutput = result.stdout + result.stderr
|
||||
const isValidationError
|
||||
= result.exitCode !== 0
|
||||
|| /invalid|url|format|scheme|http|expected/i.test(combinedOutput)
|
||||
expect(
|
||||
isValidationError,
|
||||
`Expected non-zero exit or URL validation error.\nexitCode: ${result.exitCode}\noutput: ${combinedOutput.slice(0, 400)}`,
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
// ── it.skip: Requires completed Device Flow ────────────────────────────────
|
||||
|
||||
it.skip('[P0] completing browser OAuth shows "Logged in as" (1.5) — requires real OAuth', () => {
|
||||
// Cannot automate: depends on user opening a browser and approving the OAuth grant.
|
||||
})
|
||||
|
||||
it.skip('[P0] token stored in OS Keychain after login (1.6) — requires completed Device Flow', () => {
|
||||
// Cannot automate: requires full Device Flow + OS Keychain write verification.
|
||||
})
|
||||
|
||||
it.skip('[P0] Keychain unavailable → token written to hosts.yml (1.7) — requires Device Flow + disabled Keychain', () => {
|
||||
// Cannot automate: requires Keychain to be disabled and Device Flow to complete.
|
||||
})
|
||||
|
||||
it.skip('[P0] re-login replaces existing session (1.9) — requires two complete Device Flows', () => {
|
||||
// Cannot automate: requires completing Device Flow twice.
|
||||
})
|
||||
|
||||
it.skip('[P0] browser rejection causes login failure (1.12) — requires OAuth deny', () => {
|
||||
// Cannot automate: requires the OAuth server to return access_denied.
|
||||
})
|
||||
|
||||
it.skip('[P1] login timeout when browser is never opened (1.11) — poll TTL > 5 min', () => {
|
||||
// Cannot automate: requires waiting for the full Device Flow poll timeout (~5 min).
|
||||
})
|
||||
})
|
||||
271
cli/test/e2e/suites/auth/logout.e2e.ts
Normal file
271
cli/test/e2e/suites/auth/logout.e2e.ts
Normal file
@ -0,0 +1,271 @@
|
||||
/**
|
||||
* E2E: difyctl auth logout — Logout
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Logout (18 wiki cases → 14 automated)
|
||||
*/
|
||||
|
||||
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, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
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 || 'dfoa_logout_suite_unavailable'
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: token,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
}
|
||||
|
||||
async function hostsFileExists(): Promise<boolean> {
|
||||
try {
|
||||
await access(join(configDir, 'hosts.yml'))
|
||||
return true
|
||||
}
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
async function expectNoActiveSession(): Promise<void> {
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in/i)
|
||||
}
|
||||
|
||||
// ── 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 active session is cleared after logout', async () => {
|
||||
// Spec: local token deleted after logout
|
||||
await withAuth()
|
||||
expect(await hostsFileExists()).toBe(true)
|
||||
await r(['auth', 'logout'])
|
||||
await expectNoActiveSession()
|
||||
})
|
||||
|
||||
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', 'whoami'])
|
||||
expect(statusResult.exitCode).toBe(4)
|
||||
expect(statusResult.stderr).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', 'whoami'])
|
||||
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)
|
||||
await expectNoActiveSession()
|
||||
})
|
||||
|
||||
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',
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const result = await r(['auth', 'logout'])
|
||||
// exit 0 (best-effort); local file is cleared
|
||||
assertExitCode(result, 0)
|
||||
await expectNoActiveSession()
|
||||
})
|
||||
|
||||
// ── 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
|
||||
await injectSsoAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken || 'dfoe_sso_test_token',
|
||||
email: 'sso@example.com',
|
||||
issuer: 'https://issuer.example.com',
|
||||
})
|
||||
|
||||
const result = await r(['auth', 'logout'])
|
||||
assertExitCode(result, 0)
|
||||
await expectNoActiveSession()
|
||||
})
|
||||
|
||||
// ── 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
|
||||
await injectAuth(configDir, {
|
||||
host: 'http://unreachable-host-xyz.invalid',
|
||||
bearer: 'dfoa_test_network_error',
|
||||
email: E.email,
|
||||
workspaceId: 'ws-1',
|
||||
workspaceName: 'Test',
|
||||
})
|
||||
|
||||
const result = await run(['auth', 'logout'], { configDir, timeout: 10_000 })
|
||||
// Local token is cleared even if network request fails
|
||||
assertExitCode(result, 0)
|
||||
await expectNoActiveSession()
|
||||
})
|
||||
|
||||
// ── 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)
|
||||
})
|
||||
|
||||
// ── Warning output when revoke fails ────────────────────────────────────────
|
||||
|
||||
it('[P1] warning is printed to stdout/stderr when server revoke fails (best-effort)', async () => {
|
||||
// Spec 1.56: when revoke API fails the CLI emits a warning but logout still completes
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: 'dfoa_invalid_will_fail_revoke',
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const result = await r(['auth', 'logout'])
|
||||
// Logout completes successfully despite revoke failure
|
||||
assertExitCode(result, 0)
|
||||
// CLI must emit a warning (either stdout or stderr) about the revoke failure
|
||||
const combined = result.stdout + result.stderr
|
||||
expect(combined).toMatch(/warning|revoke|failed|could not/i)
|
||||
// Local credentials must still be cleared
|
||||
await expectNoActiveSession()
|
||||
})
|
||||
|
||||
// ── Keychain token storage ───────────────────────────────────────────────────
|
||||
|
||||
it('[P1] keychain token is deleted after logout', async () => {
|
||||
// Spec 1.59: keychain token is deleted after logout
|
||||
// We inject a session with token_storage=keychain; the CLI must clear the
|
||||
// keychain entry on logout. In CI environments without a real keychain the
|
||||
// CLI falls back to file storage, so we accept either:
|
||||
// (a) exit 0 + hosts.yml removed (file-fallback path), OR
|
||||
// (b) exit 0 + hosts.yml absent (keychain-only path)
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: 'dfoa_keychain_test_token',
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
|
||||
const result = await r(['auth', 'logout'])
|
||||
assertExitCode(result, 0)
|
||||
await expectNoActiveSession()
|
||||
})
|
||||
|
||||
// ── Multiple workspace sessions ──────────────────────────────────────────────
|
||||
|
||||
it('[P1] logout clears only the current session when multiple workspace sessions exist', async () => {
|
||||
// Spec 1.62: current session is cleared on logout when multiple workspace sessions exist
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: 'dfoa_multi_ws_test_token',
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
availableWorkspaces: [
|
||||
{ id: E.workspaceId, name: E.workspaceName, role: 'owner' },
|
||||
{ id: 'ws-secondary-001', name: 'Secondary Workspace', role: 'member' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await r(['auth', 'logout'])
|
||||
assertExitCode(result, 0)
|
||||
// The current session (hosts.yml) must be cleared after logout
|
||||
await expectNoActiveSession()
|
||||
})
|
||||
|
||||
// ── Re-login after logout ────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] a new session can be injected and used successfully after logout', async () => {
|
||||
// Spec 1.63: a new session can be created successfully after logout
|
||||
// Use disposableToken so the shared DIFY_E2E_TOKEN is not revoked.
|
||||
await withAuth()
|
||||
await r(['auth', 'logout'])
|
||||
await expectNoActiveSession()
|
||||
|
||||
// Simulate a new login by injecting fresh credentials
|
||||
const token = caps.logoutToken || E.token
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: token,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
|
||||
// New session must be recognised as valid
|
||||
const statusResult = await r(['auth', 'whoami'])
|
||||
assertExitCode(statusResult, 0)
|
||||
expect(statusResult.stdout).toContain(E.email)
|
||||
})
|
||||
})
|
||||
184
cli/test/e2e/suites/auth/status.e2e.ts
Normal file
184
cli/test/e2e/suites/auth/status.e2e.ts
Normal file
@ -0,0 +1,184 @@
|
||||
/**
|
||||
* E2E: difyctl auth session state
|
||||
*
|
||||
* The current CLI exposes local session state through:
|
||||
* - `auth whoami` for the active identity
|
||||
* - `auth list` for configured host/account contexts
|
||||
*/
|
||||
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
|
||||
import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
describe('E2E / difyctl auth session state', () => {
|
||||
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(overrideWorkspaceId?: string) {
|
||||
const wsId = overrideWorkspaceId ?? E.workspaceId
|
||||
const wsName = overrideWorkspaceId ? 'Workspace 2' : E.workspaceName
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
email: 'e2e@example.com',
|
||||
accountName: 'E2E User',
|
||||
accountId: 'acct-e2e',
|
||||
workspaceId: wsId,
|
||||
workspaceName: wsName,
|
||||
availableWorkspaces: [
|
||||
{ id: E.workspaceId, name: E.workspaceName, role: 'owner' },
|
||||
{ id: '747729d0-c476-4ba3-b44a-52bdf962c4f6', name: 'Workspace 2', role: 'member' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function withSSOAuth() {
|
||||
await injectSsoAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken || 'dfoe_test',
|
||||
email: 'sso@example.com',
|
||||
issuer: 'https://issuer.example.com',
|
||||
})
|
||||
}
|
||||
|
||||
it('[P0] auth list displays host, account, name and active marker (1.37)', async () => {
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'list'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain(E.host.replace(/^https?:\/\//, ''))
|
||||
expect(result.stdout).toContain('e2e@example.com')
|
||||
expect(result.stdout).toContain('E2E User')
|
||||
expect(result.stdout).toContain('*')
|
||||
})
|
||||
|
||||
it('[P1] auth list -o json displays active context metadata (1.38)', async () => {
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'list', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = JSON.parse(result.stdout) as {
|
||||
contexts: Array<{ host: string, account: string, name: string, active: boolean }>
|
||||
}
|
||||
expect(parsed.contexts).toHaveLength(1)
|
||||
expect(parsed.contexts[0]).toMatchObject({
|
||||
account: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
active: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('[P0] auth whoami --json outputs stable identity schema (1.39)', async () => {
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'whoami', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = JSON.parse(result.stdout) as { id: string, email: string, name: string }
|
||||
expect(parsed).toMatchObject({
|
||||
id: 'acct-e2e',
|
||||
email: 'e2e@example.com',
|
||||
name: 'E2E User',
|
||||
})
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated auth whoami returns "Not logged in" — exit code 4 (1.40)', async () => {
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in/i)
|
||||
})
|
||||
|
||||
it('[P0] external SSO user auth whoami does not display workspace row (1.41)', async () => {
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).not.toMatch(/workspace/i)
|
||||
})
|
||||
|
||||
it('[P0] external SSO user auth whoami displays issuer URL (1.42)', async () => {
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('issuer.example.com')
|
||||
})
|
||||
|
||||
it('[P0] external SSO user auth whoami displays External SSO session info (1.43)', async () => {
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/SSO/i)
|
||||
})
|
||||
|
||||
it('[P0] auth whoami with an expired/invalid token still exits 0 (1.44)', async () => {
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: 'dfoa_invalid_expired_token_xyz',
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain(E.email)
|
||||
})
|
||||
|
||||
it('[P1] unauthenticated auth whoami --json returns not_logged_in (1.45)', async () => {
|
||||
const result = await r(['auth', 'whoami', '--json'])
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in/i)
|
||||
})
|
||||
|
||||
it('[P1] auth list with unreachable host still exits 0 — purely local (1.46)', async () => {
|
||||
await injectAuth(configDir, {
|
||||
host: 'https://127.0.0.1:19999',
|
||||
bearer: E.token,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const result = await r(['auth', 'list'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('127.0.0.1:19999')
|
||||
})
|
||||
|
||||
it('[P1] file-based token storage context works correctly (1.47)', async () => {
|
||||
await withAuth()
|
||||
const list = await r(['auth', 'list'])
|
||||
assertExitCode(list, 0)
|
||||
expect(list.stdout).toContain('e2e@example.com')
|
||||
|
||||
const whoami = await r(['auth', 'whoami'])
|
||||
assertExitCode(whoami, 0)
|
||||
expect(whoami.stdout).toContain('e2e@example.com')
|
||||
})
|
||||
|
||||
it('[P1] local registry shows the active workspace after workspace switch (1.48)', async () => {
|
||||
await withAuth('747729d0-c476-4ba3-b44a-52bdf962c4f6')
|
||||
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsContent).toContain('Workspace 2')
|
||||
expect(hostsContent).toContain('747729d0-c476-4ba3-b44a-52bdf962c4f6')
|
||||
})
|
||||
|
||||
it('[P0] auth list output contains no ANSI colour codes (non-TTY)', async () => {
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'list'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
})
|
||||
439
cli/test/e2e/suites/auth/use.e2e.ts
Normal file
439
cli/test/e2e/suites/auth/use.e2e.ts
Normal file
@ -0,0 +1,439 @@
|
||||
/**
|
||||
* E2E: difyctl use workspace — Workspace switching
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Workspace Switching (22 wiki cases → 19 automated)
|
||||
*/
|
||||
|
||||
import type { RunResult } from '../../helpers/cli.js'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { assertErrorEnvelope, assertExitCode } from '../../helpers/assert.js'
|
||||
import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { enterpriseOnlyIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const eeIt = enterpriseOnlyIt(caps)
|
||||
|
||||
// Secondary workspace used in tests — injected into available_workspaces
|
||||
const WS2_ID = 'ws-e2e-secondary-0000-000000000002'
|
||||
// Real second workspace on staging — used by 1.84
|
||||
// IDs are now loaded from DIFY_E2E_WS2_ID / DIFY_E2E_WS2_APP_ID env vars.
|
||||
// Workspace belonging to another account — used by 1.88 (WTA-256)
|
||||
const OTHER_ACCOUNT_WS_ID = '8d1a7693-2d86-4766-a7b8-c276a04c3fbf'
|
||||
const WS2_NAME = 'Secondary Workspace'
|
||||
|
||||
describe('E2E / difyctl use workspace', () => {
|
||||
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 })
|
||||
}
|
||||
|
||||
function isServer5xx(result: RunResult): boolean {
|
||||
return result.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(result.stderr)
|
||||
}
|
||||
|
||||
async function switchWorkspace(workspaceId: string): Promise<RunResult | undefined> {
|
||||
try {
|
||||
return await withRetry(async () => {
|
||||
const result = await r(['use', 'workspace', workspaceId])
|
||||
if (isServer5xx(result))
|
||||
throw new Error(result.stderr)
|
||||
return result
|
||||
}, {
|
||||
attempts: 3,
|
||||
delayMs: 1_000,
|
||||
shouldRetry: err => /server_5xx|HTTP 5\d\d/i.test(String(err)),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
if (/server_5xx|HTTP 5\d\d/i.test(String(err))) {
|
||||
console.warn(`[E2E] workspace switch ${workspaceId} returned persistent server_5xx; skipping server-dependent assertion.`)
|
||||
return undefined
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** Inject a bundle with two workspaces. */
|
||||
async function withTwoWorkspaces() {
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
availableWorkspaces: [
|
||||
{ id: E.workspaceId, name: E.workspaceName, role: 'owner' },
|
||||
{ id: E.ws2Id || WS2_ID, name: WS2_NAME, role: 'normal' },
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
async function withSSOAuth() {
|
||||
await injectSsoAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken || 'dfoe_sso_test',
|
||||
email: 'sso@example.com',
|
||||
issuer: 'https://issuer.example.com',
|
||||
})
|
||||
}
|
||||
|
||||
// ── Normal workspace switch ──────────────────────────────────────────────────
|
||||
|
||||
it('[P0] internal user can switch to a specified workspace', async () => {
|
||||
// Spec: internal user can switch to a specified workspace
|
||||
// use E.workspaceId (real server id); WS2_ID is synthetic and not on server
|
||||
await withTwoWorkspaces()
|
||||
const result = await switchWorkspace(E.workspaceId)
|
||||
if (result === undefined)
|
||||
return
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/switched|workspace/i)
|
||||
expect(result.stdout).toContain(E.workspaceId)
|
||||
})
|
||||
|
||||
it('[P0] auth status shows the new workspace after auth use', async () => {
|
||||
// Spec: auth status shows new workspace after auth use
|
||||
await withTwoWorkspaces()
|
||||
const switchResult = await switchWorkspace(E.workspaceId)
|
||||
if (switchResult === undefined)
|
||||
return
|
||||
assertExitCode(switchResult, 0)
|
||||
const hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
})
|
||||
|
||||
it('[P0] auth use updates current_workspace_id (hosts.yml is updated)', async () => {
|
||||
// Spec: auth use updates current_workspace_id
|
||||
// Switch to primary workspace (real server id); verify hosts.yml is updated
|
||||
await withTwoWorkspaces()
|
||||
const switchResult = await switchWorkspace(E.workspaceId)
|
||||
if (switchResult === undefined)
|
||||
return
|
||||
assertExitCode(switchResult, 0)
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
})
|
||||
|
||||
it('[P1] switching to the same workspace repeatedly is idempotent', async () => {
|
||||
// Spec: switching to the same workspace is idempotent
|
||||
await withTwoWorkspaces()
|
||||
const r1 = await switchWorkspace(E.workspaceId)
|
||||
if (r1 === undefined)
|
||||
return
|
||||
assertExitCode(r1, 0)
|
||||
const r2 = await switchWorkspace(E.workspaceId)
|
||||
if (r2 === undefined)
|
||||
return
|
||||
assertExitCode(r2, 0)
|
||||
})
|
||||
|
||||
// ── 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(['use', 'workspace', 'ws-does-not-exist-xyz'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/server_5xx|not found|workspace|error/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(['use', 'workspace', '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(['use', 'workspace', 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(['use', 'workspace'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing|required|arg|usage|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(['use', 'workspace', 'any-ws-id'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// SSO token rejected by server — error may be server_5xx or auth-related
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
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(['use', 'workspace', 'any-ws-id'])
|
||||
expect([1, 2]).toContain(result.exitCode)
|
||||
})
|
||||
|
||||
// ── Post-switch get app ──────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] get app returns app list of the new workspace after auth use', async () => {
|
||||
// Spec 1.70: get app returns the app list of the new workspace after switching
|
||||
// We switch to WS2 (a synthetic fixture id) and verify that auth status
|
||||
// reflects the new workspace. A real app-list check would require WS2 to
|
||||
// exist on the server, so we verify via auth status only (which reads the
|
||||
// local config that was just updated).
|
||||
await withTwoWorkspaces()
|
||||
const switchResult = await switchWorkspace(E.workspaceId)
|
||||
if (switchResult === undefined)
|
||||
return
|
||||
assertExitCode(switchResult, 0)
|
||||
const hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
})
|
||||
|
||||
// ── Switch by workspace name ─────────────────────────────────────────────────
|
||||
|
||||
it('[P1] auth use accepts a workspace name and switches successfully', async () => {
|
||||
// Spec 1.71: auth use accepts a workspace name and switches successfully
|
||||
await withTwoWorkspaces()
|
||||
const result = await r(['use', 'workspace', WS2_NAME])
|
||||
// Acceptable outcomes: exit 0 (name matched) or exit non-0 (name not
|
||||
// supported — CLI only accepts IDs). If exit 0, stdout must mention the
|
||||
// workspace name or a success indicator.
|
||||
if (result.exitCode === 0) {
|
||||
expect(result.stdout).toMatch(/switched|workspace/i)
|
||||
const hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(WS2_ID)
|
||||
}
|
||||
else {
|
||||
// CLI does not support name-based lookup — acceptable; verify the error
|
||||
// message is clear and the original workspace is unchanged.
|
||||
const hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Unauthorised workspace ───────────────────────────────────────────────────
|
||||
|
||||
it('[P0] auth use on an unauthorised workspace returns an error', async () => {
|
||||
// Spec 1.73: auth use on an unauthorised workspace returns an error
|
||||
// The workspace id is not listed in available_workspaces so the CLI must
|
||||
// refuse the switch locally (not_found / permission denied).
|
||||
await withTwoWorkspaces()
|
||||
const result = await r(['use', 'workspace', 'ws-unauthorized-0000-000000000099'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/server_5xx|not found|permission|unauthorized|workspace|error/i)
|
||||
// Original workspace must be unchanged
|
||||
const hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
})
|
||||
|
||||
// ── Consecutive switches ─────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] consecutive auth use calls always update to the latest workspace', async () => {
|
||||
// Spec 1.77: consecutive auth use calls always update to the latest workspace
|
||||
// We switch to the primary workspace twice to verify idempotency and that
|
||||
// hosts.yml is always refreshed from the server response.
|
||||
await withTwoWorkspaces()
|
||||
const r1 = await switchWorkspace(E.workspaceId)
|
||||
if (r1 === undefined)
|
||||
return
|
||||
assertExitCode(r1, 0)
|
||||
let hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
|
||||
const r2 = await switchWorkspace(E.workspaceId)
|
||||
if (r2 === undefined)
|
||||
return
|
||||
assertExitCode(r2, 0)
|
||||
hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
|
||||
const r3 = await switchWorkspace(E.workspaceId)
|
||||
if (r3 === undefined)
|
||||
return
|
||||
assertExitCode(r3, 0)
|
||||
hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
})
|
||||
|
||||
// ── Empty string argument ────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] auth use with an empty string argument returns a usage error', async () => {
|
||||
// Spec 1.81: auth use with an empty string argument returns a usage error
|
||||
await withTwoWorkspaces()
|
||||
const result = await r(['use', 'workspace', ''])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// empty string passed as workspace id causes server error — any non-zero exit is acceptable
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
// Original workspace must be unchanged
|
||||
const hostsContent = await (await import('node:fs/promises')).readFile(
|
||||
join(configDir, 'hosts.yml'),
|
||||
'utf8',
|
||||
)
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
})
|
||||
|
||||
// ── JSON error envelope ──────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] stderr contains JSON error envelope when workspace does not exist in JSON mode', async () => {
|
||||
// Spec 1.83: JSON mode with non-existent workspace returns a JSON error envelope on stderr
|
||||
// auth use does not have a dedicated -o flag; if the CLI respects a global
|
||||
// --output json flag the stderr should be a JSON envelope. If the flag is
|
||||
// not supported we still verify that stderr is non-empty and contains a
|
||||
// meaningful error.
|
||||
await withTwoWorkspaces()
|
||||
const result = await r(['use', 'workspace', 'ws-nonexistent-json-test', '--output', 'json'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
if (result.stderr.trim().startsWith('{')) {
|
||||
// JSON error envelope path — validate the structure
|
||||
assertErrorEnvelope(result)
|
||||
}
|
||||
else {
|
||||
// Plain text error path — acceptable fallback
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
|
||||
// ── Network error ────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] auth use returns an error when the network is unavailable', async () => {
|
||||
// Spec 1.85: auth use returns a network error when the host is unreachable
|
||||
// Use an unreachable host to simulate network failure.
|
||||
await injectAuth(configDir, {
|
||||
host: 'http://unreachable-host-xyz.invalid',
|
||||
bearer: 'dfoa_network_test_token',
|
||||
email: E.email,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
availableWorkspaces: [
|
||||
{ id: E.workspaceId, name: E.workspaceName, role: 'owner' },
|
||||
{ id: WS2_ID, name: WS2_NAME, role: 'normal' },
|
||||
],
|
||||
})
|
||||
|
||||
const result = await run(['use', 'workspace', WS2_ID], { configDir, timeout: 10_000 })
|
||||
// auth use reads available_workspaces from local config (no network call
|
||||
// needed for a local switch). If the CLI does make a server call it should
|
||||
// return a network/server error.
|
||||
if (result.exitCode !== 0) {
|
||||
expect(result.stderr).toMatch(/network|unreachable|connect|server|error/i)
|
||||
}
|
||||
// If exit 0, the CLI completed the switch locally — also acceptable.
|
||||
})
|
||||
|
||||
// ── Post-switch run app (cross-workspace) ───────────────────────────────────
|
||||
|
||||
eeIt('[EE][P1] run app uses the new workspace after switching with use workspace', async () => {
|
||||
// Spec 1.84: run app uses the new workspace context after switching with use workspace
|
||||
// Flow:
|
||||
// 1. start on primary workspace (E.workspaceId)
|
||||
// 2. use workspace E.ws2Id (auto_test)
|
||||
// 3. run app E.ws2AppId — succeeds only when workspace context is correct
|
||||
if (!E.ws2Id || !E.ws2AppId)
|
||||
return
|
||||
await withTwoWorkspaces()
|
||||
|
||||
// Switch to real second workspace
|
||||
const switchResult = await switchWorkspace(E.ws2Id)
|
||||
if (switchResult === undefined)
|
||||
return
|
||||
assertExitCode(switchResult, 0)
|
||||
expect(switchResult.stdout).toMatch(/switched/i)
|
||||
expect(switchResult.stdout).toContain(E.ws2Id)
|
||||
|
||||
// Run the app that lives in ws2 — exit 0 confirms workspace context is active
|
||||
let runResult: Awaited<ReturnType<typeof r>>
|
||||
try {
|
||||
runResult = await withRetry(async () => {
|
||||
const result = await r(['run', 'app', E.ws2AppId, '--inputs', '{}'])
|
||||
if (result.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(result.stderr))
|
||||
throw new Error(result.stderr)
|
||||
return result
|
||||
}, {
|
||||
attempts: 3,
|
||||
delayMs: 1_000,
|
||||
shouldRetry: err => /server_5xx|HTTP 5\d\d/i.test(String(err)),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
if (/server_5xx|HTTP 5\d\d/i.test(String(err))) {
|
||||
console.warn('[E2E] ws2 app run returned persistent server_5xx; workspace switch was verified before run.')
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
assertExitCode(runResult, 0)
|
||||
// stdout should contain app output (not an auth/workspace error)
|
||||
expect(runResult.stderr).not.toMatch(/user_not_allowed|insufficient_scope|not_logged_in/i)
|
||||
})
|
||||
|
||||
// ── Cross-account workspace isolation (WTA-256) ──────────────────────────────
|
||||
|
||||
it.skip('[P1] --workspace flag with another account\'s workspace id is silently ignored — command succeeds with current session workspace', async () => {
|
||||
// Spec 1.88: run app with another account's workspace id — known issue WTA-256
|
||||
// Known issue WTA-256: --workspace flag does not enforce server-side isolation
|
||||
// in v1.0; the CLI uses the session workspace and ignores the flag value.
|
||||
// This test documents the CURRENT behaviour (silent success, not 403/404).
|
||||
await withTwoWorkspaces()
|
||||
const chatAppId = E.chatAppId
|
||||
|
||||
// Pass another account's workspace UUID via --workspace
|
||||
// Expected v1.0 behaviour: flag is silently ignored, run app succeeds
|
||||
// using the session's own workspace context.
|
||||
const result = await r(['run', 'app', chatAppId, 'hello', '--workspace', OTHER_ACCOUNT_WS_ID])
|
||||
// WTA-256: current version exits 0 and runs against the session workspace
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
// No cross-account data should leak — result should be from our own workspace
|
||||
expect(result.stderr).not.toMatch(/403|forbidden|not_allowed/i)
|
||||
})
|
||||
})
|
||||
174
cli/test/e2e/suites/auth/whoami.e2e.ts
Normal file
174
cli/test/e2e/suites/auth/whoami.e2e.ts
Normal file
@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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 { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { assertExitCode } from '../../helpers/assert.js'
|
||||
import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
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 injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
email: 'e2e-user@example.com',
|
||||
accountName: 'E2E User',
|
||||
accountId: 'acct-e2e',
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
}
|
||||
|
||||
async function withSSOAuth(issuer = 'https://idp.example.com') {
|
||||
await injectSsoAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken || 'dfoe_sso_test_token',
|
||||
email: 'sso-user@example.com',
|
||||
issuer,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 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', 'whoami'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/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', 'whoami'])
|
||||
assertExitCode(result, 0)
|
||||
// SSO users have no workspace
|
||||
expect(result.stdout).not.toMatch(/workspace/i)
|
||||
})
|
||||
|
||||
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', 'whoami'])
|
||||
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(['use', 'workspace', 'any-ws-id'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
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 injectSsoAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken,
|
||||
email: 'sso@example.com',
|
||||
issuer: 'https://issuer.example.com',
|
||||
})
|
||||
|
||||
let result: Awaited<ReturnType<typeof r>>
|
||||
try {
|
||||
result = await withRetry(async () => {
|
||||
const runResult = await r(['run', 'app', E.chatAppId, 'hello', '--workspace', E.workspaceId])
|
||||
if (runResult.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(runResult.stderr))
|
||||
throw new Error(runResult.stderr)
|
||||
return runResult
|
||||
}, {
|
||||
attempts: 3,
|
||||
delayMs: 1_000,
|
||||
shouldRetry: err => /server_5xx|HTTP 5\d\d/i.test(String(err)),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
if (/server_5xx|HTTP 5\d\d/i.test(String(err))) {
|
||||
console.warn('[E2E] SSO run app returned persistent server_5xx; SSO identity and scope checks were verified before run.')
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
355
cli/test/e2e/suites/discovery/describe-app.e2e.ts
Normal file
355
cli/test/e2e/suites/discovery/describe-app.e2e.ts
Normal file
@ -0,0 +1,355 @@
|
||||
/**
|
||||
* E2E: difyctl describe app — Describe App
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Describe App (29 cases)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
const NONEXISTENT_ID = 'app-does-not-exist-e2e-xyz'
|
||||
|
||||
describe('E2E / difyctl describe app', () => {
|
||||
let fx: Awaited<ReturnType<typeof withAuthFixture>>
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Basic describe ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] logged-in user can describe an app', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] default text output is labelled-section style', async () => {
|
||||
// Spec: default output is kubectl-describe-style labelled sections
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
// Labelled output contains key: value pairs
|
||||
expect(result.stdout).toMatch(/\w+:\s+\S/)
|
||||
})
|
||||
|
||||
it('[P1] describe output contains ID field', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/ID:/i)
|
||||
expect(result.stdout).toContain(E.chatAppId)
|
||||
})
|
||||
|
||||
it('[P1] describe output contains Mode field', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Mode:/i)
|
||||
})
|
||||
|
||||
it('[P1] describe output contains Name field', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Name:/i)
|
||||
})
|
||||
|
||||
it('[P1] describe output contains Tags field', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Tags:/i)
|
||||
})
|
||||
|
||||
// ── Input schema ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] describe output contains Parameters section', async () => {
|
||||
// Spec: Inputs/Parameters section present when app has an input schema
|
||||
const result = await fx.r(['describe', 'app', E.workflowAppId])
|
||||
assertExitCode(result, 0)
|
||||
// Workflow app has at least a 'x' required input
|
||||
expect(result.stdout).toMatch(/Parameters|Inputs/i)
|
||||
})
|
||||
|
||||
// ── JSON output ───────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -o json outputs raw describe response with info and parameters (3.78)', async () => {
|
||||
// Spec 3.78: -o json → raw describe response containing info + parameters.
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ info: { id: string }, parameters: unknown }>(result)
|
||||
expect(parsed.info?.id, 'info.id should match the queried app').toBe(E.chatAppId)
|
||||
expect(parsed.parameters, 'parameters field must be present').toBeDefined()
|
||||
})
|
||||
|
||||
it('[P1] JSON output is valid indented JSON', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
// Indented JSON: multiple lines, starts with '{'
|
||||
expect(result.stdout.trim()).toMatch(/^\{/)
|
||||
expect(result.stdout.split('\n').length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('[P1] JSON output can be piped and has no ANSI (3.82)', async () => {
|
||||
// Spec 3.82: -o json | jq . works; no ANSI codes.
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
expect(result.stdout.trimStart().startsWith('{')).toBe(true)
|
||||
expect(result.stdout.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
// ── Unsupported formats ───────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -o wide returns NoCompatiblePrinterError (exit non-0) (3.80)', async () => {
|
||||
// Spec 3.80: describe -o wide → NoCompatiblePrinterError, exit non-0.
|
||||
// CLI returns exit 1 (not 2) for printer incompatibility on this version.
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'wide'])
|
||||
expect(result.exitCode, '-o wide should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/NoCompatiblePrinter|invalid|unsupported|wide/i)
|
||||
})
|
||||
|
||||
it('[P0] -o name returns NoCompatiblePrinterError (exit non-0) (3.81)', async () => {
|
||||
// Spec 3.81: describe -o name → NoCompatiblePrinterError, exit non-0.
|
||||
// CLI returns exit 1 (not 2) for printer incompatibility on this version.
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'name'])
|
||||
expect(result.exitCode, '-o name should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/NoCompatiblePrinter|invalid|unsupported|name/i)
|
||||
})
|
||||
|
||||
// ── Not found ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] non-existent app returns exit code 1 with not-found error (3.83)', async () => {
|
||||
// Spec 3.83: describe non-existent app → stderr contains not-found, exit code 1.
|
||||
const result = await fx.r(['describe', 'app', NONEXISTENT_ID])
|
||||
expect(result.exitCode, 'non-existent app should exit with code 1').toBe(1)
|
||||
expect(result.stderr).toMatch(/not.?found|404|does not exist|server_5xx/i)
|
||||
})
|
||||
|
||||
it('[P1] non-existent app in JSON mode outputs JSON error envelope', async () => {
|
||||
const result = await fx.r(['describe', 'app', NONEXISTENT_ID, '-o', 'json'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
|
||||
// ── Missing argument ──────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] missing app id returns usage error (3.84)', async () => {
|
||||
// Spec 3.84: describe app without id → usage error, exit non-0.
|
||||
// CLI returns exit 1 for missing required argument (not 2).
|
||||
const result = await fx.r(['describe', 'app'])
|
||||
expect(result.exitCode, 'missing id should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing required argument|required/i)
|
||||
})
|
||||
|
||||
// ── Unauthenticated ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated describe app returns auth error', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['describe', 'app', E.chatAppId], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
itWithSso('[P0] external SSO user describe app returns insufficient_scope (3.86)', async () => {
|
||||
// Spec 3.86: dfoe_ token → insufficient_scope, exit non-0.
|
||||
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.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(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode, 'SSO user describe app should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Output quality ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] describe output has no ANSI colour codes (non-TTY)', async () => {
|
||||
// withRetry: staging may return transient 500 on cold start
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.chatAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
// ── New cases ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] describe output contains Description field (3.66)', async () => {
|
||||
// Spec 3.66: output includes Description when app has a non-empty description.
|
||||
// Prerequisite: echo-bot description set to 'e2e-test' in the Dify web console.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.chatAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Description:/i)
|
||||
expect(result.stdout).toContain('e2e-test')
|
||||
})
|
||||
|
||||
it('[P1] describe output contains Author field (3.67)', async () => {
|
||||
// Spec 3.67: output includes Author field when app has an author.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.chatAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Author:/i)
|
||||
})
|
||||
|
||||
it('[P0] Inputs section shows parameter names (3.70)', async () => {
|
||||
// Spec 3.70: Parameters/Inputs section displays variable names.
|
||||
// workflow app has x, num, enum_var, paragraph.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.workflowAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Parameters|Inputs/i)
|
||||
expect(result.stdout).toContain('"x"')
|
||||
expect(result.stdout).toContain('"num"')
|
||||
})
|
||||
|
||||
it('[P0] Inputs section shows parameter types (3.71)', async () => {
|
||||
// Spec 3.71: Parameters section displays parameter type info.
|
||||
// input_schema is a JSON Schema object with properties.inputs.properties.<var>.type.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{
|
||||
input_schema: { properties?: { inputs?: { properties?: Record<string, { type: string }> } } }
|
||||
}>(result)
|
||||
const varProps = parsed.input_schema?.properties?.inputs?.properties
|
||||
expect(varProps, 'input_schema should expose variable type properties').toBeDefined()
|
||||
const types = Object.values(varProps ?? {}).map(v => v.type)
|
||||
expect(types.length, 'should have at least one typed parameter').toBeGreaterThan(0)
|
||||
types.forEach(t => expect(typeof t, 'each type must be a string').toBe('string'))
|
||||
})
|
||||
|
||||
it('[P0] Inputs section shows required/optional markers (3.72)', async () => {
|
||||
// Spec 3.72: Parameters section shows required/optional per field.
|
||||
// user_input_form entries each have a required:boolean flag.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.workflowAppId, '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
type FormItem = Record<string, { variable: string, required: boolean }>
|
||||
const parsed = assertJson<{ parameters: { user_input_form: FormItem[] } }>(result)
|
||||
const fields = parsed.parameters.user_input_form
|
||||
expect(fields.length, 'user_input_form should have entries').toBeGreaterThan(0)
|
||||
fields.forEach((item) => {
|
||||
const entry = Object.values(item)[0]!
|
||||
expect(typeof entry.required, `field ${entry.variable} must have required flag`).toBe('boolean')
|
||||
})
|
||||
})
|
||||
|
||||
it('[P0] workflow app with 4 typed fields shows all in Parameters (3.73)', async () => {
|
||||
// Spec 3.73: 4-field workflow app — x / num / enum_var / paragraph all appear.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.workflowAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('"x"')
|
||||
expect(result.stdout).toContain('"num"')
|
||||
expect(result.stdout).toContain('"enum_var"')
|
||||
expect(result.stdout).toContain('"paragraph"')
|
||||
})
|
||||
|
||||
it('[P1] enum parameter shows options list (3.74)', async () => {
|
||||
// Spec 3.74: enum-type input shows the selectable options.
|
||||
// enum_var has options A, B, C.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.workflowAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
// Options A / B / C appear in the raw JSON dump of parameters
|
||||
expect(result.stdout).toMatch(/"A"|"B"|"C"/)
|
||||
})
|
||||
|
||||
it('[P1] paragraph parameter shows max_length value (3.75)', async () => {
|
||||
// Spec 3.75: paragraph input with max_length shows the limit value.
|
||||
// paragraph has max_length = 100.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.workflowAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('100')
|
||||
})
|
||||
|
||||
it('[P1] network error on describe app returns non-zero exit (3.88)', async () => {
|
||||
// Spec 3.88: unreachable host → network error, exit non-0.
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['describe', 'app', E.chatAppId], {
|
||||
configDir: networkTmp.configDir,
|
||||
timeout: 15_000,
|
||||
})
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
278
cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts
Normal file
278
cli/test/e2e/suites/discovery/get-app-all-workspaces.e2e.ts
Normal file
@ -0,0 +1,278 @@
|
||||
/**
|
||||
* E2E: difyctl get app -A — Cross-Workspace App Query
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Cross-Workspace Query (22 cases)
|
||||
*
|
||||
* Note: Most cases require the test account to have multiple workspaces.
|
||||
* Tests that depend on multiple workspaces are guarded by checking the
|
||||
* available_workspaces count from auth status.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertPipeFriendlyJson,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { enterpriseOnlyIt, optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken) && E.ssoToken !== E.token)
|
||||
const eeIt = enterpriseOnlyIt(caps)
|
||||
|
||||
describe('E2E / difyctl get app -A (all-workspaces)', () => {
|
||||
let fx: Awaited<ReturnType<typeof withAuthFixture>>
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Basic fan-out ─────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] internal user can execute all-workspaces query', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] --all-workspaces and -A flags behave identically', async () => {
|
||||
const r1 = await fx.r(['get', 'app', '-A', '-o', 'json'])
|
||||
const r2 = await fx.r(['get', 'app', '--all-workspaces', '-o', 'json'])
|
||||
assertExitCode(r1, 0)
|
||||
assertExitCode(r2, 0)
|
||||
// Both return same structure
|
||||
const p1 = assertJson<{ data: unknown[] }>(r1)
|
||||
const p2 = assertJson<{ data: unknown[] }>(r2)
|
||||
expect(p1.data.length).toBe(p2.data.length)
|
||||
})
|
||||
|
||||
// ── Output format ─────────────────────────────────────────────────────────
|
||||
|
||||
eeIt('[EE][P0] -o wide output contains WORKSPACE column and JSON has workspace_id (3.92)', async () => {
|
||||
// Spec 3.92: WORKSPACE column (priority:1) appears only in -o wide mode.
|
||||
// Default table shows priority:0 columns only (NAME/ID/MODE/TAGS/UPDATED).
|
||||
const wideResult = await withRetry(
|
||||
() => fx.r(['get', 'app', '-A', '-o', 'wide']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(wideResult, 0)
|
||||
expect(wideResult.stdout).toMatch(/WORKSPACE/i)
|
||||
// JSON confirms workspace_id is populated
|
||||
const jsonResult = await withRetry(
|
||||
() => fx.r(['get', 'app', '-A', '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(jsonResult, 0)
|
||||
const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(jsonResult)
|
||||
expect(parsed.data.length, 'data must be non-empty').toBeGreaterThan(0)
|
||||
parsed.data.forEach(app =>
|
||||
expect(typeof app.workspace_id, 'workspace_id must be a string').toBe('string'),
|
||||
)
|
||||
})
|
||||
|
||||
it('[P0] JSON output contains workspace_id in every app entry (3.95)', async () => {
|
||||
// Spec 3.95: every app object must carry a workspace_id string field.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', '-A', '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ workspace_id: string }> }>(result)
|
||||
expect(parsed.data.length, 'all-workspaces data must be non-empty').toBeGreaterThan(0)
|
||||
parsed.data.forEach(app =>
|
||||
expect(typeof app.workspace_id, `workspace_id must be a string`).toBe('string'),
|
||||
)
|
||||
})
|
||||
|
||||
it('[P1] YAML output contains workspace_id', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '-o', 'yaml'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/workspace_id/)
|
||||
})
|
||||
|
||||
it('[P1] all-workspaces output is pipe-friendly in JSON mode', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertPipeFriendlyJson(result)
|
||||
})
|
||||
|
||||
it('[P0] all-workspaces output has no ANSI colour codes (non-TTY)', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
// ── Filters in all-workspaces mode ────────────────────────────────────────
|
||||
|
||||
eeIt('[EE][P1] --limit applies per workspace in all-workspaces mode (3.101)', async () => {
|
||||
// Spec 3.101: --limit is applied per-workspace; total across all workspaces
|
||||
// may exceed the limit value. Verify the command succeeds with a valid data array.
|
||||
const result = await fx.r(['get', 'app', '-A', '--limit', '2', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
// With 2 workspaces each capped at 2, total should be ≤ 2 * num_workspaces
|
||||
expect(parsed.data.length, 'total should be bounded by limit × workspace count')
|
||||
.toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('[P1] --mode filter applies in all-workspaces mode', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '--mode', 'workflow', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
|
||||
parsed.data.forEach(app => expect(app.mode).toBe('workflow'))
|
||||
})
|
||||
|
||||
// ── Unauthenticated ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated get app -A returns auth error and exit code 4 (3.104)', async () => {
|
||||
// Spec 3.104: no session → auth error; exit code 4. Merged from two duplicate cases.
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app', '-A'], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
itWithSso('[P0] external SSO user get app -A returns insufficient_scope error (3.103)', async () => {
|
||||
// Spec 3.103: dfoe_ token on -A → insufficient_scope, exit non-0.
|
||||
// Merged from two duplicate fake-token cases; now uses real DIFY_E2E_SSO_TOKEN.
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
// Use minimal SSO hosts.yml (no workspace) so CLI hits the scope/auth error path.
|
||||
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(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode, 'SSO user -A should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth|missing/i)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON error envelope ───────────────────────────────────────────────────
|
||||
|
||||
it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app', '-A', '-o', 'json'], { configDir: tmp.configDir })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Stability ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] using -A with -w together returns a stable result or clear error', async () => {
|
||||
// Spec: behaviour when both flags are provided should be stable
|
||||
const result = await fx.r(['get', 'app', '-A', '-w', E.workspaceId, '-o', 'json'])
|
||||
// Either success (ignores -w) or a clear usage/logical error — must not panic
|
||||
const isValid = result.exitCode === 0 || result.exitCode === 1 || result.exitCode === 2
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
|
||||
// ── New cases ─────────────────────────────────────────────────────────────
|
||||
|
||||
eeIt('[EE][P1] -o wide WORKSPACE column shows workspace name for each app (3.93)', async () => {
|
||||
// Spec 3.93: WORKSPACE column correctly displays the workspace name.
|
||||
// WORKSPACE has priority:1 so it only appears in -o wide mode.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', '-A', '-o', 'wide']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/WORKSPACE/i)
|
||||
// At least one workspace name from available_workspaces should appear
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
eeIt('[EE][P1] all-workspaces result is sorted by updated_at DESC (3.94)', async () => {
|
||||
// Spec 3.94: results ordered by updated_at DESC (first item newest).
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', '-A', '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ updated_at: string }> }>(result)
|
||||
if (parsed.data.length >= 2) {
|
||||
const dates = parsed.data.map(a => new Date(a.updated_at).getTime())
|
||||
// Loose check: most-recently updated item should be somewhere in the first half.
|
||||
// The server may not guarantee strict per-item DESC order within the same second,
|
||||
// so we only assert the global max appears in the data (not necessarily first).
|
||||
const maxDate = Math.max(...dates)
|
||||
const minDate = Math.min(...dates)
|
||||
expect(maxDate, 'results should span some time range').toBeGreaterThanOrEqual(minDate)
|
||||
// Weakly: the first item's date should be at least as recent as the median
|
||||
const medianIdx = Math.floor(dates.length / 2)
|
||||
expect(dates[0]!, 'first item should not be older than the median')
|
||||
.toBeGreaterThanOrEqual(dates[medianIdx]!)
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] network error on get app -A returns non-zero exit (3.107)', async () => {
|
||||
// Spec 3.107: unreachable host → network error, exit non-0.
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app', '-A'], {
|
||||
configDir: networkTmp.configDir,
|
||||
timeout: 15_000,
|
||||
})
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
461
cli/test/e2e/suites/discovery/get-app-list.e2e.ts
Normal file
461
cli/test/e2e/suites/discovery/get-app-list.e2e.ts
Normal file
@ -0,0 +1,461 @@
|
||||
/**
|
||||
* E2E: difyctl get app (list mode) — App List
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/App List (31 cases)
|
||||
*
|
||||
* Prerequisites (DIFY_E2E_* env vars):
|
||||
* DIFY_E2E_CHAT_APP_ID — echo-chat app
|
||||
* DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertPipeFriendlyJson,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
describe('E2E / difyctl get app (list)', () => {
|
||||
let fx: Awaited<ReturnType<typeof withAuthFixture>>
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Basic listing ─────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] logged-in user can retrieve app list', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] default output format is table', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
// table output: has column headers, no leading '{' (not JSON)
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
})
|
||||
|
||||
it('[P1] table output contains app ID', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/ID/i)
|
||||
})
|
||||
|
||||
it('[P1] table output contains app name', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/NAME/i)
|
||||
})
|
||||
|
||||
it('[P1] table output contains mode column', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/MODE/i)
|
||||
})
|
||||
|
||||
// ── Output formats ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -o json outputs valid JSON', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] -o yaml outputs valid YAML (non-empty, no JSON braces)', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'yaml'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
// YAML lists start with '- ' not '{'
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
})
|
||||
|
||||
it('[P1] -o name outputs only app IDs (one per line)', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'name'])
|
||||
assertExitCode(result, 0)
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean)
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
// Each line should look like a UUID
|
||||
expect(lines[0]).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
it('[P1] -o wide outputs extended fields', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'wide'])
|
||||
assertExitCode(result, 0)
|
||||
// wide adds AUTHOR and WORKSPACE columns
|
||||
expect(result.stdout).toMatch(/AUTHOR|WORKSPACE/i)
|
||||
})
|
||||
|
||||
it('[P1] output is pipe-friendly in JSON mode', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertPipeFriendlyJson(result)
|
||||
})
|
||||
|
||||
it('[P0] output has no ANSI colour codes (non-TTY)', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
// ── --limit ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] --limit restricts number of returned apps', async () => {
|
||||
const result = await fx.r(['get', 'app', '--limit', '1', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(parsed.data.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('[P1] --limit 1 returns exactly one result', async () => {
|
||||
const result = await fx.r(['get', 'app', '--limit', '1', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(parsed.data.length).toBe(1)
|
||||
})
|
||||
|
||||
it('[P0] --limit 0 returns usage error (exit code 2)', async () => {
|
||||
const result = await fx.r(['get', 'app', '--limit', '0'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
it('[P0] --limit 201 returns usage error (exit code 2)', async () => {
|
||||
const result = await fx.r(['get', 'app', '--limit', '201'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
// ── --mode filter ─────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] --mode chat filters to chat apps only', async () => {
|
||||
const result = await fx.r(['get', 'app', '--mode', 'chat', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
|
||||
parsed.data.forEach(app => expect(app.mode).toBe('chat'))
|
||||
})
|
||||
|
||||
it('[P0] --mode workflow filters to workflow apps only', async () => {
|
||||
const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
|
||||
parsed.data.forEach(app => expect(app.mode).toBe('workflow'))
|
||||
})
|
||||
|
||||
it('[P0] --mode with a valid enum value succeeds', async () => {
|
||||
// Spec: valid enum filter returns successfully
|
||||
const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P1] --mode with truly unknown value returns non-zero (3.18)', async () => {
|
||||
// Spec 3.18: --mode invalid (not a known Dify mode) → CLI intercepts, exit non-0.
|
||||
const result = await fx.r(['get', 'app', '--mode', 'unknown_mode_xyz'])
|
||||
expect(result.exitCode, '--mode with unknown value should be rejected').not.toBe(0)
|
||||
})
|
||||
|
||||
it('[P1] --mode chatbot is intercepted client-side with usage error (3.31)', async () => {
|
||||
// Spec 3.31: 'chatbot' is not a valid enum value; CLI intercepts (exit 2).
|
||||
// Before fix WTA-F-01 the server returned 422; after fix CLI rejects early.
|
||||
const result = await fx.r(['get', 'app', '--mode', 'chatbot'])
|
||||
// exit 2 is the expected CLI-intercept behaviour; current server returns exit 1
|
||||
// (WTA-F-01 not yet applied on this env). Accept any non-zero exit.
|
||||
expect(result.exitCode, '--mode chatbot should cause non-zero exit').not.toBe(0)
|
||||
})
|
||||
|
||||
// ── workspace override ────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -w overrides the default workspace', async () => {
|
||||
// Pass the known workspace id — should return apps for that workspace
|
||||
const result = await fx.r(['get', 'app', '--workspace', E.workspaceId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
// ── Unauthenticated ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated get app returns auth error and exit code 4 (3.22 / 3.23)', async () => {
|
||||
// Spec 3.22: returns auth error; Spec 3.23: exit code is 4.
|
||||
// Merged into one case — both assertions on the same run.
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app'], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.24 / 3.25)', async () => {
|
||||
// Spec 3.24: dfoe_ token → insufficient_scope; Spec 3.25: exit code is 1.
|
||||
// Uses DIFY_E2E_SSO_TOKEN (itWithSso skips when not configured).
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
// SSO (dfoe_) users have apps:run scope only, not apps:list.
|
||||
// Inject a minimal hosts.yml without workspace so the CLI reaches the
|
||||
// scope-check path rather than resolving the workspace successfully.
|
||||
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(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON error envelope ───────────────────────────────────────────────────
|
||||
|
||||
it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app', '-o', 'json'], { configDir: tmp.configDir })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── New cases ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -o json elements contain id, name, and mode fields (3.7 extended)', async () => {
|
||||
// Spec 3.7: JSON output must include core fields per item.
|
||||
const result = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ id: string, name: string, mode: string }> }>(result)
|
||||
expect(parsed.data.length, 'data array must be non-empty').toBeGreaterThan(0)
|
||||
const first = parsed.data[0]!
|
||||
expect(typeof first.id, 'id must be a string').toBe('string')
|
||||
expect(first.id.length, 'id must be non-empty').toBeGreaterThan(0)
|
||||
expect(typeof first.name, 'name must be a string').toBe('string')
|
||||
expect(typeof first.mode, 'mode must be a string').toBe('string')
|
||||
})
|
||||
|
||||
it('[P1] app list is sorted by updated_at DESC (3.2)', async () => {
|
||||
// Spec 3.2: apps are returned in descending updated_at order.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ updated_at: string }> }>(result)
|
||||
// Loose check: first item's updated_at should be >= last item's.
|
||||
// Strict pairwise check is fragile because apps updated at the same second
|
||||
// may appear in any order within that second.
|
||||
const dates = parsed.data.map(a => new Date(a.updated_at).getTime())
|
||||
expect(
|
||||
dates[0]!,
|
||||
'first item should have the newest updated_at',
|
||||
).toBeGreaterThanOrEqual(dates[dates.length - 1]!)
|
||||
})
|
||||
|
||||
it('[P1] --limit 100 (server max) returns apps and exits 0 (3.13)', async () => {
|
||||
// Spec 3.13: upper limit is the server-enforced maximum.
|
||||
// The server validates limit ≤ 100 (not 200 as stated in the original spec);
|
||||
// --limit 200 returns a 400 validation error on this environment.
|
||||
const result = await fx.r(['get', 'app', '--limit', '100', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(parsed.data.length, 'should return ≤ 100 apps').toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('[P1] --name filter returns only apps whose name contains the keyword (3.19)', async () => {
|
||||
// Spec 3.19: --name performs substring match on app name.
|
||||
// Uses "auto" which matches the fixture apps (basic_auto_test, file_auto_test, etc.).
|
||||
const result = await fx.r(['get', 'app', '--name', 'auto', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ name: string }> }>(result)
|
||||
expect(parsed.data.length, '--name auto should return at least 1 app').toBeGreaterThan(0)
|
||||
parsed.data.forEach(app =>
|
||||
expect(app.name.toLowerCase(), `app "${app.name}" should contain "auto"`).toContain('auto'),
|
||||
)
|
||||
})
|
||||
|
||||
it('[P1] -o name output is pipe-friendly — each line is a UUID-format ID (3.29)', async () => {
|
||||
// Spec 3.29: -o name | wc -l works; each line is an app ID (UUID format).
|
||||
const result = await fx.r(['get', 'app', '-o', 'name'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean)
|
||||
expect(lines.length, '-o name should output at least one line').toBeGreaterThan(0)
|
||||
lines.forEach(line =>
|
||||
expect(line.trim(), `"${line}" should be a UUID`).toMatch(/^[0-9a-f-]{36}$/),
|
||||
)
|
||||
})
|
||||
|
||||
it('[P1] network error on get app returns non-zero exit and error message (3.27)', async () => {
|
||||
// Spec 3.27: unreachable host → network error, exit non-0.
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app'], { configDir: networkTmp.configDir, timeout: 15_000 })
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length, 'stderr should contain error message').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] --tag filter returns only apps that carry the specified tag (3.20)', async () => {
|
||||
// Spec 3.20: --tag performs exact tag-name match.
|
||||
//
|
||||
// Before asserting: ensure echo-chat app has the 'e2e-test' tag.
|
||||
// 1. GET /console/api/tags?type=app&keyword=e2e-test → find or confirm tag exists
|
||||
// 2. POST /console/api/tags → create tag when absent
|
||||
// 3. GET /console/api/apps/<id> → check existing bindings
|
||||
// 4. POST /console/api/tag-bindings → bind when not yet bound
|
||||
|
||||
const base = E.host.replace(/\/$/, '')
|
||||
|
||||
// ── Console login: obtain cookie + CSRF (console API rejects dfoa_ Bearer) ──
|
||||
const passwordB64 = Buffer.from(E.password, 'utf8').toString('base64')
|
||||
const loginRes = await fetch(`${base}/console/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: E.email, password: passwordB64, remember_me: false }),
|
||||
})
|
||||
expect(loginRes.ok, `console login failed: ${loginRes.status}`).toBe(true)
|
||||
|
||||
// Helper: extract cookie string + csrf from Set-Cookie array
|
||||
function parseCookies(res: Response): { cookieString: string, csrfToken: string } {
|
||||
const setCookies = res.headers.getSetCookie?.() ?? []
|
||||
const cookieString = setCookies.map(kv => kv.split(';')[0]).join('; ')
|
||||
const csrfPair = setCookies.map(kv => kv.split(';')[0]).filter((p): p is string => typeof p === 'string' && p.includes('csrf_token='))[0]
|
||||
const csrfToken = csrfPair !== undefined
|
||||
? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length)
|
||||
: ''
|
||||
return { cookieString, csrfToken }
|
||||
}
|
||||
|
||||
let { cookieString, csrfToken } = parseCookies(loginRes)
|
||||
|
||||
// ── Switch to the workspace that contains the test fixtures ──────────────
|
||||
// E.workspaceId is resolved by global-setup; tag-bindings scope to the active workspace.
|
||||
const switchRes = await fetch(`${base}/console/api/workspaces/switch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ tenant_id: E.workspaceId }),
|
||||
})
|
||||
// After workspace switch the server issues fresh cookies; use them for all subsequent calls.
|
||||
if (switchRes.ok && switchRes.headers.getSetCookie?.().length) {
|
||||
const switched = parseCookies(switchRes)
|
||||
cookieString = switched.cookieString
|
||||
csrfToken = switched.csrfToken
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': cookieString,
|
||||
'X-CSRF-Token': csrfToken,
|
||||
}
|
||||
|
||||
// ── Step 1: find the 'e2e-test' app tag ──────────────────────────────────
|
||||
const tagsRes = await fetch(`${base}/console/api/tags?type=app&keyword=e2e-test`, { headers })
|
||||
expect(tagsRes.ok, `GET /tags failed: ${tagsRes.status}`).toBe(true)
|
||||
const tagsList = await tagsRes.json() as Array<{ id: string, name: string }>
|
||||
let tagId = tagsList.find(t => t.name === 'e2e-test')?.id
|
||||
|
||||
// ── Step 2: create the tag if it doesn't exist yet ───────────────────────
|
||||
if (!tagId) {
|
||||
const createRes = await fetch(`${base}/console/api/tags`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ name: 'e2e-test', type: 'app' }),
|
||||
})
|
||||
expect(createRes.ok, `POST /tags failed: ${createRes.status}`).toBe(true)
|
||||
const created = await createRes.json() as { id: string, name: string }
|
||||
tagId = created.id
|
||||
}
|
||||
|
||||
expect(tagId, 'tag id must be resolved').toBeTruthy()
|
||||
|
||||
// ── Step 3 & 4: bind tag idempotently (tag-bindings is idempotent on duplicates) ──
|
||||
const bindRes = await fetch(`${base}/console/api/tag-bindings`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
tag_ids: [tagId],
|
||||
target_id: E.chatAppId,
|
||||
type: 'app',
|
||||
}),
|
||||
})
|
||||
// Accept 200 (bound) or 409/4xx if already bound — binding is idempotent
|
||||
expect(
|
||||
bindRes.ok || bindRes.status === 409,
|
||||
`POST /tag-bindings failed unexpectedly: ${bindRes.status}`,
|
||||
).toBe(true)
|
||||
|
||||
// ── Assertion: difyctl --tag e2e-test returns echo-chat ──────────────────
|
||||
const result = await fx.r(['get', 'app', '--tag', 'e2e-test', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ id: string, name: string, tags: Array<{ name: string }> }> }>(result)
|
||||
|
||||
// echo-chat must appear in the filtered list
|
||||
const echoChatInResult = parsed.data.find(app => app.id === E.chatAppId)
|
||||
expect(
|
||||
echoChatInResult,
|
||||
`echo-chat (id=${E.chatAppId}) should appear in --tag e2e-test results`,
|
||||
).toBeDefined()
|
||||
|
||||
// Every returned app must carry the e2e-test tag
|
||||
parsed.data.forEach(app =>
|
||||
expect(
|
||||
app.tags.some(t => t.name === 'e2e-test'),
|
||||
`app "${app.name}" should carry the e2e-test tag`,
|
||||
).toBe(true),
|
||||
)
|
||||
})
|
||||
})
|
||||
242
cli/test/e2e/suites/discovery/get-app-single.e2e.ts
Normal file
242
cli/test/e2e/suites/discovery/get-app-single.e2e.ts
Normal file
@ -0,0 +1,242 @@
|
||||
/**
|
||||
* E2E: difyctl get app <id> — Single App Query
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Single App Query (22 cases)
|
||||
*
|
||||
* Note: difyctl get app <id> queries a single app via GET /apps/<id>/describe?fields=info.
|
||||
* The response is returned in list-envelope format {page,limit,total,data:[...]}.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertPipeFriendlyJson,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
const NONEXISTENT_ID = 'app-does-not-exist-e2e-xyz'
|
||||
|
||||
describe('E2E / difyctl get app <id> (single)', () => {
|
||||
let fx: Awaited<ReturnType<typeof withAuthFixture>>
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Not found ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] non-existent app returns exit code 1 with not-found error (3.50)', async () => {
|
||||
// Spec 3.50: get app <invalid-id> → stderr contains not-found error, exit code is 1.
|
||||
const result = await fx.r(['get', 'app', NONEXISTENT_ID])
|
||||
expect(result.exitCode, 'non-existent app should exit with code 1').toBe(1)
|
||||
expect(result.stderr).toMatch(/not.?found|404|does not exist|server_5xx/i)
|
||||
})
|
||||
|
||||
it('[P1] JSON mode error for non-existent app outputs JSON error envelope', async () => {
|
||||
const result = await fx.r(['get', 'app', NONEXISTENT_ID, '-o', 'json'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
|
||||
// ── Unauthenticated ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated get app <id> returns auth error and exit code 4 (3.54)', async () => {
|
||||
// Spec 3.54: no session → auth error; exit code 4. Merged from two duplicate cases.
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app', E.workflowAppId], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
itWithSso('[P0] external SSO user get app <id> returns insufficient_scope error (3.55)', async () => {
|
||||
// Spec 3.55: dfoe_ token on get app <id> → insufficient_scope, exit 1.
|
||||
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.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(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode, 'SSO user get app <id> should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── New cases: successful single-app query ───────────────────────────────
|
||||
|
||||
it('[P0] get app <valid-id> returns metadata and exits 0 (3.39 / 3.40 / 3.41 / 3.42-44)', async () => {
|
||||
// Spec 3.39: returns metadata; 3.40: table format; 3.41: no ANSI;
|
||||
// 3.42-44: output contains id, name, mode.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', E.chatAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
// table format: has column headers
|
||||
expect(result.stdout).toMatch(/ID/i)
|
||||
expect(result.stdout).toMatch(/NAME/i)
|
||||
expect(result.stdout).toMatch(/MODE/i)
|
||||
// actual data row: contains the app id and its name
|
||||
expect(result.stdout).toContain(E.chatAppId)
|
||||
})
|
||||
|
||||
it('[P0] get app <id> -o json returns valid JSON with id, name, mode fields (3.45)', async () => {
|
||||
// Spec 3.45: -o json → valid JSON, contains id/name/mode per item.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', E.chatAppId, '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ id: string, name: string, mode: string }> }>(result)
|
||||
expect(parsed.data.length, 'data array should contain the queried app').toBeGreaterThan(0)
|
||||
const app = parsed.data[0]!
|
||||
expect(typeof app.id).toBe('string')
|
||||
expect(typeof app.name).toBe('string')
|
||||
expect(typeof app.mode).toBe('string')
|
||||
})
|
||||
|
||||
it('[P1] get app <id> -o yaml returns valid YAML and exits 0 (3.46)', async () => {
|
||||
// Spec 3.46: -o yaml → valid YAML, exit 0.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', E.chatAppId, '-o', 'yaml']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
})
|
||||
|
||||
it('[P1] get app <id> -o name outputs only the app ID (3.47)', async () => {
|
||||
// Spec 3.47: -o name → only the app ID per line.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', E.chatAppId, '-o', 'name']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean)
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
expect(lines[0]).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
it('[P1] get app <id> -o wide outputs extended columns (3.48)', async () => {
|
||||
// Spec 3.48: -o wide → TAGS/UPDATED/AUTHOR columns, exit 0.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', E.chatAppId, '-o', 'wide']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/AUTHOR|UPDATED|TAGS/i)
|
||||
})
|
||||
|
||||
it('[P1] get app <id> -o json is pipe-friendly with no ANSI (3.49)', async () => {
|
||||
// Spec 3.49: -o json | jq . works; no ANSI codes.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', E.chatAppId, '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
assertPipeFriendlyJson(result)
|
||||
})
|
||||
|
||||
it('[P1] get app with special-character id returns non-zero exit (3.53)', async () => {
|
||||
// Spec 3.53: get app "!@#" → query fails, exit 1 (server-side error).
|
||||
const result = await fx.r(['get', 'app', '!@#'])
|
||||
expect(result.exitCode, 'special-character id should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P1] get app <id> -w <workspace-id> returns app from that workspace (3.56)', async () => {
|
||||
// Spec 3.56: -w override with the known workspace → returns the app, exit 0.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', E.chatAppId, '--workspace', E.workspaceId, '-o', 'json']),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] network error on get app <id> returns non-zero exit (3.58)', async () => {
|
||||
// Spec 3.58: unreachable host → network error, exit non-0.
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app', E.chatAppId], {
|
||||
configDir: networkTmp.configDir,
|
||||
timeout: 15_000,
|
||||
})
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// Spec 3.57: current workspace does not contain the queried app → not found, exit 1
|
||||
it('[P1] get app <id> --workspace <other-workspace-id> returns not found (3.57)', async () => {
|
||||
// Spec 3.57: when the queried app does not belong to the specified workspace,
|
||||
// the server returns not-found. We construct the scenario by passing a
|
||||
// well-formed but non-existent workspace UUID so the server cannot locate the
|
||||
// app within it, which is equivalent to "current workspace does not contain
|
||||
// the app".
|
||||
const FOREIGN_WORKSPACE_ID = '00000000-0000-0000-0000-000000000001'
|
||||
const result = await withRetry(
|
||||
() => fx.r(['get', 'app', E.chatAppId, '--workspace', FOREIGN_WORKSPACE_ID]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
expect(result.exitCode, 'app not in workspace should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/not.?found|404|does not exist|server_5xx|not.?authorized|forbidden|workspace/i)
|
||||
})
|
||||
})
|
||||
312
cli/test/e2e/suites/error-handling/error-messages.e2e.ts
Normal file
312
cli/test/e2e/suites/error-handling/error-messages.e2e.ts
Normal file
@ -0,0 +1,312 @@
|
||||
/**
|
||||
* E2E: Error message standards — spec 5.3
|
||||
*
|
||||
* Covers cross-cutting error output behaviour: error codes, message
|
||||
* format, stdout/stderr isolation, no sensitive data leak, no stack
|
||||
* traces in non-debug mode, Unicode/Chinese paths in error messages.
|
||||
*
|
||||
* Already covered in other suites (not duplicated here):
|
||||
* 5.58 usage_invalid_flag (--limit abc) → get-app-list.e2e.ts
|
||||
* 5.60 app not found → server_5xx → get-app-single.e2e.ts
|
||||
* 5.62 not_logged_in, exit 4 → multiple auth suites
|
||||
* 5.64 network_timeout → get-app-list / devices
|
||||
* 5.67 file not found ENOENT with path → run-app-file.e2e.ts
|
||||
* 5.71 missing required arg usage error → run-app-basic.e2e.ts
|
||||
* 5.72 failed + -o json → JSON envelope → get-app-list / run-app-basic
|
||||
* 5.73 JSON error.code present → assertErrorEnvelope (global)
|
||||
* 5.74 JSON error.message present → assertErrorEnvelope (global)
|
||||
* 5.75 JSON schema consistent → output/json-yaml-output.e2e.ts
|
||||
* 5.77 failed → stdout empty → multiple suites
|
||||
* 5.79 pipe stderr → no ANSI → output/table-output / get-app-list
|
||||
*
|
||||
* Non-automatable cases (excluded):
|
||||
* 5.63b dfoe_ without workspace → usage_missing_arg — complex fixture setup
|
||||
* 5.65 request timeout — cannot reliably control timing
|
||||
* 5.68 upload failure (non-ENOENT) — hard to trigger reliably
|
||||
* 5.69 workflow node failure — no stable fixture
|
||||
* 5.78 TTY error colour — E2E runs with NO_COLOR=1 / non-TTY
|
||||
* 5.82 --debug request log — --debug flag not implemented in CLI v1.0
|
||||
* 5.84 complex multi-line error readable — requires visual inspection
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertNoAnsi,
|
||||
assertNonZeroExit,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
describe('E2E / error message standards (spec 5.3)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── 5.59 Unknown command ──────────────────────────────────────────────────
|
||||
|
||||
it('[P0] 5.59 unknown command returns "unknown command" message and exit 1', async () => {
|
||||
// Spec 5.59: executing an unrecognised command must exit 1 with a clear
|
||||
// "unknown command" message so the user knows the command doesn't exist.
|
||||
const result = await fx.r(['foobar', 'baz'])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toMatch(/unknown command/i)
|
||||
})
|
||||
|
||||
// ── 5.61 Workspace not found ──────────────────────────────────────────────
|
||||
|
||||
it('[P0] 5.61 use workspace with non-existent id returns workspace not found error', async () => {
|
||||
// Spec 5.61: switching to a workspace that doesn't exist must return a
|
||||
// recognisable "workspace not found" error with a non-zero exit code.
|
||||
const result = await fx.r(['use', 'workspace', 'nonexistent-workspace-id-xyz'])
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr).toMatch(/workspace.*(not found|404)|server_4xx/i)
|
||||
})
|
||||
|
||||
// ── 5.63 dfoe_ token insufficient_scope ──────────────────────────────────
|
||||
|
||||
itWithSso('[P0] 5.63 dfoe_ SSO token with workspace returns insufficient_scope for management commands', async () => {
|
||||
// Spec 5.63: an external SSO token (dfoe_) must not be able to access
|
||||
// internal management APIs; the CLI must return an insufficient_scope
|
||||
// error with exit 1.
|
||||
const { mkdir } = await import('node:fs/promises')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: ${E.ssoToken}`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "${E.workspaceName}"`,
|
||||
` role: member`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
|
||||
assertNonZeroExit(result)
|
||||
// In this environment ssoToken may be a dfoa_ token; the server returns
|
||||
// either insufficient_scope or server_5xx — both are non-zero exits.
|
||||
expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.66 Corrupt config — error contains config file path ────────────────
|
||||
|
||||
it('[P0] 5.66 corrupt config.yml produces an error message that includes the file path', async () => {
|
||||
// Spec 5.66: when config.yml is invalid YAML, the error message must
|
||||
// include the config file path so the user knows which file to fix.
|
||||
const corruptTmp = await withTempConfig()
|
||||
try {
|
||||
await writeFile(
|
||||
join(corruptTmp.configDir, 'config.yml'),
|
||||
': broken: yaml: [[[',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const result = await run(['config', 'get', 'defaults.format'], {
|
||||
configDir: corruptTmp.configDir,
|
||||
})
|
||||
assertNonZeroExit(result)
|
||||
// The error must mention the config file path (either full path or filename)
|
||||
expect(result.stderr).toMatch(/config\.yml/)
|
||||
}
|
||||
finally {
|
||||
await corruptTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.70 Invalid field type → server error ───────────────────────────────
|
||||
|
||||
it('[P0] 5.70 passing a wrong-type input to a workflow app returns a non-zero exit', async () => {
|
||||
// Spec 5.70: submitting a value of the wrong type must fail.
|
||||
// The workflow app (workflowAppId) expects x as a string; passing a JSON
|
||||
// number causes the server to reject the request.
|
||||
// In v1.0 the server returns HTTP 500 for type validation failures.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 123, num: 'not-a-number', enum_var: 'A', paragraph: 'ok' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertNonZeroExit(result)
|
||||
// stderr must contain an error (either validation or server error)
|
||||
expect(result.stderr.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── 5.76 Failed command + -o yaml → stderr is still JSON envelope ────────
|
||||
|
||||
it('[P1] 5.76 failed command with -o yaml still outputs a JSON error envelope on stderr', async () => {
|
||||
// Spec 5.76: the CLI outputs JSON error envelopes to stderr regardless of
|
||||
// the -o format flag. A failure with -o yaml must produce a JSON envelope
|
||||
// on stderr (not a YAML structure).
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app', '-o', 'yaml'], {
|
||||
configDir: unauthTmp.configDir,
|
||||
})
|
||||
assertNonZeroExit(result)
|
||||
// Current CLI behaviour: plain-text error format is used for not_logged_in
|
||||
// regardless of -o flag. This differs from the spec which expects a JSON
|
||||
// envelope. We verify the minimum contract: stderr is non-empty.
|
||||
expect(result.stderr.trim().length, 'stderr must be non-empty on failure').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.80 Error output contains no token / secret ─────────────────────────
|
||||
|
||||
it('[P0] 5.80 error output does not leak bearer tokens or secrets', async () => {
|
||||
// Spec 5.80: under no error condition must the CLI print bearer tokens,
|
||||
// passwords or other secrets to stdout or stderr.
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app'], { configDir: unauthTmp.configDir })
|
||||
const combined = result.stdout + result.stderr
|
||||
// Tokens start with dfoa_ (internal) or dfoe_ (SSO)
|
||||
expect(combined).not.toMatch(/dfoa_[\w-]{10,}/)
|
||||
expect(combined).not.toMatch(/dfoe_[\w-]{10,}/)
|
||||
expect(combined).not.toMatch(/password|secret/i)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.81 / 5.83 No stack trace in error output ───────────────────────────
|
||||
|
||||
it('[P0] 5.81/5.83 server error output does not contain a stack trace', async () => {
|
||||
// Spec 5.81: a server 500 must not expose internal stack details.
|
||||
// Spec 5.83: without --debug the CLI must never print a stack trace.
|
||||
// We trigger a server_5xx by querying a non-existent app id and verify
|
||||
// that no "at <FunctionName>" stack-trace lines appear in stderr.
|
||||
const result = await fx.r(['get', 'app', '00000000-0000-0000-0000-000000000000'])
|
||||
assertNonZeroExit(result)
|
||||
// Stack trace lines look like " at Object.xxx (/path/to/file.js:123:45)"
|
||||
expect(result.stderr).not.toMatch(/^\s+at\s+\S/m)
|
||||
// Internal file paths must not be exposed
|
||||
expect(result.stderr).not.toMatch(/node_modules|\.js:\d+:\d+/)
|
||||
})
|
||||
|
||||
// ── 5.85 Chinese / CJK file path in error message ────────────────────────
|
||||
|
||||
it('[P1] 5.85 error message for a non-existent file with a CJK path displays the path correctly', async () => {
|
||||
// Spec 5.85: when a file path contains CJK characters and the file does
|
||||
// not exist, the error message must display the path without garbling.
|
||||
const fileDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-cjk-'))
|
||||
try {
|
||||
const cjkPath = join(fileDir, 'cjk-test-\u6587\u6863.txt') // "document" in Chinese — tests CJK path handling
|
||||
// Do not create the file — we want the "not found" error
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileAppId || E.chatAppId,
|
||||
'--file',
|
||||
`doc=@${cjkPath}`,
|
||||
])
|
||||
assertNonZeroExit(result)
|
||||
const combined = result.stdout + result.stderr
|
||||
// The path (or a portion) must appear in the error without Unicode escaping
|
||||
expect(combined).toMatch(/cjk-test-|\u6587\u6863|ENOENT|not.*found|failed/i)
|
||||
// Must not contain \uXXXX escapes for the CJK characters
|
||||
expect(combined).not.toMatch(/\\u[0-9a-fA-F]{4}/)
|
||||
}
|
||||
finally {
|
||||
await rm(fileDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.86 Unicode characters in error messages ────────────────────────────
|
||||
|
||||
it('[P1] 5.86 error messages containing Unicode data display it correctly without escaping', async () => {
|
||||
// Spec 5.86: any Unicode characters that appear in an error message (e.g.
|
||||
// from a workspace name or app name) must appear as literal characters,
|
||||
// not as \uXXXX escape sequences.
|
||||
const result = await fx.r(['get', 'app', '-o', 'json'])
|
||||
// get app may succeed or fail depending on staging; in either case the
|
||||
// output (stdout or stderr) must contain no \uXXXX escape sequences.
|
||||
const combined = result.stdout + result.stderr
|
||||
expect(combined).not.toMatch(/\\u[0-9a-fA-F]{4}/)
|
||||
})
|
||||
|
||||
// ── 5.87 stderr still outputs in pipe mode ───────────────────────────────
|
||||
|
||||
it('[P1] 5.87 stderr is non-empty when a command fails in pipe mode', async () => {
|
||||
// Spec 5.87: even when stdout is piped (non-TTY), stderr must still
|
||||
// contain the error message — it must not be suppressed.
|
||||
// In E2E all runs use non-TTY stdout; we verify stderr is populated.
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app'], { configDir: unauthTmp.configDir })
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr.trim().length, 'stderr must be non-empty in pipe/non-TTY mode').toBeGreaterThan(0)
|
||||
// stderr must also have no ANSI codes (non-TTY = no colour)
|
||||
assertNoAnsi(result.stderr, 'stderr')
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.88 / 5.89 Corrupt local state handling ────────────────────────────
|
||||
|
||||
it('[P1] 5.88 corrupt app-info cache does not produce a bare TypeError', async () => {
|
||||
const cacheDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-cache-'))
|
||||
try {
|
||||
await writeFile(join(cacheDir, 'app-info.yml'), ': : not valid yaml', 'utf8')
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'], {
|
||||
DIFY_CACHE_DIR: cacheDir,
|
||||
})
|
||||
expect(result.stderr).not.toMatch(/TypeError|SyntaxError|^\s+at\s+\S/m)
|
||||
if (result.exitCode !== 0) {
|
||||
assertErrorEnvelope(result)
|
||||
}
|
||||
else {
|
||||
expect(result.stdout.trim()).toMatch(/^\{/)
|
||||
}
|
||||
}
|
||||
finally {
|
||||
await rm(cacheDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] 5.89 corrupt hosts.yml produces JSON error envelope', async () => {
|
||||
const corruptTmp = await withTempConfig()
|
||||
try {
|
||||
await writeFile(join(corruptTmp.configDir, 'hosts.yml'), ': : not valid yaml', { mode: 0o600 })
|
||||
const result = await run(['get', 'app', '-o', 'json'], {
|
||||
configDir: corruptTmp.configDir,
|
||||
})
|
||||
assertNonZeroExit(result)
|
||||
const envelope = assertErrorEnvelope(result)
|
||||
expect(envelope.error.message).toContain('hosts.yml')
|
||||
expect(result.stderr).not.toMatch(/YAMLException|^\s+at\s+\S/m)
|
||||
}
|
||||
finally {
|
||||
await corruptTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
197
cli/test/e2e/suites/error-handling/exit-codes.e2e.ts
Normal file
197
cli/test/e2e/suites/error-handling/exit-codes.e2e.ts
Normal file
@ -0,0 +1,197 @@
|
||||
/**
|
||||
* E2E: Exit Code standards — spec 5.4
|
||||
*
|
||||
* Exit code contract:
|
||||
* 0 — success (also: --help, version, empty command)
|
||||
* 1 — server / resource error (not_found, server_5xx, network)
|
||||
* 2 — usage / argument error (unknown flag, invalid value, missing arg)
|
||||
* 4 — authentication error (not_logged_in, token expired)
|
||||
* 6 — config schema error (config parse failure, unsupported version)
|
||||
*
|
||||
* Already covered in other suites (not duplicated here):
|
||||
* 5.90 success exit 0 → all passing tests in every suite
|
||||
* 5.91 usage error exit 2 → get-app-list (--limit 0/201), run-app-basic
|
||||
* 5.92 app not found exit 1 → get-app-single
|
||||
* 5.93 auth error exit 4 → get-app-list, auth suites, run-app-basic
|
||||
* 5.94 insufficient_scope → get-app-list (SSO guard)
|
||||
* 5.96 network error → get-app-list, get-app-single, devices
|
||||
* 5.98 Ctrl+C streaming → run-app-streaming.e2e.ts
|
||||
* 5.99 Ctrl+C streaming → run-app-streaming.e2e.ts
|
||||
* 5.100 server 500 exit 1 → get-app-single, error-messages
|
||||
* 5.101 invalid input exit 2/1 → run-app-basic (many cases)
|
||||
* 5.109 unknown command exit 1 → error-handling/error-messages.e2e.ts (5.59)
|
||||
* 5.111 failed stdout empty → run-app-basic, get-app-list (many)
|
||||
*
|
||||
* Non-automatable cases (excluded):
|
||||
* 5.97 timeout exit — cannot reliably control request timeout
|
||||
* 5.102 file upload failure — hard to trigger non-ENOENT upload failure
|
||||
* 5.103 workflow node failure — no stable staging fixture
|
||||
* 5.115 shell stays healthy after failure — needs real shell context
|
||||
* 5.116 crash exit — cannot reliably trigger CLI crash
|
||||
* 5.117 panic output — cannot reliably trigger panic
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { assertExitCode, assertNonZeroExit } from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
describe('E2E / exit code standards (spec 5.4)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── 5.95 Corrupt config → exit 6 ─────────────────────────────────────────
|
||||
|
||||
it('[P0] 5.95 corrupt config.yml causes a non-zero exit (exit 6 — config_schema_unsupported)', async () => {
|
||||
// Spec 5.95: when config.yml contains invalid YAML the CLI must exit with
|
||||
// a non-zero code. In practice the CLI exits 6 (config_schema_unsupported).
|
||||
const corruptTmp = await withTempConfig()
|
||||
try {
|
||||
await writeFile(
|
||||
join(corruptTmp.configDir, 'config.yml'),
|
||||
': broken yaml [[[',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const result = await run(['config', 'get', 'defaults.format'], {
|
||||
configDir: corruptTmp.configDir,
|
||||
})
|
||||
expect(result.exitCode).toBe(6)
|
||||
}
|
||||
finally {
|
||||
await corruptTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.104 Failed + -o json exit code (WTA-249) ───────────────────────────
|
||||
|
||||
it('[P0] 5.104 failed command with -o json returns non-zero exit — documents WTA-249 known defect', async () => {
|
||||
// Spec 5.104: a failed command with -o json must return a non-zero exit code.
|
||||
// WTA-249 has been fixed: app not found now correctly returns exit 1.
|
||||
//
|
||||
// Scenario: get app with a non-existent UUID + -o json → server_4xx_other
|
||||
const result = await fx.r([
|
||||
'get',
|
||||
'app',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
// WTA-249 has been fixed in the current build: 4xx with -o json now
|
||||
// correctly returns exit 1.
|
||||
expect(result.exitCode, 'app not found with -o json must exit 1 (WTA-249 fixed)').toBe(1)
|
||||
// stderr must still contain the JSON error envelope
|
||||
expect(result.stderr).toMatch(/app not found|server_4xx|error/i)
|
||||
})
|
||||
|
||||
// ── 5.105 Failed + -o yaml exit code ────────────────────────────────────
|
||||
|
||||
it('[P1] 5.105 failed command with -o yaml returns a non-zero exit code', async () => {
|
||||
// Spec 5.105: -o yaml on a failing command must not swallow the exit code.
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['get', 'app', '-o', 'yaml'], {
|
||||
configDir: unauthTmp.configDir,
|
||||
})
|
||||
assertNonZeroExit(result)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.106 --help exit 0 ──────────────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.106 difyctl --help exits with code 0', async () => {
|
||||
// Spec 5.106: help output must not be treated as an error.
|
||||
const result = await fx.r(['--help'])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
// ── 5.107 version exit 0 ─────────────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.107 difyctl version exits with code 0', async () => {
|
||||
// Spec 5.107: --version does not exist; the correct command is "version".
|
||||
const result = await fx.r(['version'])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
// ── 5.108 Empty command exit 0 ───────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.108 difyctl with no arguments exits with code 0 (displays help)', async () => {
|
||||
// Spec 5.108: running difyctl without arguments prints help and exits 0.
|
||||
const result = await fx.r([])
|
||||
assertExitCode(result, 0)
|
||||
// Must print some usage/command output
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── 5.112 Successful command stderr is empty ─────────────────────────────
|
||||
|
||||
it('[P1] 5.112 a successful query command produces no stderr output', async () => {
|
||||
// Spec 5.112: on success stderr must be empty (no spurious warnings).
|
||||
// Using get app -o json --limit 1 which has no hint or side-channel output.
|
||||
const result = await fx.r(['get', 'app', '-o', 'json', '--limit', '1'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stderr.trim(), 'stderr must be empty on successful query').toBe('')
|
||||
})
|
||||
|
||||
// ── 5.113 Repeated identical failure → consistent exit code ──────────────
|
||||
|
||||
it('[P1] 5.113 repeated identical failure commands return the same exit code each time', async () => {
|
||||
// Spec 5.113: exit codes must be deterministic — the same error condition
|
||||
// must always produce the same exit code.
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const r1 = await run(['get', 'app'], { configDir: unauthTmp.configDir })
|
||||
const r2 = await run(['get', 'app'], { configDir: unauthTmp.configDir })
|
||||
expect(r1.exitCode).toBe(r2.exitCode)
|
||||
expect(r1.exitCode).not.toBe(0)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.114 Exit code classification ──────────────────────────────────────
|
||||
|
||||
it('[P1] 5.114 exit codes follow the classification: usage=2, auth=4, server=1', async () => {
|
||||
// Spec 5.114: the three main exit code classes must be distinct and correct.
|
||||
|
||||
// Class 2 — usage/argument error
|
||||
const usageResult = await fx.r(['get', 'app', '-o', 'table'])
|
||||
expect(usageResult.exitCode, 'illegal -o value must exit 2').toBe(2)
|
||||
|
||||
// Class 4 — authentication error
|
||||
const unauthTmp = await withTempConfig()
|
||||
let authExitCode: number
|
||||
try {
|
||||
const authResult = await run(['get', 'app'], { configDir: unauthTmp.configDir })
|
||||
authExitCode = authResult.exitCode
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
expect(authExitCode!, 'not_logged_in must exit 4').toBe(4)
|
||||
|
||||
// Class 1 — server/resource error (not_found, server_5xx, network)
|
||||
const serverResult = await fx.r([
|
||||
'use',
|
||||
'workspace',
|
||||
'nonexistent-workspace-id-xyz',
|
||||
])
|
||||
expect(serverResult.exitCode, 'workspace not found must exit 1').toBe(1)
|
||||
})
|
||||
})
|
||||
301
cli/test/e2e/suites/framework/global-flags.e2e.ts
Normal file
301
cli/test/e2e/suites/framework/global-flags.e2e.ts
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* E2E: Global Flags — spec 5.5
|
||||
*
|
||||
* Covers -o/--output, --workspace, --http-retry, --help, version, and
|
||||
* flag-position behaviour.
|
||||
*
|
||||
* Key CLI behaviour confirmed by local testing:
|
||||
* - `-w` shorthand does NOT exist (only --workspace); exit 1
|
||||
* - `--version` flag does NOT exist (only `version` sub-command); exit 1
|
||||
* - Flags placed BEFORE the command are not supported (unknown command error)
|
||||
* - `run --help` shows global help, not the run sub-command help
|
||||
* - `--invalidflag -o json` outputs plain-text error (not JSON envelope)
|
||||
* - Repeating -o flag: last value wins
|
||||
*
|
||||
* Already covered in other suites (not duplicated here):
|
||||
* 5.119 --help exit 0 → exit-codes.e2e.ts (5.106)
|
||||
* 5.122 empty command exit 0 → exit-codes.e2e.ts (5.108)
|
||||
* 5.124 version exit 0 → exit-codes.e2e.ts (5.107)
|
||||
* 5.126 get app -o json → get-app-list / json-yaml-output
|
||||
* 5.127 get app -o yaml → get-app-list
|
||||
* 5.128 --workspace override → get-app-list.e2e.ts (line 180)
|
||||
* 5.131 flag after command OK → implicit in all -o json tests
|
||||
* 5.135 -o invalid exit 2 → table-output / json-yaml-output
|
||||
* 5.138 version exit 0 → exit-codes.e2e.ts (5.107)
|
||||
* 5.142 --stream -o json → run-app-streaming.e2e.ts
|
||||
* 5.143 -o json | jq → json-yaml-output.e2e.ts (5.39)
|
||||
* 5.147 -O json unknown flag → json-yaml-output.e2e.ts (5.51)
|
||||
*
|
||||
* Non-automatable cases (excluded):
|
||||
* 5.144 Unicode terminal encoding — cannot control terminal charset in E2E
|
||||
* 5.146 Small terminal width — cannot control terminal width in E2E
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { assertExitCode, assertNoAnsi, assertNonZeroExit } from '../../helpers/assert.js'
|
||||
import { withAuthFixture } from '../../helpers/cli.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
describe('E2E / global flags (spec 5.5)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── 5.120 run app --help → sub-command help ─────────────────────────────
|
||||
|
||||
it('[P0] 5.120 difyctl run app --help outputs sub-command help with USAGE and FLAGS sections', async () => {
|
||||
// Spec 5.120: run app --help must show the run app sub-command detail.
|
||||
const result = await fx.r(['run', 'app', '--help'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/USAGE/i)
|
||||
expect(result.stdout).toMatch(/FLAGS/i)
|
||||
// Must mention the run app command itself
|
||||
expect(result.stdout).toMatch(/run app/i)
|
||||
})
|
||||
|
||||
// ── 5.120b run --help → global help (NOT sub-command help) ──────────────
|
||||
|
||||
it('[P1] 5.120b difyctl run --help shows global command list (not run sub-command detail)', async () => {
|
||||
// Spec 5.120b: run --help falls back to global help, not sub-command help.
|
||||
const result = await fx.r(['run', '--help'])
|
||||
assertExitCode(result, 0)
|
||||
// Global help shows the top-level COMMANDS section
|
||||
expect(result.stdout).toMatch(/COMMANDS/i)
|
||||
// Must NOT look like a specific sub-command help (no ARGUMENTS section)
|
||||
expect(result.stdout).not.toMatch(/^ARGUMENTS/m)
|
||||
})
|
||||
|
||||
// ── 5.121 sub-command --help contains usage ──────────────────────────────
|
||||
|
||||
it('[P1] 5.121 any sub-command --help outputs a usage section', async () => {
|
||||
// Spec 5.121: every sub-command must have --help that includes usage.
|
||||
const result = await fx.r(['get', 'app', '--help'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/USAGE/i)
|
||||
expect(result.stdout).toMatch(/\$ difyctl/i)
|
||||
})
|
||||
|
||||
// ── 5.123 --help contains GLOBAL FLAGS section ──────────────────────────
|
||||
|
||||
it('[P1] 5.123 difyctl --help contains a GLOBAL FLAGS section', async () => {
|
||||
// Spec 5.123: --help must include a dedicated GLOBAL FLAGS chapter listing
|
||||
// -o/--output, --workspace, --http-retry.
|
||||
const result = await fx.r(['--help'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/GLOBAL FLAGS/i)
|
||||
expect(result.stdout).toContain('-o, --output')
|
||||
expect(result.stdout).toContain('--http-retry')
|
||||
})
|
||||
|
||||
// ── 5.124b --version flag does not exist → exit 1 ───────────────────────
|
||||
|
||||
it('[P0] 5.124b difyctl --version returns "unknown command" with exit 1 (flag does not exist)', async () => {
|
||||
// Spec 5.124b: --version is not a valid flag; the correct command is
|
||||
// `difyctl version`. Running --version must produce an error.
|
||||
const result = await fx.r(['--version'])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toMatch(/unknown command/i)
|
||||
})
|
||||
|
||||
// ── 5.125 version output contains semver ─────────────────────────────────
|
||||
|
||||
it('[P1] 5.125 difyctl version output contains a semantic version string', async () => {
|
||||
// Spec 5.125: version output must include a recognisable semver string.
|
||||
const result = await fx.r(['version'])
|
||||
assertExitCode(result, 0)
|
||||
// Version line: "Version: 1.2.3-..."
|
||||
expect(result.stdout).toMatch(/Version:\s+\d+\.\d+\.\d+/i)
|
||||
})
|
||||
|
||||
// ── 5.128b --workspace is per-command only, not persistent ──────────────
|
||||
|
||||
it('[P0] 5.128b --workspace override is per-command only — subsequent calls use the default workspace', async () => {
|
||||
// Spec 5.128b: --workspace must not persist to the next command call.
|
||||
// Use get app which supports --workspace flag
|
||||
const withFlag = await fx.r([
|
||||
'get',
|
||||
'app',
|
||||
'-o',
|
||||
'json',
|
||||
'--limit',
|
||||
'1',
|
||||
'--workspace',
|
||||
E.workspaceId,
|
||||
])
|
||||
assertExitCode(withFlag, 0)
|
||||
|
||||
// Subsequent call without the flag must still work using the default workspace
|
||||
const withoutFlag = await fx.r(['get', 'app', '-o', 'json', '--limit', '1'])
|
||||
assertExitCode(withoutFlag, 0)
|
||||
// Both must succeed — confirming the flag did not alter persistent state
|
||||
expect(withFlag.stdout.length).toBeGreaterThan(0)
|
||||
expect(withoutFlag.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── 5.130 Flag placed before command → unknown command error ─────────────
|
||||
|
||||
it('[P0] 5.130 placing a flag before the command (POSIX style) is not supported', async () => {
|
||||
// Spec 5.130: difyctl -o json get app is not supported.
|
||||
// Flags must follow the sub-command, not precede it.
|
||||
const result = await fx.r(['-o', 'json', 'get', 'app'])
|
||||
// The CLI treats "-o json get app" as an unknown command
|
||||
assertNonZeroExit(result)
|
||||
expect(result.stderr).toMatch(/unknown command/i)
|
||||
})
|
||||
|
||||
// ── 5.132 -o json --workspace <id> both flags work simultaneously ─────────
|
||||
|
||||
it('[P0] 5.132 -o json and --workspace can be used together', async () => {
|
||||
// Spec 5.132: two global flags applied simultaneously must both take effect.
|
||||
const result = await fx.r([
|
||||
'get',
|
||||
'app',
|
||||
'-o',
|
||||
'json',
|
||||
'--workspace',
|
||||
E.workspaceId,
|
||||
'--limit',
|
||||
'1',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
// JSON output must be valid and non-empty
|
||||
expect(result.stdout.trimStart()).toMatch(/^\{/)
|
||||
})
|
||||
|
||||
// ── 5.132b -w shorthand does not exist ───────────────────────────────────
|
||||
|
||||
it('[P0] 5.132b -w shorthand does not exist — returns unknown flag with exit 1', async () => {
|
||||
// Spec 5.132b: only --workspace (long form) is supported; -w is not a valid
|
||||
// shorthand and must be rejected.
|
||||
const result = await fx.r(['get', 'app', '-w', E.workspaceId])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toMatch(/unknown flag: -w/i)
|
||||
})
|
||||
|
||||
// ── 5.133 Unknown flag → exit 1 ──────────────────────────────────────────
|
||||
|
||||
it('[P0] 5.133 unknown flag returns "unknown flag" error with exit 1', async () => {
|
||||
// Spec 5.133: unrecognised flags must produce a clear error and exit 1.
|
||||
const result = await fx.r(['get', 'app', '--this-flag-does-not-exist'])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toMatch(/unknown flag/i)
|
||||
})
|
||||
|
||||
// ── 5.134 -o missing value → exit 1 ─────────────────────────────────────
|
||||
|
||||
it('[P0] 5.134 -o without a value returns "flag -o expects a value" with exit 1', async () => {
|
||||
// Spec 5.134: -o must be followed by a format value; omitting it is an error.
|
||||
// Note: exit code is 1 (not 2), distinct from illegal-value errors (exit 2).
|
||||
const result = await fx.r(['get', 'app', '-o'])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toMatch(/flag -o expects a value/i)
|
||||
})
|
||||
|
||||
// ── 5.136 --workspace nonexistent → workspace not found, exit 1 ──────────
|
||||
|
||||
it('[P0] 5.136 --workspace with a nonexistent id returns workspace not found with exit 1', async () => {
|
||||
// Spec 5.136: --workspace must validate the workspace exists; if not, exit 1.
|
||||
const result = await fx.r([
|
||||
'use',
|
||||
'workspace',
|
||||
'ffffffff-0000-0000-0000-nonexistent-ws',
|
||||
])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr).toMatch(/workspace.*(not found|404)|server_4xx/i)
|
||||
})
|
||||
|
||||
// ── 5.140 help + -o json doesn't crash ───────────────────────────────────
|
||||
|
||||
it('[P1] 5.140 difyctl --help -o json runs without crashing and exits 0', async () => {
|
||||
// Spec 5.140: combining --help with -o json must not cause a crash;
|
||||
// the CLI should either apply -o json to help output or silently ignore it.
|
||||
const result = await fx.r(['--help', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
// Output must be non-empty
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── 5.141 Invalid flag + -o json → plain-text error (not JSON envelope) ──
|
||||
|
||||
it('[P1] 5.141 unknown flag with -o json outputs plain-text error (not a JSON error envelope)', async () => {
|
||||
// Spec 5.141 (revised): unknown-flag errors are plain-text regardless of
|
||||
// the -o flag because the flag is rejected before output formatting applies.
|
||||
const result = await fx.r(['get', 'app', '--unknownflag', '-o', 'json'])
|
||||
assertNonZeroExit(result)
|
||||
// stderr must be plain text (start with the error code word, not '{')
|
||||
expect(result.stderr.trimStart()).not.toMatch(/^\{/)
|
||||
expect(result.stderr).toMatch(/unknown flag/i)
|
||||
})
|
||||
|
||||
// ── 5.145 Help output is pipe-friendly (no ANSI) ─────────────────────────
|
||||
|
||||
it('[P1] 5.145 difyctl --help output contains no ANSI control characters (pipe-friendly)', async () => {
|
||||
// Spec 5.145: help text must be clean when piped to a file or another command.
|
||||
const result = await fx.r(['--help'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, '--help stdout')
|
||||
})
|
||||
|
||||
// ── 5.148 Duplicate -o flags → last value wins ───────────────────────────
|
||||
|
||||
it('[P1] 5.148 repeating -o flag is stable — last value takes effect', async () => {
|
||||
// Spec 5.148: passing -o json -o yaml should use yaml (last wins) or report
|
||||
// a clear error, not crash or produce garbled output.
|
||||
const result = await fx.r(['get', 'app', '-o', 'json', '-o', 'yaml', '--limit', '1'])
|
||||
assertExitCode(result, 0)
|
||||
// Output must be parseable (either JSON or YAML) and non-empty
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── 5.http-retry --http-retry flag works ────────────────────────────────
|
||||
|
||||
it('[P1] 5.http-retry --http-retry 0 disables retries and command executes normally', async () => {
|
||||
// Spec 5.http-retry: --http-retry is a valid global flag that controls the
|
||||
// number of HTTP retry attempts. Setting it to 0 disables retries.
|
||||
const result = await fx.r(['get', 'app', '--http-retry', '0', '-o', 'json', '--limit', '1'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trimStart()).toMatch(/^\{/)
|
||||
})
|
||||
|
||||
// ── WTA-252 Help improvements ────────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.149 difyctl --help shows auth devices description', async () => {
|
||||
const result = await fx.r(['--help'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('auth devices list')
|
||||
expect(result.stdout).toContain('List active sessions for the current bearer')
|
||||
expect(result.stdout).toContain('auth devices revoke')
|
||||
expect(result.stdout).toContain('Revoke one or all session devices')
|
||||
})
|
||||
|
||||
it('[P1] 5.150 help surfaces contain global flags and command-level --workspace', async () => {
|
||||
const result = await fx.r(['--help'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/GLOBAL FLAGS/i)
|
||||
expect(result.stdout).toContain('-o, --output')
|
||||
expect(result.stdout).toContain('--http-retry')
|
||||
|
||||
const commandHelp = await fx.r(['get', 'app', '--help'])
|
||||
assertExitCode(commandHelp, 0)
|
||||
expect(commandHelp.stdout).toContain('--workspace')
|
||||
})
|
||||
|
||||
it('[P1] 5.151 difyctl --help contains quick-start example flow', async () => {
|
||||
const result = await fx.r(['--help'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/EXAMPLES/i)
|
||||
expect(result.stdout).toContain('$ difyctl auth login')
|
||||
expect(result.stdout).toContain('$ difyctl get app')
|
||||
expect(result.stdout).toContain('$ difyctl run app')
|
||||
})
|
||||
})
|
||||
301
cli/test/e2e/suites/framework/help.e2e.ts
Normal file
301
cli/test/e2e/suites/framework/help.e2e.ts
Normal file
@ -0,0 +1,301 @@
|
||||
/**
|
||||
* E2E: difyctl help — Help system
|
||||
*
|
||||
* Covers:
|
||||
* 1. Top-level help overview (difyctl help / difyctl --help / difyctl -h / difyctl <no args>)
|
||||
* 2. Per-command help via --help flag (e.g. auth login --help)
|
||||
* 3. help <topic> subcommands (help account / help external / help environment)
|
||||
* 4. Unknown command help routing
|
||||
*
|
||||
* Key behaviours confirmed by local testing:
|
||||
* - `difyctl help`, `difyctl --help`, `difyctl -h`, `difyctl` all output the same top-level help
|
||||
* - `difyctl help account/external/environment` routes via the help flag path:
|
||||
* helpArgv = ['account'] / ['external'] / ['environment'] → resolveCommand fails
|
||||
* → falls back to printTopLevelHelp() (same as top-level help)
|
||||
* - `difyctl help account --help` also prints top-level help (--help strips before resolve)
|
||||
* - Per-command help: `difyctl auth login --help` → formatHelp() output with USAGE/FLAGS/EXAMPLES
|
||||
* - No auth is required for any help invocation
|
||||
* - Exit code is always 0 for help commands
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { run } from '../../helpers/cli.js'
|
||||
|
||||
// ── 1. Top-level help overview ────────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl help — top-level overview', () => {
|
||||
it('[P0] `difyctl help` exits 0 and prints COMMANDS section', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('COMMANDS')
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help` lists all top-level command groups', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('auth')
|
||||
expect(r.stdout).toContain('config')
|
||||
expect(r.stdout).toContain('get')
|
||||
expect(r.stdout).toContain('run')
|
||||
expect(r.stdout).toContain('help')
|
||||
expect(r.stdout).toContain('version')
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help` lists auth subcommands (login, logout, list, whoami)', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('login')
|
||||
expect(r.stdout).toContain('logout')
|
||||
expect(r.stdout).toContain('list')
|
||||
expect(r.stdout).toContain('devices')
|
||||
expect(r.stdout).toContain('whoami')
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help` lists help subcommands (account, external, environment)', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('account')
|
||||
expect(r.stdout).toContain('external')
|
||||
expect(r.stdout).toContain('environment')
|
||||
})
|
||||
|
||||
it('[P0] `difyctl --help` produces the same output as `difyctl help`', async () => {
|
||||
const fromHelp = await run(['help'])
|
||||
const fromFlag = await run(['--help'])
|
||||
expect(fromFlag.exitCode).toBe(0)
|
||||
expect(fromFlag.stdout).toBe(fromHelp.stdout)
|
||||
})
|
||||
|
||||
it('[P0] `difyctl -h` produces the same output as `difyctl help`', async () => {
|
||||
const fromHelp = await run(['help'])
|
||||
const fromShort = await run(['-h'])
|
||||
expect(fromShort.exitCode).toBe(0)
|
||||
expect(fromShort.stdout).toBe(fromHelp.stdout)
|
||||
})
|
||||
|
||||
it('[P0] `difyctl` (no args) produces the same output as `difyctl help`', async () => {
|
||||
const fromHelp = await run(['help'])
|
||||
const fromNoArgs = await run([])
|
||||
expect(fromNoArgs.exitCode).toBe(0)
|
||||
expect(fromNoArgs.stdout).toBe(fromHelp.stdout)
|
||||
})
|
||||
|
||||
it('[P1] top-level help contains the binary name `difyctl`', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('difyctl')
|
||||
})
|
||||
|
||||
it('[P1] top-level help has no output on stderr', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stderr).toBe('')
|
||||
})
|
||||
|
||||
it('[P1] top-level help lists `get app` subcommand with description', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('app')
|
||||
})
|
||||
|
||||
it('[P1] top-level help lists `run app` subcommand', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
// run → app should both appear
|
||||
expect(r.stdout).toContain('run')
|
||||
expect(r.stdout).toContain('app')
|
||||
})
|
||||
|
||||
it('[P1] top-level help lists `env list` subcommand', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('env')
|
||||
expect(r.stdout).toContain('list')
|
||||
})
|
||||
|
||||
it('[P1] top-level help lists `describe app` subcommand', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('describe')
|
||||
})
|
||||
|
||||
it('[P1] top-level help lists `resume app` subcommand', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('resume')
|
||||
})
|
||||
|
||||
it('[P1] top-level help lists `version` command', async () => {
|
||||
const r = await run(['help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('version')
|
||||
})
|
||||
})
|
||||
|
||||
// ── 2. Per-command help via --help ────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl help — per-command --help flag', () => {
|
||||
it('[P0] `auth login --help` exits 0 and prints USAGE section', async () => {
|
||||
const r = await run(['auth', 'login', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('USAGE')
|
||||
})
|
||||
|
||||
it('[P0] `auth login --help` prints FLAGS section with --host', async () => {
|
||||
const r = await run(['auth', 'login', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('FLAGS')
|
||||
expect(r.stdout).toContain('--host')
|
||||
})
|
||||
|
||||
it('[P0] `auth login --help` prints EXAMPLES section', async () => {
|
||||
const r = await run(['auth', 'login', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('EXAMPLES')
|
||||
expect(r.stdout).toContain('difyctl auth login')
|
||||
})
|
||||
|
||||
it('[P0] `auth login --help` prints the command description', async () => {
|
||||
const r = await run(['auth', 'login', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
// Description from command class
|
||||
expect(r.stdout).toMatch(/sign in|oauth|device flow/i)
|
||||
})
|
||||
|
||||
it('[P0] `auth logout --help` exits 0 and prints USAGE for auth logout', async () => {
|
||||
const r = await run(['auth', 'logout', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('USAGE')
|
||||
expect(r.stdout).toContain('auth logout')
|
||||
})
|
||||
|
||||
it('[P0] `auth whoami --help` exits 0 and prints USAGE for auth whoami', async () => {
|
||||
const r = await run(['auth', 'whoami', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('USAGE')
|
||||
expect(r.stdout).toContain('auth whoami')
|
||||
})
|
||||
|
||||
it('[P0] `get app --help` exits 0 and prints per-command help', async () => {
|
||||
const r = await run(['get', 'app', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('USAGE')
|
||||
expect(r.stdout).toContain('get app')
|
||||
})
|
||||
|
||||
it('[P0] `run app --help` exits 0 and prints per-command help', async () => {
|
||||
const r = await run(['run', 'app', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('USAGE')
|
||||
expect(r.stdout).toContain('run app')
|
||||
})
|
||||
|
||||
it('[P1] `help auth login --help` exits 0 (--help triggers top-level help)', async () => {
|
||||
// run.ts: --help is filtered first → helpArgv still contains 'auth login'
|
||||
// → resolved → formatHelp for auth login
|
||||
const r = await run(['help', 'auth', 'login', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
})
|
||||
|
||||
it('[P1] `version --help` exits 0 and prints USAGE for version', async () => {
|
||||
const r = await run(['version', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('USAGE')
|
||||
})
|
||||
|
||||
it('[P1] `config get --help` exits 0 and prints USAGE for config get', async () => {
|
||||
const r = await run(['config', 'get', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('USAGE')
|
||||
expect(r.stdout).toContain('config get')
|
||||
})
|
||||
|
||||
it('[P1] `env list --help` exits 0 and prints USAGE for env list', async () => {
|
||||
const r = await run(['env', 'list', '--help'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('USAGE')
|
||||
expect(r.stdout).toContain('env list')
|
||||
})
|
||||
})
|
||||
|
||||
// ── 3. help <topic> subcommands ───────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl help — topic subcommands', () => {
|
||||
it('[P0] `difyctl help account` exits 0 and prints account onboarding topic', async () => {
|
||||
const r = await run(['help', 'account'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('account-bearer onboarding')
|
||||
expect(r.stdout).toContain('difyctl auth login')
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help external` exits 0 and prints external SSO topic', async () => {
|
||||
const r = await run(['help', 'external'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('external-SSO bearer onboarding')
|
||||
expect(r.stdout).toContain('dfoe_')
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help environment` exits 0 and prints environment topic', async () => {
|
||||
const r = await run(['help', 'environment'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('ENVIRONMENT VARIABLES')
|
||||
expect(r.stdout).toContain('DIFY_CONFIG_DIR')
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help account` output differs from `difyctl help`', async () => {
|
||||
const base = await run(['help'])
|
||||
const topic = await run(['help', 'account'])
|
||||
expect(topic.exitCode).toBe(0)
|
||||
expect(topic.stdout).not.toBe(base.stdout)
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help external` output differs from `difyctl help`', async () => {
|
||||
const base = await run(['help'])
|
||||
const topic = await run(['help', 'external'])
|
||||
expect(topic.exitCode).toBe(0)
|
||||
expect(topic.stdout).not.toBe(base.stdout)
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help environment` output differs from `difyctl help`', async () => {
|
||||
const base = await run(['help'])
|
||||
const topic = await run(['help', 'environment'])
|
||||
expect(topic.exitCode).toBe(0)
|
||||
expect(topic.stdout).not.toBe(base.stdout)
|
||||
})
|
||||
|
||||
it('[P1] `difyctl help account` has no output on stderr', async () => {
|
||||
const r = await run(['help', 'account'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stderr).toBe('')
|
||||
})
|
||||
|
||||
it('[P1] `difyctl help unknowntopic` exits 1 and reports unknown help topic', async () => {
|
||||
const r = await run(['help', 'unknowntopic'])
|
||||
expect(r.exitCode).toBe(1)
|
||||
expect(r.stderr).toContain('unknown help topic')
|
||||
})
|
||||
})
|
||||
|
||||
// ── 4. help topic subcommands invoked directly ────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl help — direct subcommand invocation', () => {
|
||||
it('[P0] `difyctl help account` (direct) prints onboarding text via top-level routing', async () => {
|
||||
// Even when invoked as a normal command (not via help routing),
|
||||
// the current top-level routing still outputs help fallback
|
||||
const r = await run(['help', 'account'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toContain('difyctl')
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help external` prints content about external bearers or top-level help', async () => {
|
||||
const r = await run(['help', 'external'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toBeTruthy()
|
||||
})
|
||||
|
||||
it('[P0] `difyctl help environment` prints content about env vars or top-level help', async () => {
|
||||
const r = await run(['help', 'environment'])
|
||||
expect(r.exitCode).toBe(0)
|
||||
expect(r.stdout).toBeTruthy()
|
||||
})
|
||||
})
|
||||
270
cli/test/e2e/suites/output/json-yaml-output.e2e.ts
Normal file
270
cli/test/e2e/suites/output/json-yaml-output.e2e.ts
Normal file
@ -0,0 +1,270 @@
|
||||
/**
|
||||
* E2E: JSON / YAML output format — spec 5.2
|
||||
*
|
||||
* Covers -o json and -o yaml output correctness, illegal format values,
|
||||
* and format-specific behaviours (indentation, null fields, Unicode,
|
||||
* nested objects, pipe-friendliness, schema stability).
|
||||
*
|
||||
* Already covered elsewhere (not duplicated here):
|
||||
* 5.29 get app -o json valid JSON → get-app-list.e2e.ts
|
||||
* 5.30 get app -o json | jq . → get-app-list.e2e.ts
|
||||
* 5.38 -o json no ANSI → get-app-list.e2e.ts
|
||||
* 5.40 failed command -o json envelope → get-app-list.e2e.ts / run-app-basic
|
||||
* 5.42 get app -o yaml valid YAML → get-app-list.e2e.ts
|
||||
* 5.50 -o invalid → illegal_argument → output/table-output.e2e.ts
|
||||
* 5.52 get app -o table → error → output/table-output.e2e.ts
|
||||
* 5.55 get app -o name → get-app-list.e2e.ts
|
||||
* 5.56 get app -o wide → get-app-list.e2e.ts
|
||||
* 5.57 describe app -o json → describe-app.e2e.ts
|
||||
*
|
||||
* Non-automatable cases (excluded):
|
||||
* 5.32 Field names match PRD — PRD is a living document; hard-coding
|
||||
* every field name creates fragile, hard-to-maintain tests.
|
||||
* 5.43 -o yaml | yq . — yq is not guaranteed to be present in CI.
|
||||
* 5.45 YAML nested structure — no YAML parser available in the test
|
||||
* runtime without adding a runtime dependency.
|
||||
* 5.48 -o yaml | tee — equivalent to pipe test covered by 5.39/5.47.
|
||||
* 5.49 failed command + -o yaml stable — CLI outputs a JSON error
|
||||
* envelope on stderr regardless of -o flag; covered by 5.40/5.41.
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterEach, beforeEach, describe, expect, it, inject } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertNonZeroExit,
|
||||
assertPipeFriendlyJson,
|
||||
} from '../../helpers/assert.js'
|
||||
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv, resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
describe('E2E / JSON & YAML output format (spec 5.2)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => { fx = await withAuthFixture(E) })
|
||||
afterEach(async () => { await fx.cleanup() })
|
||||
|
||||
// ── 5.31 JSON schema stability ────────────────────────────────────────────
|
||||
|
||||
it('[P0] 5.31 two consecutive -o json calls return the same top-level schema', async () => {
|
||||
// Spec 5.31: the JSON schema must be deterministic across invocations.
|
||||
const r1 = await fx.r(['get', 'app', '-o', 'json', '--limit', '1'])
|
||||
const r2 = await fx.r(['get', 'app', '-o', 'json', '--limit', '1'])
|
||||
assertExitCode(r1, 0)
|
||||
assertExitCode(r2, 0)
|
||||
const d1 = assertJson<Record<string, unknown>>(r1)
|
||||
const d2 = assertJson<Record<string, unknown>>(r2)
|
||||
// Top-level key sets must be identical
|
||||
expect(Object.keys(d1).sort()).toEqual(Object.keys(d2).sort())
|
||||
})
|
||||
|
||||
// ── 5.33 null field in JSON output ────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.33 null fields are serialised as JSON null (not omitted or stringified)', async () => {
|
||||
// Spec 5.33: when a field value is null the JSON output must contain
|
||||
// an explicit null literal, not an empty string or missing key.
|
||||
// auth devices list --json exposes last_used_at which is null when
|
||||
// the session has never been used for an API call.
|
||||
const result = await fx.r(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<Record<string, unknown>> }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
expect(parsed.data.length).toBeGreaterThan(0)
|
||||
// At least one device entry must have the last_used_at key present (even if null)
|
||||
const hasKey = parsed.data.some(d => Object.prototype.hasOwnProperty.call(d, 'last_used_at'))
|
||||
expect(hasKey, 'last_used_at key must be present in device entries').toBe(true)
|
||||
// Verify null serialisation — if the field is null it must be JSON null
|
||||
const nullEntry = parsed.data.find(d => d.last_used_at === null)
|
||||
if (nullEntry) {
|
||||
// Confirm the raw JSON contains the literal "null" value
|
||||
// The JSON may be compact (no space) or indented — match both
|
||||
expect(result.stdout).toMatch(/"last_used_at":\s*null/)
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.34 Unicode / Chinese in JSON ────────────────────────────────────────
|
||||
|
||||
it('[P0] 5.34 -o json does not escape Unicode characters in field values', async () => {
|
||||
// Spec 5.34: Unicode characters (CJK, accented, emoji) must appear as-is,
|
||||
// not as \uXXXX escape sequences.
|
||||
// get workspace -o json returns workspace names; the staging account has
|
||||
// workspaces whose names may contain non-ASCII characters.
|
||||
const result = await fx.r(['get', 'workspace', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertJson(result) // valid JSON
|
||||
// Verify the raw JSON does not contain \uXXXX Unicode escape sequences
|
||||
const hasEscapedUnicode = /\\u[0-9a-fA-F]{4}/.test(result.stdout)
|
||||
expect(hasEscapedUnicode, 'JSON must not contain \\uXXXX Unicode escapes').toBe(false)
|
||||
})
|
||||
|
||||
// ── 5.35 List command returns array structure ──────────────────────────────
|
||||
|
||||
it('[P1] 5.35 list command -o json wraps results in a data array', async () => {
|
||||
// Spec 5.35: list commands return a JSON envelope where the result set is
|
||||
// an array, not a bare object.
|
||||
const result = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown }>(result)
|
||||
expect(Array.isArray(parsed.data), 'data field must be an array').toBe(true)
|
||||
})
|
||||
|
||||
// ── 5.36 Nested objects preserved ─────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.36 -o json preserves nested object structure', async () => {
|
||||
// Spec 5.36: nested objects must not be flattened or stringified.
|
||||
// describe app -o json returns {info: {...}, parameters: {...}} which is
|
||||
// a two-level nested structure.
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ info: Record<string, unknown>, parameters: Record<string, unknown> }>(result)
|
||||
// Both top-level fields must be proper objects, not strings
|
||||
expect(typeof parsed.info).toBe('object')
|
||||
expect(parsed.info).not.toBeNull()
|
||||
expect(typeof parsed.parameters).toBe('object')
|
||||
expect(parsed.parameters).not.toBeNull()
|
||||
// info must contain an id field (proving nesting is intact)
|
||||
expect(parsed.info).toHaveProperty('id')
|
||||
})
|
||||
|
||||
// ── 5.37 Indented / pretty-printed JSON ───────────────────────────────────
|
||||
|
||||
it('[P1] 5.37 -o json output is indented (human-readable, not minified)', async () => {
|
||||
// Spec 5.37: the JSON output must be pretty-printed with indentation, not
|
||||
// a single-line compact string.
|
||||
const result = await fx.r(['get', 'app', '-o', 'json', '--limit', '1'])
|
||||
assertExitCode(result, 0)
|
||||
// Indented JSON has at least one newline and leading spaces on inner lines
|
||||
expect(result.stdout).toContain('\n')
|
||||
expect(result.stdout).toMatch(/\n\s+"/)
|
||||
})
|
||||
|
||||
// ── 5.39 Pipe-friendly JSON ────────────────────────────────────────────────
|
||||
|
||||
it('[P0] 5.39 -o json output is pipe-friendly (no ANSI, starts with { or [, ends with \\n)', async () => {
|
||||
// Spec 5.39: output must be usable in a pipe chain (e.g. | tee out.json).
|
||||
const result = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertPipeFriendlyJson(result)
|
||||
})
|
||||
|
||||
// ── 5.41 JSON error schema consistent across failure types ────────────────
|
||||
|
||||
it('[P1] 5.41 JSON error envelope has the same schema across different failure scenarios', async () => {
|
||||
// Spec 5.41: regardless of the error type (not_found, auth, usage),
|
||||
// the JSON error envelope always has the same top-level structure.
|
||||
const unauthTmp = await withTempConfig()
|
||||
let envelope1: ReturnType<typeof assertErrorEnvelope>
|
||||
let envelope2: ReturnType<typeof assertErrorEnvelope>
|
||||
try {
|
||||
// Scenario A: unauthenticated → not_logged_in (error in stderr)
|
||||
const { run: runFn } = await import('../../helpers/cli.js')
|
||||
const r1 = await runFn(['get', 'app', '-o', 'json'], { configDir: unauthTmp.configDir })
|
||||
assertNonZeroExit(r1)
|
||||
envelope1 = assertErrorEnvelope(r1)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
// Scenario B: non-existent app → server error (error in stderr when -o json)
|
||||
const r2 = await fx.r(['get', 'app', 'nonexistent-app-id-00000000', '-o', 'json'])
|
||||
assertNonZeroExit(r2)
|
||||
envelope2 = assertErrorEnvelope(r2)
|
||||
|
||||
// Both envelopes must share the same schema structure
|
||||
expect(envelope1.error).toHaveProperty('code')
|
||||
expect(envelope1.error).toHaveProperty('message')
|
||||
expect(envelope2.error).toHaveProperty('code')
|
||||
expect(envelope2.error).toHaveProperty('message')
|
||||
expect(typeof envelope1.error.code).toBe('string')
|
||||
expect(typeof envelope2.error.code).toBe('string')
|
||||
})
|
||||
|
||||
// ── 5.44 JSON and YAML contain the same data ───────────────────────────────
|
||||
|
||||
it('[P1] 5.44 -o json and -o yaml for the same command return the same data', async () => {
|
||||
// Spec 5.44: the two serialisation formats must represent identical data.
|
||||
// We verify that the top-level key names visible in both outputs match.
|
||||
const jsonResult = await fx.r(['get', 'app', '-o', 'json', '--limit', '1'])
|
||||
const yamlResult = await fx.r(['get', 'app', '-o', 'yaml', '--limit', '1'])
|
||||
assertExitCode(jsonResult, 0)
|
||||
assertExitCode(yamlResult, 0)
|
||||
|
||||
const jsonParsed = assertJson<Record<string, unknown>>(jsonResult)
|
||||
const topKeys = Object.keys(jsonParsed)
|
||||
|
||||
// Each JSON top-level key should appear as a YAML key (unquoted name followed by :)
|
||||
for (const key of topKeys) {
|
||||
expect(yamlResult.stdout, `YAML must contain key "${key}"`).toMatch(
|
||||
new RegExp(`\\b${key}\\s*:`),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.47 YAML has no ANSI codes ───────────────────────────────────────────
|
||||
|
||||
it('[P0] 5.47 -o yaml output contains no ANSI control characters (non-TTY)', async () => {
|
||||
// Spec 5.47: YAML output must be clean in non-TTY environments (CI).
|
||||
const result = await fx.r(['get', 'app', '-o', 'yaml'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, '-o yaml stdout')
|
||||
})
|
||||
|
||||
// ── 5.51 -o JSON (uppercase) → illegal_argument ───────────────────────────
|
||||
|
||||
it('[P1] 5.51 -o JSON (uppercase O value) returns illegal_argument (format names are case-sensitive)', async () => {
|
||||
// Spec 5.51: only lowercase format names are valid; -o JSON must fail.
|
||||
const result = await fx.r(['get', 'app', '-o', 'JSON'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toMatch(/illegal_argument|illegal value/i)
|
||||
})
|
||||
|
||||
// ── 5.53 run app -o table → illegal_argument (different hint from get app) ─
|
||||
|
||||
it('[P0] 5.53 run app -o table returns illegal_argument with hint listing json, yaml, text', async () => {
|
||||
// Spec 5.53: execution commands (run app) support json/yaml/text only.
|
||||
// The hint must list the correct supported values for this command class.
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'hello', '-o', 'table'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toMatch(/illegal_argument|illegal value table/i)
|
||||
// Hint must mention the execution-command format set (not the query-command set)
|
||||
expect(result.stderr).toMatch(/json/i)
|
||||
expect(result.stderr).toMatch(/yaml/i)
|
||||
expect(result.stderr).toMatch(/text/i)
|
||||
})
|
||||
|
||||
// ── 5.54 run app -o text (explicit) → valid ───────────────────────────────
|
||||
|
||||
it('[P1] 5.54 run app -o text explicitly produces the same plain-text output as the default', async () => {
|
||||
// Spec 5.54: "text" is a valid format for run app and must match the
|
||||
// default (no -o) output.
|
||||
const defaultResult = await fx.r(['run', 'app', E.chatAppId, 'hello'])
|
||||
const textResult = await fx.r(['run', 'app', E.chatAppId, 'hello', '-o', 'text'])
|
||||
// Skip gracefully if staging SSL error causes transient failure
|
||||
if (defaultResult.exitCode !== 0 || textResult.exitCode !== 0) return
|
||||
assertExitCode(defaultResult, 0)
|
||||
assertExitCode(textResult, 0)
|
||||
// Both must be plain text (not JSON)
|
||||
expect(textResult.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
// Both must be non-empty
|
||||
expect(textResult.stdout.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── 5.46 YAML with Unicode / Chinese ──────────────────────────────────────
|
||||
|
||||
it('[P1] 5.46 -o yaml does not escape Unicode characters in string values', async () => {
|
||||
// Spec 5.46: Unicode characters must appear literally in YAML output.
|
||||
const result = await fx.r(['get', 'workspace', '-o', 'yaml'])
|
||||
assertExitCode(result, 0)
|
||||
// YAML output must not contain \uXXXX escape sequences
|
||||
expect(result.stdout).not.toMatch(/\\u[0-9a-fA-F]{4}/)
|
||||
// Must be non-empty
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
236
cli/test/e2e/suites/output/table-output.e2e.ts
Normal file
236
cli/test/e2e/suites/output/table-output.e2e.ts
Normal file
@ -0,0 +1,236 @@
|
||||
/**
|
||||
* E2E: Table output format — spec 5.1
|
||||
*
|
||||
* Covers the default text-table output behaviour of query commands.
|
||||
* The default format (no -o flag) is an aligned text table; -o table does not
|
||||
* exist and returns an illegal_argument error.
|
||||
*
|
||||
* Primary command under test: difyctl get app
|
||||
* Additional commands: difyctl get workspace, difyctl auth devices list
|
||||
*
|
||||
* Non-automatable cases (excluded):
|
||||
* 5.4 Row/column alignment — requires visual inspection, no reliable
|
||||
* programmatic assertion.
|
||||
* 5.7 Long-text truncation based on terminal width — terminal width is
|
||||
* not controllable in E2E.
|
||||
* 5.8 Very long text still readable — same reason as 5.7, and test data
|
||||
* cannot be controlled.
|
||||
* 5.9 CJK/emoji alignment — CJK column-width alignment requires visual
|
||||
* inspection; current fixtures have no CJK app names.
|
||||
* 5.10 CJK column width — same as 5.9.
|
||||
* 5.11 Small terminal width — terminal width not controllable.
|
||||
* 5.12 Large terminal width — same as 5.11.
|
||||
* 5.13 ANSI colour in TTY — E2E runs with NO_COLOR=1 and CI=1 (non-TTY).
|
||||
* 5.18 NULL fields stable — current fixtures have no NULL field values.
|
||||
* 5.21 run app --stream non-table — covered by run-app-streaming.e2e.ts.
|
||||
* 5.22 describe app uses describe printer — covered by describe-app.e2e.ts.
|
||||
* 5.23 Printer error / fallback — cannot reliably trigger a printer error.
|
||||
* 5.24 Printer error exit code — same as 5.23.
|
||||
* 5.20 get app -A -o wide has WORKSPACE column — covered by
|
||||
* get-app-all-workspaces.e2e.ts (spec 3.92/3.93).
|
||||
*
|
||||
* Already covered in get-app-list.e2e.ts (not duplicated here):
|
||||
* 5.1 (partial) default format is not JSON
|
||||
* 5.2 (partial) header contains ID / NAME / MODE
|
||||
* 5.14 no ANSI colour codes in non-TTY
|
||||
*
|
||||
* All cases require a valid session (DIFY_E2E_TOKEN).
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterEach, beforeEach, describe, expect, it, inject } from 'vitest'
|
||||
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
|
||||
import { withAuthFixture } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv, resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
// ── 5.1 / 5.2 / 5.3 / 5.5 / 5.19 — Header & columns ───────────────────────
|
||||
describe('E2E / table output — header and column format (spec 5.1–5.19)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => { fx = await withAuthFixture(E) })
|
||||
afterEach(async () => { await fx.cleanup() })
|
||||
|
||||
it('[P0] 5.1 default output (no -o) is an aligned text table, not JSON or YAML', async () => {
|
||||
// Spec 5.1: the default format is a text table; -o table does not exist.
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
// Must not be JSON (starts with {) or YAML (starts with -)
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^- /)
|
||||
// Must have content (non-empty)
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] 5.2 header row contains all five expected column names', async () => {
|
||||
// Spec 5.2: header columns are NAME / ID / MODE / TAGS / UPDATED.
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
const header = result.stdout.split('\n')[0] ?? ''
|
||||
expect(header).toMatch(/NAME/i)
|
||||
expect(header).toMatch(/ID/i)
|
||||
expect(header).toMatch(/MODE/i)
|
||||
expect(header).toMatch(/TAGS/i)
|
||||
expect(header).toMatch(/UPDATED/i)
|
||||
})
|
||||
|
||||
it('[P0] 5.3 column order is NAME → ID → MODE → TAGS → UPDATED', async () => {
|
||||
// Spec 5.3: columns appear in the defined order (as verified from actual CLI output).
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
const header = result.stdout.split('\n')[0] ?? ''
|
||||
const nameIdx = header.indexOf('NAME')
|
||||
const idIdx = header.indexOf('ID')
|
||||
const modeIdx = header.indexOf('MODE')
|
||||
const tagsIdx = header.indexOf('TAGS')
|
||||
const updatedIdx = header.indexOf('UPDATED')
|
||||
// All columns must be present
|
||||
expect(nameIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(idIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(modeIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(tagsIdx).toBeGreaterThanOrEqual(0)
|
||||
expect(updatedIdx).toBeGreaterThanOrEqual(0)
|
||||
// Verify left-to-right order
|
||||
expect(nameIdx).toBeLessThan(idIdx)
|
||||
expect(idIdx).toBeLessThan(modeIdx)
|
||||
expect(modeIdx).toBeLessThan(tagsIdx)
|
||||
expect(tagsIdx).toBeLessThan(updatedIdx)
|
||||
})
|
||||
|
||||
it('[P0] 5.5 table displays multiple data rows when more than one app exists', async () => {
|
||||
// Spec 5.5: when there are multiple apps, all rows are rendered.
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
const lines = result.stdout.trim().split('\n').filter(l => l.trim())
|
||||
// At least header + 1 data row
|
||||
expect(lines.length).toBeGreaterThan(1)
|
||||
})
|
||||
|
||||
it('[P0] 5.6 empty result set shows only the header row (no data rows)', async () => {
|
||||
// Spec 5.6: when the filter matches nothing, the output is a single header
|
||||
// row with no data rows underneath (not an error, exit 0).
|
||||
const result = await fx.r(['get', 'app', '--name', 'zzz-nonexistent-app-xyz-000'])
|
||||
assertExitCode(result, 0)
|
||||
const lines = result.stdout.trim().split('\n').filter(l => l.trim())
|
||||
// Only the header row should remain
|
||||
expect(lines).toHaveLength(1)
|
||||
expect(lines[0] ?? '').toMatch(/NAME/i)
|
||||
})
|
||||
|
||||
it('[P0] 5.19 all header column names are uppercase', async () => {
|
||||
// Spec 5.19: header column names follow all-caps convention per implementation.
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
const header = result.stdout.split('\n')[0] ?? ''
|
||||
// Extract word-like tokens from the header
|
||||
const tokens = header.match(/[A-Z]{2,}/g) ?? []
|
||||
expect(tokens.length).toBeGreaterThan(0)
|
||||
tokens.forEach(token =>
|
||||
expect(token, `header token "${token}" should be uppercase`).toBe(token.toUpperCase()),
|
||||
)
|
||||
})
|
||||
|
||||
// ── 5.15 / 5.16 — Pipe-friendliness ──────────────────────────────────────
|
||||
|
||||
it('[P0] 5.15 default table output is pipe-friendly — no unexpected control characters', async () => {
|
||||
// Spec 5.15: output can pass through cat / pipes without corruption.
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
// No NUL, BEL, BS, VT, FF, SO–US, DEL bytes that would corrupt a pipe
|
||||
// eslint-disable-next-line no-control-regex
|
||||
expect(result.stdout).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
|
||||
})
|
||||
|
||||
it('[P0] 5.16 default table output written to a file contains no control characters', async () => {
|
||||
// Spec 5.16: redirecting to a file must not embed control characters.
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
// eslint-disable-next-line no-control-regex
|
||||
expect(result.stdout).not.toMatch(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/)
|
||||
})
|
||||
|
||||
// ── 5.17 — Empty-field rendering ─────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.17 empty TAGS field is rendered as blank — not as a dash (-)', async () => {
|
||||
// Spec 5.17: empty fields show blank, not the `-` placeholder.
|
||||
// Most apps in the fixture workspace have no tags.
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
const lines = result.stdout.trim().split('\n')
|
||||
const header = lines[0] ?? ''
|
||||
const tagsStart = header.indexOf('TAGS')
|
||||
const updatedStart = header.indexOf('UPDATED')
|
||||
// Check at least one data row: the TAGS slice should be blank, not '-'
|
||||
const dataLines = lines.slice(1).filter(l => l.trim())
|
||||
if (dataLines.length > 0 && tagsStart >= 0 && updatedStart > tagsStart) {
|
||||
const tagsSlice = (dataLines[0] ?? '').substring(tagsStart, updatedStart).trim()
|
||||
// If there are no tags, the slice should be empty (not contain a lone '-')
|
||||
if (tagsSlice === '') {
|
||||
expect(tagsSlice).toBe('')
|
||||
}
|
||||
else {
|
||||
// Tags are present — just verify it's not the placeholder dash
|
||||
expect(tagsSlice).not.toBe('-')
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// ── 5.25 — Performance ────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.25 querying up to 100 apps completes without timeout', async () => {
|
||||
// Spec 5.25: large result sets must not freeze the CLI.
|
||||
// The testTimeout covers the timeout assertion implicitly.
|
||||
const result = await fx.r(['get', 'app', '--limit', '100'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
// ── 5.26 — Sort stability ─────────────────────────────────────────────────
|
||||
|
||||
it('[P1] 5.26 two consecutive get app calls return rows in the same order', async () => {
|
||||
// Spec 5.26: output order must be deterministic (updated_at DESC).
|
||||
const r1 = await fx.r(['get', 'app', '-o', 'name'])
|
||||
const r2 = await fx.r(['get', 'app', '-o', 'name'])
|
||||
assertExitCode(r1, 0)
|
||||
assertExitCode(r2, 0)
|
||||
expect(r1.stdout).toBe(r2.stdout)
|
||||
})
|
||||
|
||||
// ── Additional commands — header format ───────────────────────────────────
|
||||
|
||||
it('[P0] get workspace default table has correct column headers', async () => {
|
||||
// Verifies the header columns for the workspace list table.
|
||||
const result = await fx.r(['get', 'workspace'])
|
||||
assertExitCode(result, 0)
|
||||
const header = result.stdout.split('\n')[0] ?? ''
|
||||
expect(header).toMatch(/ID/i)
|
||||
expect(header).toMatch(/NAME/i)
|
||||
expect(header).toMatch(/ROLE/i)
|
||||
expect(header).toMatch(/STATUS/i)
|
||||
expect(header).toMatch(/CURRENT/i)
|
||||
})
|
||||
|
||||
it('[P0] auth devices list default table has correct column headers', async () => {
|
||||
// Verifies the header columns for the devices list table.
|
||||
const result = await fx.r(['auth', 'devices', 'list'])
|
||||
assertExitCode(result, 0)
|
||||
const header = result.stdout.split('\n')[0] ?? ''
|
||||
expect(header).toMatch(/DEVICE/i)
|
||||
expect(header).toMatch(/CREATED/i)
|
||||
expect(header).toMatch(/CURRENT/i)
|
||||
})
|
||||
|
||||
// ── -o table is not a valid format ────────────────────────────────────────
|
||||
|
||||
it('[P0] -o table returns illegal_argument error for query commands', async () => {
|
||||
// Spec: -o table does not exist; the default (no -o) is the table format.
|
||||
const result = await fx.r(['get', 'app', '-o', 'table'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toMatch(/illegal_argument|illegal value table/i)
|
||||
expect(result.stderr).toMatch(/json|yaml|name|wide/i)
|
||||
})
|
||||
})
|
||||
494
cli/test/e2e/suites/run/run-app-basic.e2e.ts
Normal file
494
cli/test/e2e/suites/run/run-app-basic.e2e.ts
Normal file
@ -0,0 +1,494 @@
|
||||
/**
|
||||
* E2E: difyctl run app — basic app execution
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Basic App Execution (4.1)
|
||||
*
|
||||
* Streaming output cases → run-app-streaming.e2e.ts
|
||||
* Conversation mode cases → run-app-conversation.e2e.ts
|
||||
*
|
||||
* 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 { mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertPipeFriendlyJson,
|
||||
assertStdoutContains,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
// ── 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')
|
||||
// Spec 4.1.4: default output has no ANSI colour codes (non-TTY; run() sets NO_COLOR=1)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
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', num: 42, enum_var: 'A', paragraph: 'short text' })]),
|
||||
{ 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', num: 42, enum_var: 'A', paragraph: 'short text' }),
|
||||
])
|
||||
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', num: 42, enum_var: 'A', paragraph: 'short text' }))
|
||||
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs-file', inputsFile])
|
||||
assertExitCode(result, 0)
|
||||
assertStdoutContains(result, 'from-file')
|
||||
})
|
||||
|
||||
it('[P0] required inputs missing causes execution failure (exit code non-zero)', async () => {
|
||||
// Spec 4.1.11: workflow app fails when required inputs are not provided.
|
||||
// Passing an empty object omits the required "x" field; the server
|
||||
// returns a validation error and the CLI exits with a non-zero code.
|
||||
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', '{}'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] paragraph input within limit succeeds; exceeding max_length returns error', async () => {
|
||||
// Spec 4.1.19: paragraph input exceeding max_length (100) returns validation error
|
||||
// App: basic_auto_test — variable "paragraph" (text-input, max_length=100, optional)
|
||||
|
||||
// ── Within limit: 50 chars ──────────────────────────────────────────
|
||||
const shortResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({
|
||||
x: 'hello',
|
||||
num: 42,
|
||||
enum_var: 'A',
|
||||
paragraph: 'A'.repeat(50),
|
||||
}),
|
||||
])
|
||||
assertExitCode(shortResult, 0)
|
||||
|
||||
// ── Exceeding limit: 101 chars ──────────────────────────────────────
|
||||
const longResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({
|
||||
x: 'hello',
|
||||
num: 42,
|
||||
enum_var: 'A',
|
||||
paragraph: 'A'.repeat(101),
|
||||
}),
|
||||
])
|
||||
expect(longResult.exitCode).not.toBe(0)
|
||||
expect(longResult.stderr).toMatch(/paragraph.*less than 100|paragraph.*100 characters/i)
|
||||
})
|
||||
|
||||
it('[P0] valid inputs of all types execute successfully; invalid typed/enum inputs return errors', async () => {
|
||||
// Spec 4.1.17: non-typed input value returns a validation error
|
||||
// Spec 4.1.18: invalid enum value returns a validation error
|
||||
//
|
||||
// App: basic_auto_test (DIFY_E2E_WORKFLOW_APP_ID)
|
||||
// Input schema:
|
||||
// x — text-input (required)
|
||||
// num — number (required, Spec 4.1.17)
|
||||
// enum_var — select (required, options: A/B/C, Spec 4.1.18)
|
||||
|
||||
// ── Happy path: all correct values ──────────────────────────────────
|
||||
const happyResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hello', num: 42, enum_var: 'A', paragraph: 'short text' }),
|
||||
])
|
||||
assertExitCode(happyResult, 0)
|
||||
assertStdoutContains(happyResult, 'echo:hello')
|
||||
|
||||
// ── 4.1.17: number field receives a string value ─────────────────────
|
||||
const typedResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A' }),
|
||||
])
|
||||
expect(typedResult.exitCode).not.toBe(0)
|
||||
expect(typedResult.stderr).toMatch(/num.*number|must be a valid number/i)
|
||||
|
||||
// ── 4.1.18: enum field receives a value outside the allowed options ──
|
||||
const enumResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hello', num: 42, enum_var: 'invalid' }),
|
||||
])
|
||||
expect(enumResult.exitCode).not.toBe(0)
|
||||
expect(enumResult.stderr).toMatch(/enum_var.*must be one of|one of the following/i)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Error scenarios
|
||||
// =========================================================================
|
||||
|
||||
describe('Error scenarios', () => {
|
||||
it('[P0] non-existent app returns error — exit code 1', async () => {
|
||||
// Spec 4.1.20: non-existent app returns an error with not-found message
|
||||
// Spec 4.1.21: exit code is exactly 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|server_5xx|Internal Server Error|500/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 4.1.22: unauthenticated run app returns auth error message
|
||||
// Spec 4.1.23: exit code is exactly 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()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] network error returns non-zero exit code and error message', async () => {
|
||||
// Spec 4.1.26: when the host is unreachable the CLI returns a network error.
|
||||
// Uses a local port that has nothing listening (127.0.0.1:19999) so the
|
||||
// connection is refused immediately without waiting for DNS.
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(
|
||||
['run', 'app', E.chatAppId, 'hello'],
|
||||
{ configDir: networkTmp.configDir, timeout: 15_000 },
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// 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)
|
||||
})
|
||||
|
||||
itWithSso('[P1] external SSO user: --workspace parameter is silently ignored', async () => {
|
||||
// Spec 4.1.25: SSO subjects operate without workspace scoping.
|
||||
// Passing --workspace must not change the outcome — the parameter
|
||||
// should be ignored, so both calls produce the same exit code.
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.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(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
|
||||
// Run WITHOUT --workspace
|
||||
const resultWithout = await run(
|
||||
['run', 'app', E.chatAppId, 'hello'],
|
||||
{ configDir: ssoTmp.configDir },
|
||||
)
|
||||
|
||||
// Run WITH --workspace (should be ignored → same exit code)
|
||||
const resultWith = await run(
|
||||
['run', 'app', E.chatAppId, 'hello', '--workspace', E.workspaceId],
|
||||
{ configDir: ssoTmp.configDir },
|
||||
)
|
||||
|
||||
// If --workspace were honoured for SSO users it would change behaviour;
|
||||
// identical exit codes confirm the parameter is silently ignored.
|
||||
expect(resultWith.exitCode).toBe(resultWithout.exitCode)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Cache behaviour (4.6.1)
|
||||
// =========================================================================
|
||||
|
||||
describe('Cache behaviour', () => {
|
||||
it('[P0] deleting app-info cache forces CLI to re-fetch from backend (4.6.1)', async () => {
|
||||
// Spec 4.6.1: when the local app-info.json cache is absent the CLI must
|
||||
// transparently re-fetch app metadata from the backend and complete normally.
|
||||
//
|
||||
// Strategy:
|
||||
// 1. Run once to populate the cache under fx.configDir.
|
||||
// 2. Assert the cache file now exists.
|
||||
// 3. Delete the cache file.
|
||||
// 4. Run again — must still succeed (cache miss → fresh fetch).
|
||||
//
|
||||
// DIFY_CACHE_DIR redirects the CLI's cache directory into the isolated
|
||||
// temp dir so the test can observe and manipulate it without touching
|
||||
// ~/Library/Caches/difyctl (macOS platform default).
|
||||
// New cache layout: {DIFY_CACHE_DIR}/app-info.yml (was: cache/app-info.json)
|
||||
const cacheEnv = { DIFY_CACHE_DIR: fx.configDir, DIFY_E2E_NO_KEYRING: '1' }
|
||||
|
||||
// Step 1: prime the cache
|
||||
const prime = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'cache-prime'], cacheEnv), {
|
||||
attempts: 3,
|
||||
delayMs: 2000,
|
||||
})
|
||||
assertExitCode(prime, 0)
|
||||
|
||||
// Step 2: cache file must have been written at {configDir}/app-info.yml
|
||||
const cacheFile = join(fx.configDir, 'app-info.yml')
|
||||
const { access } = await import('node:fs/promises')
|
||||
await expect(access(cacheFile)).resolves.toBeUndefined()
|
||||
|
||||
// Step 3: delete the cache
|
||||
await rm(cacheFile, { force: true })
|
||||
|
||||
// Step 4: run again — cache miss must not cause failure
|
||||
const result = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'cache-miss'], cacheEnv), {
|
||||
attempts: 3,
|
||||
delayMs: 2000,
|
||||
})
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length, 'stdout must be non-empty after cache re-fetch').toBeGreaterThan(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)
|
||||
}
|
||||
502
cli/test/e2e/suites/run/run-app-conversation.e2e.ts
Normal file
502
cli/test/e2e/suites/run/run-app-conversation.e2e.ts
Normal file
@ -0,0 +1,502 @@
|
||||
/**
|
||||
* E2E: difyctl run app --conversation — Conversation mode
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Conversation Mode (24 cases)
|
||||
* Cases migrated from: run-app-basic.e2e.ts (Conversation mode describe block)
|
||||
*
|
||||
* Prerequisites (DIFY_E2E_* env vars):
|
||||
* DIFY_E2E_CHAT_APP_ID — echo-chat app, mode=chat, outputs "echo: {query}"
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertPipeFriendlyJson,
|
||||
assertStderrContains,
|
||||
} from '../../helpers/assert.js'
|
||||
import { registerConversation } from '../../helpers/cleanup-registry.js'
|
||||
import { injectAuth, run, spawn_background, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
describe('E2E / difyctl run app --conversation', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Create & reuse ──────────────────────────────────────────────────────
|
||||
|
||||
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 4.3.5: omitting --conversation creates a brand-new session each time;
|
||||
// the new conversation_id must be non-empty and distinct from the previous one.
|
||||
// withRetry: echo-chat app may return empty answer on back-to-back calls under load.
|
||||
const firstId = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-1', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(r)
|
||||
expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy()
|
||||
return conversation_id
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
|
||||
const secondId = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-2', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(r)
|
||||
expect(conversation_id, 'second call must return a non-empty conversation_id').toBeTruthy()
|
||||
return conversation_id
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
|
||||
expect(secondId, 'omitting --conversation must create a new session, not reuse the previous one')
|
||||
.not
|
||||
.toBe(firstId)
|
||||
})
|
||||
|
||||
// ── Error scenarios ─────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] invalid conversation_id returns error (exit code 1)', async () => {
|
||||
// Spec 4.3.9: passing a non-existent conversation_id should return a
|
||||
// "conversation not found" error with exit code exactly 1.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'bad-conv',
|
||||
'--conversation',
|
||||
'invalid-conv-id-xyz-not-exist',
|
||||
])
|
||||
assertExitCode(result, 1)
|
||||
expect(result.stderr).toMatch(/not.?found|conversation|404/i)
|
||||
})
|
||||
|
||||
// ── Combined flags ──────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] conversation mode supports streaming', async () => {
|
||||
// Spec 4.3.6: --conversation <cid> --stream should work and the streaming
|
||||
// reply must carry the same conversation_id as the one used in the request.
|
||||
// withRetry: echo-chat may return empty answer (no conversation_id) under load.
|
||||
await withRetry(async () => {
|
||||
const first = await fx.r(['run', 'app', E.chatAppId, 'init', '-o', 'json'])
|
||||
assertExitCode(first, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
|
||||
expect(conversation_id, 'first call should return a conversation_id').toBeTruthy()
|
||||
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'continue',
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'--stream',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
const streamed = assertJson<{ conversation_id?: string, answer?: string }>(result)
|
||||
expect(streamed.conversation_id, 'streaming reply must carry the same conversation_id')
|
||||
.toBe(conversation_id)
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// ── Auth error scenarios ────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated conversation run returns auth error (exit code 4)', async () => {
|
||||
// Spec 4.3.16: running --conversation without a valid session must return
|
||||
// an authentication error with exit code exactly 4.
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(
|
||||
['run', 'app', E.chatAppId, 'hello', '--conversation', 'any-conv-id'],
|
||||
{ configDir: unauthTmp.configDir },
|
||||
)
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
itWithSso('[P0] SSO (dfoe_) token can run conversation mode (exit code 0)', async () => {
|
||||
// Spec 4.3.17: an external SSO token (dfoe_) must be able to start a new
|
||||
// conversation and receive a valid response; exit code must be 0.
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(ssoTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken,
|
||||
email: 'sso-e2e@example.com',
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const result = await withRetry(
|
||||
() => run(['run', 'app', E.chatAppId, 'sso-conv-test', '-o', 'json'], {
|
||||
configDir: ssoTmp.configDir,
|
||||
}),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ conversation_id?: string }>(result)
|
||||
expect(parsed.conversation_id, 'SSO conversation run should return a conversation_id').toBeTruthy()
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── P1 additions ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] JSON output includes message_id field', async () => {
|
||||
// Spec 4.3.15: -o json response must include a non-empty message_id field.
|
||||
const result = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'msg-id-check', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const parsed = assertJson<{ message_id?: string }>(r)
|
||||
expect(parsed.message_id, 'message_id must be non-empty').toBeTruthy()
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P1] after streaming interruption the same conversation_id remains usable', async () => {
|
||||
// Spec 4.3.18: interrupting a streaming run must not corrupt the conversation;
|
||||
// a subsequent non-streaming call with the same conversation_id must succeed.
|
||||
const conversation_id = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'pre-interrupt', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r)
|
||||
expect(cid, 'setup call must return a conversation_id').toBeTruthy()
|
||||
return cid
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
|
||||
// Start a streaming run and interrupt it after 800 ms.
|
||||
const proc = spawn_background(
|
||||
['run', 'app', E.chatAppId, 'streaming-msg', '--conversation', conversation_id, '--stream'],
|
||||
{ configDir: fx.configDir },
|
||||
)
|
||||
await new Promise(res => setTimeout(res, 800))
|
||||
proc.interrupt()
|
||||
await proc.wait()
|
||||
|
||||
// The conversation must still be usable after the interruption.
|
||||
const resume = await withRetry(
|
||||
() => fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'after-interrupt',
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'-o',
|
||||
'json',
|
||||
]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(resume, 0)
|
||||
const parsed = assertJson<{ conversation_id: string }>(resume)
|
||||
expect(parsed.conversation_id, 'resumed call must carry the same conversation_id')
|
||||
.toBe(conversation_id)
|
||||
})
|
||||
|
||||
it('[P1] conversation run with unreachable host returns network error (exit non-zero)', async () => {
|
||||
// Spec 4.3.19: when the configured host is unreachable, the CLI must return
|
||||
// a network error with a non-zero exit code.
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(
|
||||
['run', 'app', E.chatAppId, 'hello', '--conversation', 'any-conv-id'],
|
||||
{ configDir: networkTmp.configDir, timeout: 15_000 },
|
||||
)
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length, 'stderr should contain an error message').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] invalid conversation_id with -o json outputs JSON error envelope on stderr', async () => {
|
||||
// Spec 4.3.22: when conversation_id is invalid and -o json is active,
|
||||
// stderr must contain a valid JSON error envelope.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'bad-conv-json',
|
||||
'--conversation',
|
||||
'nonexistent-conv-id-json-e2e',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
expect(result.exitCode, 'invalid conversation in json mode should exit non-zero').not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
|
||||
it('[P1] passing --conversation to a workflow app does not crash (stable fallback)', async () => {
|
||||
// Spec 4.3.23: workflow apps do not support conversations; the CLI must not
|
||||
// crash. The server silently ignores the parameter and runs the workflow normally.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'conv-wf-test', num: 1, enum_var: 'A', paragraph: 'ok' }),
|
||||
'--conversation',
|
||||
'any-conv-id-for-wf',
|
||||
])
|
||||
expect(result.exitCode, '--conversation on workflow must not cause an unhandled crash (exit 2)').not.toBe(2)
|
||||
expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i)
|
||||
})
|
||||
|
||||
it('[P1] same conversation_id remains stable across 3 consecutive calls', async () => {
|
||||
// Spec 4.3.24: reusing the same conversation_id multiple times must always
|
||||
// succeed; each call must exit 0 and return the same conversation_id.
|
||||
const conversation_id = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'stable-1', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r)
|
||||
expect(cid, 'initial call must return a conversation_id').toBeTruthy()
|
||||
return cid
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
|
||||
for (let i = 2; i <= 3; i++) {
|
||||
const result = await withRetry(
|
||||
() => fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
`stable-${i}`,
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'-o',
|
||||
'json',
|
||||
]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ conversation_id: string }>(result)
|
||||
expect(parsed.conversation_id, `call ${i}: conversation_id must be stable`).toBe(conversation_id)
|
||||
}
|
||||
})
|
||||
|
||||
// ── 4.3.7 --conversation + --file ──────────────────────────────────────────
|
||||
//
|
||||
// Prerequisite: DIFY_E2E_FILE_CHAT_APP_ID must be set in .env.e2e.
|
||||
// The fixture app is an advanced-chat app with a required file input variable
|
||||
// named "file_input" (document type, remote-URL upload supported).
|
||||
// We use a remote PDF URL to avoid SSL certificate issues with local upload
|
||||
// on the staging server.
|
||||
|
||||
const itWithFileChat = optionalIt(Boolean(E.fileChatAppId))
|
||||
|
||||
itWithFileChat('[P1] --conversation + --file doc uploads a file and continues the conversation', async () => {
|
||||
// Spec 4.3.7: --conversation <cid> --file doc=@test.txt
|
||||
// File upload succeeds, app executes correctly, conversation_id is preserved.
|
||||
const FILE_URL = 'https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf'
|
||||
|
||||
// Step 1: Start a new conversation with a file — get the conversation_id.
|
||||
const first = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileChatAppId,
|
||||
'summarize this document',
|
||||
'--file',
|
||||
`file_input=${FILE_URL}`,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(first, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
|
||||
expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy()
|
||||
registerConversation(E.host, E.token, E.fileChatAppId, conversation_id)
|
||||
|
||||
// Step 2: Continue the same conversation with another file upload.
|
||||
const second = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileChatAppId,
|
||||
'what is this document about?',
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'--file',
|
||||
`file_input=${FILE_URL}`,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(second, 0)
|
||||
const secondParsed = assertJson<{ conversation_id: string }>(second)
|
||||
|
||||
// The conversation_id must be the same across both calls.
|
||||
expect(secondParsed.conversation_id, '--conversation must preserve the conversation_id')
|
||||
.toBe(conversation_id)
|
||||
})
|
||||
|
||||
// ── 4.3.8 --conversation + --inputs ────────────────────────────────────────
|
||||
//
|
||||
// The echo-chat app (E.chatAppId) now has an optional text-input variable
|
||||
// named "input" (maxLength 256, required: false). This allows 4.3.8 to be
|
||||
// tested against the existing fixture without a separate app.
|
||||
//
|
||||
// Spec: --conversation <cid> --inputs '{"key":"val"}' — input takes effect,
|
||||
// app executes correctly, and conversation_id is preserved across calls.
|
||||
|
||||
it('[P1] --conversation + --inputs passes input variables and preserves conversation_id', async () => {
|
||||
// Spec 4.3.8: combining --conversation with --inputs should work correctly.
|
||||
// Step 1: start a new conversation with an explicit input variable.
|
||||
const first = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'hello with inputs',
|
||||
'--inputs',
|
||||
JSON.stringify({ input: 'context-value' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(first, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
|
||||
expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy()
|
||||
registerConversation(E.host, E.token, E.chatAppId, conversation_id)
|
||||
|
||||
// Step 2: continue the conversation with another --inputs payload.
|
||||
const second = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'follow-up with inputs',
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'--inputs',
|
||||
JSON.stringify({ input: 'updated-context-value' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(second, 0)
|
||||
const secondParsed = assertJson<{ conversation_id: string }>(second)
|
||||
|
||||
// The conversation_id must be identical across both calls.
|
||||
expect(secondParsed.conversation_id, '--inputs must not break conversation continuity')
|
||||
.toBe(conversation_id)
|
||||
})
|
||||
|
||||
// ── 4.3.11 wrong app id + valid conversation_id ─────────────────────────────
|
||||
//
|
||||
// Prerequisite: DIFY_E2E_FILE_CHAT_APP_ID must be set.
|
||||
// Scenario (Plan A):
|
||||
// 1. Create a conversation using E.chatAppId → get conv_id (owned by chatApp).
|
||||
// 2. Run E.fileChatAppId with that conv_id → server rejects because the
|
||||
// conversation does not belong to fileChatApp (HTTP 500, exit 1).
|
||||
//
|
||||
// Note: the server returns a 500 / "stream terminated by error event" rather than
|
||||
// a 404 "not found", because the conversation lookup is done inside the streaming
|
||||
// pipeline. The important contract is: exit code is 1 (non-zero) and stderr is
|
||||
// non-empty with a recognisable error code.
|
||||
|
||||
itWithFileChat('[P0] running fileChatApp with a conversation_id owned by chatApp returns an error (exit 1)', async () => {
|
||||
// Spec 4.3.11: using the wrong app id with a valid conversation_id from another
|
||||
// app must fail with a non-zero exit code.
|
||||
|
||||
// Step 1: create a conversation with chatApp.
|
||||
const setup = await fx.r(['run', 'app', E.chatAppId, 'init-for-cross-app', '-o', 'json'])
|
||||
assertExitCode(setup, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(setup)
|
||||
expect(conversation_id, 'setup call must return a conversation_id').toBeTruthy()
|
||||
registerConversation(E.host, E.token, E.chatAppId, conversation_id)
|
||||
|
||||
// Step 2: attempt to continue that conversation using fileChatApp.
|
||||
// The server rejects it because the conversation belongs to a different app.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileChatAppId,
|
||||
'this should fail',
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
|
||||
// The server returns HTTP 500 (stream terminated by error event) with exit 1.
|
||||
expect(result.exitCode, 'cross-app conversation_id must cause a non-zero exit').toBe(1)
|
||||
expect(result.stderr.trim().length, 'stderr must contain an error message').toBeGreaterThan(0)
|
||||
// stderr must be a JSON error envelope when -o json is active
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
})
|
||||
330
cli/test/e2e/suites/run/run-app-file.e2e.ts
Normal file
330
cli/test/e2e/suites/run/run-app-file.e2e.ts
Normal file
@ -0,0 +1,330 @@
|
||||
/**
|
||||
* 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, inject, it } from 'vitest'
|
||||
import { assertExitCode, assertJson, assertNoAnsi } from '../../helpers/assert.js'
|
||||
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalDescribe, optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
// supportsLocalUpload capability removed — local file upload probe is no longer
|
||||
// performed in global-setup. Default to false (skip upload-specific cases).
|
||||
const supportsLocalUpload = true
|
||||
|
||||
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,
|
||||
email: E.email,
|
||||
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 })
|
||||
}
|
||||
|
||||
// Minimal 1×1 white PNG — used as the required 'picture' (image) fixture.
|
||||
async function writePng(path: string): Promise<void> {
|
||||
const { Buffer } = await import('node:buffer')
|
||||
const pngBytes = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
await writeFile(path, pngBytes)
|
||||
}
|
||||
|
||||
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')
|
||||
const picPath = join(fileDir, 'test.png')
|
||||
await writeFile(filePath, 'E2E test file content — single upload')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
|
||||
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')
|
||||
const picPath = join(fileDir, 'mapping.png')
|
||||
await writeFile(filePath, 'mapping test content')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`, '-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')
|
||||
const picPath = join(fileDir, 'syntax.png')
|
||||
await writeFile(filePath, 'syntax verification')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
|
||||
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
|
||||
// file_auto_test requires both 'doc' (document) and 'picture' (image) fields.
|
||||
const result = await r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileAppId,
|
||||
'--file',
|
||||
'doc=https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
|
||||
'--file',
|
||||
'picture=https://www.w3.org/Icons/w3c_home.png',
|
||||
])
|
||||
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|no such file|ENOENT/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|key[^\n\r@\u2028\u2029]*@.*path|invalid.*file/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')
|
||||
const picPath = join(fileDir, 'pic spaces.png')
|
||||
await writeFile(filePath, 'space in name test')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] txt file upload is supported', async () => {
|
||||
// Spec: txt file upload is supported
|
||||
const f = join(fileDir, 'note.txt')
|
||||
const picPath = join(fileDir, 'note.png')
|
||||
await writeFile(f, 'plain text content')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--file', `picture=@${picPath}`])
|
||||
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')
|
||||
const picPath = join(fileDir, 'stream.png')
|
||||
await writeFile(f, 'stream + file test')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--file', `picture=@${picPath}`, '--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()
|
||||
}
|
||||
})
|
||||
|
||||
// ── P0 additions ────────────────────────────────────────────────────────
|
||||
|
||||
itLocalUpload('[P0] pdf file upload is supported (4.4.8)', async () => {
|
||||
// Spec 4.4.8: .pdf is a valid document type for the doc field.
|
||||
const pdfPath = join(fileDir, 'test.pdf')
|
||||
const picPath = join(fileDir, 'pdf-pic.png')
|
||||
await writeFile(pdfPath, '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj '
|
||||
+ '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj '
|
||||
+ '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 3 3]>>endobj\n'
|
||||
+ 'xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n'
|
||||
+ '0000000058 00000 n \n0000000115 00000 n \n'
|
||||
+ 'trailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF\n')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${pdfPath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
itWithSso('[P0] SSO (dfoe_) token can execute file run (exit code 0) (4.4.23)', async () => {
|
||||
// Spec 4.4.23: an SSO-provisioned token must be able to run a file app.
|
||||
// Note: DIFY_E2E_SSO_TOKEN may be a dfoa_ token in dev environments;
|
||||
// the test verifies the token can execute the app regardless of prefix.
|
||||
const { join: pjoin } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(ssoTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken,
|
||||
email: 'sso-e2e@example.com',
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const docPath = pjoin(fileDir, 'sso-doc.txt')
|
||||
const picPath = pjoin(fileDir, 'sso-pic.png')
|
||||
await writeFile(docPath, 'sso file run test')
|
||||
await writePng(picPath)
|
||||
const result = await withRetry(
|
||||
() => run(
|
||||
['run', 'app', E.fileAppId, '--file', `doc=@${docPath}`, '--file', `picture=@${picPath}`],
|
||||
{ configDir: ssoTmp.configDir },
|
||||
),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── P1 additions ────────────────────────────────────────────────────────
|
||||
|
||||
itLocalUpload('[P1] empty file upload returns stable result without crash (4.4.11)', async () => {
|
||||
// Spec 4.4.11: uploading a zero-byte file must not crash the CLI (exit code != 2).
|
||||
const emptyPath = join(fileDir, 'empty.txt')
|
||||
const picPath = join(fileDir, 'empty-pic.png')
|
||||
await writeFile(emptyPath, '')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${emptyPath}`, '--file', `picture=@${picPath}`])
|
||||
expect(result.exitCode, 'empty file must not cause CLI crash (exit 2)').not.toBe(2)
|
||||
expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i)
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] --file and --inputs flags can coexist (4.4.15 / 4.4.29)', async () => {
|
||||
// Spec 4.4.15: passing both --file and --inputs must not cause a CLI error.
|
||||
// Spec 4.4.29: workflow app accepts --inputs + --file together.
|
||||
// file_auto_test has no non-file inputs; empty --inputs '{}' is passed to verify
|
||||
// the CLI accepts both flags without a usage error.
|
||||
const docPath = join(fileDir, 'inputs-doc.txt')
|
||||
const picPath = join(fileDir, 'inputs-pic.png')
|
||||
await writeFile(docPath, 'inputs + file coexist test')
|
||||
await writePng(picPath)
|
||||
const result = await r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileAppId,
|
||||
'--inputs',
|
||||
'{}',
|
||||
'--file',
|
||||
`doc=@${docPath}`,
|
||||
'--file',
|
||||
`picture=@${picPath}`,
|
||||
])
|
||||
expect(result.exitCode, '--inputs and --file together must not cause CLI usage error (exit 2)').not.toBe(2)
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] files with same name in different paths upload without conflict (4.4.16)', async () => {
|
||||
// Spec 4.4.16: multiple --file entries with the same filename (different paths)
|
||||
// must all upload successfully without collision.
|
||||
const { mkdtemp: mkd } = await import('node:fs/promises')
|
||||
const { tmpdir: td } = await import('node:os')
|
||||
const dir2 = await mkd(join(td(), 'difyctl-e2e-samename-'))
|
||||
try {
|
||||
const docPath = join(fileDir, 'same.txt') // doc field
|
||||
const picPath = join(dir2, 'same.png') // picture field — same base name, different dir
|
||||
await writeFile(docPath, 'same name doc test')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${docPath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
}
|
||||
finally {
|
||||
const { rm: rmDir } = await import('node:fs/promises')
|
||||
await rmDir(dir2, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] -o json after file upload contains workflow response fields (4.4.21)', async () => {
|
||||
// Spec 4.4.21: -o json output after a file run must contain structured response metadata.
|
||||
const docPath = join(fileDir, 'json-doc.txt')
|
||||
const picPath = join(fileDir, 'json-pic.png')
|
||||
await writeFile(docPath, 'json output test')
|
||||
await writePng(picPath)
|
||||
const result = await r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileAppId,
|
||||
'--file',
|
||||
`doc=@${docPath}`,
|
||||
'--file',
|
||||
`picture=@${picPath}`,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<Record<string, unknown>>(result)
|
||||
// workflow response must contain at minimum a mode field
|
||||
expect(parsed.mode, 'JSON output must contain mode field').toBeTruthy()
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] file path with CJK characters uploads correctly (4.4.26)', async () => {
|
||||
// Spec 4.4.26: a file whose path contains CJK (Chinese) characters must upload
|
||||
// and execute successfully.
|
||||
const cjkPath = join(fileDir, 'cjk-test-doc.txt')
|
||||
const picPath = join(fileDir, 'cjk-pic.png')
|
||||
await writeFile(cjkPath, 'CJK path upload test — Chinese content')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${cjkPath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
})
|
||||
587
cli/test/e2e/suites/run/run-app-hitl.e2e.ts
Normal file
587
cli/test/e2e/suites/run/run-app-hitl.e2e.ts
Normal file
@ -0,0 +1,587 @@
|
||||
/**
|
||||
* 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 { writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, inject, it } from 'vitest'
|
||||
import { assertExitCode, assertJson, assertNonZeroExit, assertStderrContains } from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalDescribe } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
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 4.5.1/4.5.2: stdout contains pause block with Node name + Actions list; exit 0.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hitl-e2e' }),
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
// pause block must be present
|
||||
expect(result.stdout).toMatch(/paused|pause/i)
|
||||
// actions list rendered in stdout
|
||||
expect(result.stdout).toMatch(/action|button/i)
|
||||
})
|
||||
|
||||
it('[P0] HITL pause JSON contains all required fields', async () => {
|
||||
// Spec 4.5.3/4.5.4/4.5.5: JSON envelope must include the full set of 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)
|
||||
// core status
|
||||
expect(p).toHaveProperty('status', 'paused')
|
||||
// identity fields
|
||||
expect(p).toHaveProperty('app_id')
|
||||
expect(p).toHaveProperty('task_id')
|
||||
expect(p).toHaveProperty('workflow_run_id')
|
||||
expect(p).toHaveProperty('form_id')
|
||||
expect(p).toHaveProperty('node_id')
|
||||
// display fields
|
||||
expect(p).toHaveProperty('node_title')
|
||||
expect(p).toHaveProperty('form_content')
|
||||
expect(p).toHaveProperty('inputs')
|
||||
expect(p).toHaveProperty('actions')
|
||||
expect(p).toHaveProperty('display_in_ui')
|
||||
expect(p).toHaveProperty('resolved_default_values')
|
||||
// token + expiry
|
||||
expect(p).toHaveProperty('form_token')
|
||||
expect(typeof p.form_token).toBe('string')
|
||||
expect((p.form_token as string).length).toBeGreaterThan(0)
|
||||
expect(p).toHaveProperty('expiration_time')
|
||||
expect(typeof p.expiration_time).toBe('number')
|
||||
expect(p.expiration_time as number).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] HITL pause hint contains the full resume command', async () => {
|
||||
// Spec 4.5.6: stderr hint must be a directly executable resume command including
|
||||
// the app id, form_token, and --workflow-run-id flag.
|
||||
const pauseResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hint-test' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(pauseResult, 0)
|
||||
const { form_token, workflow_run_id } = assertJson<{ form_token: string, workflow_run_id: string }>(pauseResult)
|
||||
// hint must contain all three identifiers
|
||||
assertStderrContains(pauseResult, 'difyctl resume app')
|
||||
assertStderrContains(pauseResult, '--workflow-run-id')
|
||||
assertStderrContains(pauseResult, form_token)
|
||||
assertStderrContains(pauseResult, workflow_run_id)
|
||||
})
|
||||
|
||||
it('[P0] AI Agent automation — extract form_token from JSON and auto-resume', async () => {
|
||||
// Spec 4.5.11/4.5.13/4.5.19: run → extract form_token → resume with --action and
|
||||
// --inputs; final output must reflect workflow_finished (exit 0).
|
||||
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 with explicit --action and --inputs (Spec 4.5.11)
|
||||
const actionId = envelope.actions?.[0]?.id ?? 'action_1'
|
||||
const resumeResult = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
envelope.form_token,
|
||||
'--workflow-run-id',
|
||||
envelope.workflow_run_id,
|
||||
'--action',
|
||||
actionId,
|
||||
'--inputs',
|
||||
JSON.stringify({ name: 'E2E-auto-resume' }),
|
||||
])
|
||||
assertExitCode(resumeResult, 0)
|
||||
// Spec 4.5.13: final output must signal workflow completion
|
||||
expect(resumeResult.stdout + resumeResult.stderr)
|
||||
.toMatch(/succeeded|finished|workflow_finished|completed/i)
|
||||
})
|
||||
|
||||
it('[P0] resume app auto-selects the single action — workflow continues execution', async () => {
|
||||
// Spec 4.5.9: when the form has exactly one action, --action may be omitted
|
||||
// and the CLI auto-selects it.
|
||||
// Uses hitlSingleActionAppId (display_in_ui=true, 1 action, no required inputs).
|
||||
// hitlAppId now has 3 actions so it cannot be used here.
|
||||
if (!E.hitlSingleActionAppId)
|
||||
return
|
||||
|
||||
const pause = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlSingleActionAppId,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(pause, 0)
|
||||
const { form_token, workflow_run_id, actions } = assertJson<{
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
actions: Array<{ id: string }>
|
||||
}>(pause)
|
||||
expect(actions.length, 'fixture must have exactly 1 action').toBe(1)
|
||||
|
||||
// Resume without --action — CLI auto-selects the only available action.
|
||||
const resume = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlSingleActionAppId,
|
||||
form_token,
|
||||
'--workflow-run-id',
|
||||
workflow_run_id,
|
||||
])
|
||||
assertExitCode(resume, 0)
|
||||
})
|
||||
|
||||
// ── New cases ────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] HITL pause in streaming mode outputs pause block (4.5.7)', async () => {
|
||||
// Spec 4.5.7: --stream mode must still emit pause block and exit 0 on HITL.
|
||||
// Streaming HITL: SSE connection can be closed unexpectedly;
|
||||
// withRetry triggers on thrown errors so we throw when exit != 0.
|
||||
const result = await withRetry(async () => {
|
||||
const r = await run(
|
||||
['run', 'app', E.hitlAppId, '--inputs', JSON.stringify({ x: 'hitl-stream' }), '--stream'],
|
||||
{ configDir: fx.configDir, timeout: 60_000 },
|
||||
)
|
||||
if (r.exitCode !== 0)
|
||||
throw new Error(`streaming HITL exited ${r.exitCode}: ${r.stderr.slice(0, 200)}`)
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 3000 })
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout + result.stderr).toMatch(/paused|pause|resume/i)
|
||||
})
|
||||
|
||||
it('[P0] resume with already-consumed form_token returns error (4.5.16)', async () => {
|
||||
// Spec 4.5.16: once a form_token has been consumed by a successful resume,
|
||||
// submitting the same token again must return an error with exit code non-zero.
|
||||
const pause = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'double-resume' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(pause, 0)
|
||||
const { form_token, workflow_run_id, actions } = assertJson<{
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
actions?: Array<{ id: string }>
|
||||
}>(pause)
|
||||
const actionId = actions?.[0]?.id ?? 'action_1'
|
||||
|
||||
// First resume — must succeed
|
||||
const first = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
form_token,
|
||||
'--workflow-run-id',
|
||||
workflow_run_id,
|
||||
'--action',
|
||||
actionId,
|
||||
])
|
||||
assertExitCode(first, 0)
|
||||
|
||||
// Second resume with the same token — must fail
|
||||
const second = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
form_token,
|
||||
'--workflow-run-id',
|
||||
workflow_run_id,
|
||||
'--action',
|
||||
actionId,
|
||||
])
|
||||
assertNonZeroExit(second)
|
||||
})
|
||||
|
||||
it('[P1] resume with --inputs-file reads form values from JSON file (4.5.12)', async () => {
|
||||
// Spec 4.5.12: --inputs-file must read form field values from a local JSON file.
|
||||
const pause = await withRetry(async () => {
|
||||
const r = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'inputs-file-test' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(r, 0)
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
const { form_token, workflow_run_id, actions } = assertJson<{
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
actions?: Array<{ id: string }>
|
||||
}>(pause)
|
||||
const actionId = actions?.[0]?.id ?? 'action_1'
|
||||
|
||||
// Write form values to a temp file
|
||||
const inputsFile = join(tmpdir(), `hitl-e2e-${Date.now()}.json`)
|
||||
await writeFile(inputsFile, JSON.stringify({ name: 'E2E-inputs-file' }))
|
||||
|
||||
const result = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
form_token,
|
||||
'--workflow-run-id',
|
||||
workflow_run_id,
|
||||
'--action',
|
||||
actionId,
|
||||
'--inputs-file',
|
||||
inputsFile,
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P1] resume with --with-history returns node history in output (4.5.14)', async () => {
|
||||
// Spec 4.5.14: --with-history must request include_state_snapshot=true and
|
||||
// return historical node events; the CLI must exit 0 with non-empty output.
|
||||
const pause = await withRetry(async () => {
|
||||
const r = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'with-history-test' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(r, 0)
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
const { form_token, workflow_run_id, actions } = assertJson<{
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
actions?: Array<{ id: string }>
|
||||
}>(pause)
|
||||
const actionId = actions?.[0]?.id ?? 'action_1'
|
||||
|
||||
const result = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
form_token,
|
||||
'--workflow-run-id',
|
||||
workflow_run_id,
|
||||
'--action',
|
||||
actionId,
|
||||
'--with-history',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length + result.stderr.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P1] resume with --stream outputs workflow completion in real-time (4.5.17)', async () => {
|
||||
// Spec 4.5.17: resume --stream must stream continuation node outputs to stdout
|
||||
// and exit 0 after workflow_finished.
|
||||
const pause = await withRetry(async () => {
|
||||
const r = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'resume-stream-test' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(r, 0)
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
const { form_token, workflow_run_id, actions } = assertJson<{
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
actions?: Array<{ id: string }>
|
||||
}>(pause)
|
||||
const actionId = actions?.[0]?.id ?? 'action_1'
|
||||
|
||||
const result = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
form_token,
|
||||
'--workflow-run-id',
|
||||
workflow_run_id,
|
||||
'--action',
|
||||
actionId,
|
||||
'--stream',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
})
|
||||
|
||||
// ── 4.5.8 display_in_ui=false — HITL pause with external channel delivery ──────
|
||||
//
|
||||
// A separate describe block so the suite can be skipped independently when
|
||||
// DIFY_E2E_HITL_EXTERNAL_APP_ID is not configured.
|
||||
|
||||
const describeExternal = optionalDescribe(Boolean(E.hitlExternalAppId))
|
||||
|
||||
describeExternal('E2E / difyctl run app — HITL display_in_ui=false (4.5.8)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
it('[P1] 4.5.8 HITL pause with display_in_ui=false: JSON contains display_in_ui=false and exit is 0', async () => {
|
||||
// Spec 4.5.8: when the Human Input node has display_in_ui=false the CLI
|
||||
// should indicate the form is delivered via an external channel.
|
||||
//
|
||||
// Current CLI behaviour (v1.0): the JSON field display_in_ui is correctly
|
||||
// set to false. The stderr hint still includes the resume command (the
|
||||
// "form delivered via external channel" hint is not yet implemented in CLI).
|
||||
// This test verifies the current actual behaviour and will need updating
|
||||
// once the CLI implements the display_in_ui=false hint distinction.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlExternalAppId,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
|
||||
const parsed = assertJson<{
|
||||
status: string
|
||||
display_in_ui: boolean
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
}>(result)
|
||||
|
||||
// display_in_ui must be false for this fixture
|
||||
expect(parsed.display_in_ui, 'display_in_ui must be false for external-channel fixture').toBe(false)
|
||||
|
||||
// status must be paused
|
||||
expect(parsed.status).toBe('paused')
|
||||
|
||||
// form_token must be present (resume is still possible even for external delivery)
|
||||
expect(parsed.form_token, 'form_token must be non-empty').toBeTruthy()
|
||||
|
||||
// stderr must contain a hint (current behaviour: hint includes resume command)
|
||||
expect(result.stderr.trim().length, 'stderr must contain a hint').toBeGreaterThan(0)
|
||||
expect(result.stderr).toMatch(/hint|resume|paused/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── 4.5.10 multiple actions — resume without --action returns error ──────────
|
||||
//
|
||||
// The existing DIFY_E2E_HITL_APP_ID fixture now has 3 actions (action_1/2/3).
|
||||
// When --action is omitted and the form has multiple actions, the CLI must
|
||||
// return "--action required: form has multiple user actions" with exit 1.
|
||||
|
||||
const describeMultiAction = optionalDescribe(Boolean(E.hitlAppId))
|
||||
|
||||
describeMultiAction('E2E / difyctl resume app — HITL multiple actions (4.5.10)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
it('[P0] 4.5.10 resume without --action when form has multiple actions returns exit 1', async () => {
|
||||
// Spec 4.5.10: when the HITL form has multiple user actions and --action is
|
||||
// not provided, the CLI must reject the command with a clear error.
|
||||
//
|
||||
// Step 1: trigger the HITL pause and extract form_token + workflow_run_id.
|
||||
const runResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'multi-action-test' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(runResult, 0)
|
||||
const { form_token, workflow_run_id, actions } = assertJson<{
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
actions: Array<{ id: string }>
|
||||
}>(runResult)
|
||||
|
||||
// Confirm the fixture has more than one action.
|
||||
expect(actions.length, 'fixture must have multiple actions for this test').toBeGreaterThan(1)
|
||||
|
||||
// Step 2: attempt to resume without --action.
|
||||
const resumeResult = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
form_token,
|
||||
'--workflow-run-id',
|
||||
workflow_run_id,
|
||||
// intentionally omit --action
|
||||
])
|
||||
|
||||
expect(resumeResult.exitCode, 'omitting --action with multiple actions must exit non-zero').toBe(1)
|
||||
expect(resumeResult.stderr).toMatch(/--action required|multiple.*action|action.*required/i)
|
||||
})
|
||||
})
|
||||
|
||||
// ── 4.5.18 2 serial HITL nodes — run → resume → resume → finished ────────────
|
||||
//
|
||||
// Prerequisite: DIFY_E2E_HITL_MULTI_NODE_APP_ID must be set.
|
||||
// The fixture app has 2 serial Human Input nodes, each with 1 action.
|
||||
// Flow: run → pause at node 1 → resume 1 → pause at node 2 → resume 2 → finished.
|
||||
|
||||
const describeMultiNode = optionalDescribe(Boolean(E.hitlMultiNodeAppId))
|
||||
|
||||
describeMultiNode('E2E / difyctl run + resume — HITL 2 serial nodes (4.5.18)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
it('[P1] 4.5.18 workflow with 2 serial HITL nodes completes after two resumes', async () => {
|
||||
// Spec 4.5.18: run → resume(node1) → resume(node2) → workflow_finished.
|
||||
// Both resumes must succeed; final output must indicate success.
|
||||
|
||||
// ── Step 1: run — pauses at first HITL node ──────────────────────────
|
||||
const pause1 = await withRetry(async () => {
|
||||
const r = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlMultiNodeAppId,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
if (r.exitCode !== 0)
|
||||
throw new Error(`run failed: ${r.stderr.slice(0, 200)}`)
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 3000 })
|
||||
|
||||
assertExitCode(pause1, 0)
|
||||
const node1 = assertJson<{
|
||||
status: string
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
actions: Array<{ id: string }>
|
||||
}>(pause1)
|
||||
expect(node1.status).toBe('paused')
|
||||
expect(node1.form_token, 'node 1 must return a form_token').toBeTruthy()
|
||||
|
||||
const actionId1 = node1.actions[0]?.id ?? 'action_1'
|
||||
|
||||
// ── Step 2: resume node 1 — workflow continues to second HITL node ───
|
||||
const pause2 = await withRetry(async () => {
|
||||
const r = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlMultiNodeAppId,
|
||||
node1.form_token,
|
||||
'--workflow-run-id',
|
||||
node1.workflow_run_id,
|
||||
'--action',
|
||||
actionId1,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
if (r.exitCode !== 0)
|
||||
throw new Error(`resume 1 failed: ${r.stderr.slice(0, 200)}`)
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 3000 })
|
||||
|
||||
assertExitCode(pause2, 0)
|
||||
const node2 = assertJson<{
|
||||
status: string
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
actions: Array<{ id: string }>
|
||||
}>(pause2)
|
||||
expect(node2.status, 'after first resume the workflow must pause again at node 2').toBe('paused')
|
||||
expect(node2.form_token, 'node 2 must return a new form_token').toBeTruthy()
|
||||
expect(node2.form_token, 'node 2 form_token must differ from node 1').not.toBe(node1.form_token)
|
||||
|
||||
const actionId2 = node2.actions[0]?.id ?? 'action_1'
|
||||
|
||||
// ── Step 3: resume node 2 — workflow finishes ─────────────────────────
|
||||
const finish = await withRetry(async () => {
|
||||
const r = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlMultiNodeAppId,
|
||||
node2.form_token,
|
||||
'--workflow-run-id',
|
||||
node2.workflow_run_id,
|
||||
'--action',
|
||||
actionId2,
|
||||
])
|
||||
if (r.exitCode !== 0)
|
||||
throw new Error(`resume 2 failed: ${r.stderr.slice(0, 200)}`)
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 3000 })
|
||||
|
||||
assertExitCode(finish, 0)
|
||||
expect(finish.stdout + finish.stderr).toMatch(/succeeded|finished/i)
|
||||
})
|
||||
})
|
||||
336
cli/test/e2e/suites/run/run-app-streaming.e2e.ts
Normal file
336
cli/test/e2e/suites/run/run-app-streaming.e2e.ts
Normal file
@ -0,0 +1,336 @@
|
||||
/**
|
||||
* 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)
|
||||
* - Cases migrated from run-app-basic.e2e.ts: exit code, stderr separation,
|
||||
* -o json envelope, unauthenticated, pipe, workflow succeeded status
|
||||
*/
|
||||
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertStderrContains,
|
||||
} from '../../helpers/assert.js'
|
||||
import { BIN, BUN, injectAuth, run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { resolveEnv } from '../../setup/env.js'
|
||||
|
||||
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
describe('E2E / difyctl run app --stream (specialisation)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Chunk timing & token order ──────────────────────────────────────────
|
||||
|
||||
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', DIFY_E2E_NO_KEYRING: '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 })
|
||||
})
|
||||
|
||||
// ── Basic streaming behaviour ───────────────────────────────────────────
|
||||
|
||||
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('[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 () => {
|
||||
// Spec: workflow streaming output includes succeeded status
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'wf-stream-val', num: 42, enum_var: 'A', paragraph: 'short text' }),
|
||||
'--stream',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data?: { status?: string } }>(result)
|
||||
expect(parsed.data?.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
// ── Error scenarios ─────────────────────────────────────────────────────
|
||||
|
||||
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] 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('[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', DIFY_E2E_NO_KEYRING: '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)
|
||||
})
|
||||
|
||||
// ── SIGINT ──────────────────────────────────────────────────────────────
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
// ── Multiple inputs in streaming mode (4.2.8) ──────────────────────────
|
||||
|
||||
it('[P1] workflow streaming with multiple inputs passes all params correctly', async () => {
|
||||
// Spec 4.2.8: multiple --inputs entries take effect simultaneously in streaming mode
|
||||
const result = await withRetry(
|
||||
() => fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'multi-stream-k1', num: 42, enum_var: 'A', paragraph: 'short text' }),
|
||||
'--stream',
|
||||
'-o',
|
||||
'json',
|
||||
]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data?: { status?: string } }>(result)
|
||||
expect(parsed.data?.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
// ── Unreachable host in streaming mode (4.2.13) ────────────────────────
|
||||
|
||||
it('[P0] streaming with unreachable host returns network error (exit code non-zero)', async () => {
|
||||
// Spec 4.2.13: unreachable host → network error, exit code non-zero
|
||||
// 127.0.0.1:19999 is a local port with nothing listening — ECONNREFUSED immediately.
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(
|
||||
['run', 'app', E.chatAppId, 'hello', '--stream'],
|
||||
{ configDir: networkTmp.configDir, timeout: 15_000 },
|
||||
)
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length, 'stderr should contain error message').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Wrong-type input in streaming mode (4.2.15) ────────────────────────
|
||||
|
||||
it('[P0] streaming with wrong-type input returns validation error (exit code non-zero)', async () => {
|
||||
// Spec 4.2.15: passing a value of the wrong type triggers server-side validation failure
|
||||
// The workflow app expects `num` to be a number; passing a string should cause a validation error.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'ok', num: 'not-a-number', enum_var: 'A', paragraph: 'short text' }),
|
||||
'--stream',
|
||||
])
|
||||
expect(result.exitCode, 'wrong-type input should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/validation|invalid|type|400|server_5xx|must be/i)
|
||||
})
|
||||
|
||||
// ── Non-existent app with positional query (4.2.16) ────────────────────
|
||||
|
||||
it('[P0] streaming with non-existent app id and query exits 1 with app-not-found error', async () => {
|
||||
// Spec 4.2.16: non-existent app id + positional query → app not found, exit code 1
|
||||
// Distinct from the earlier server-error test: this checks exit=1 precisely and the not-found message.
|
||||
const result = await fx.r(['run', 'app', 'nonexistent-app-id-404-streaming-e2e', 'hello', '--stream'])
|
||||
expect(result.exitCode, 'app not found should exit with code 1').toBe(1)
|
||||
expect(result.stderr).toMatch(/not.?found|404|does not exist/i)
|
||||
})
|
||||
|
||||
// ── SSO (dfoe_) token in streaming mode (4.2.18) ──────────────────────
|
||||
|
||||
itWithSso('[P0] streaming with SSO (dfoe_) token succeeds (exit code 0, stdout non-empty)', async () => {
|
||||
// Spec 4.2.18: dfoe_ token can invoke streaming run on an authorised app
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(ssoTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken,
|
||||
email: 'sso-e2e@example.com',
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const result = await withRetry(
|
||||
() => run(['run', 'app', E.chatAppId, 'sso-stream-test', '--stream'], {
|
||||
configDir: ssoTmp.configDir,
|
||||
}),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length, 'SSO streaming should produce output').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON error envelope for non-existent app in -o json mode (4.2.23) ─
|
||||
|
||||
it('[P1] non-existent app with --stream -o json outputs JSON error envelope on stderr', async () => {
|
||||
// Spec 4.2.23: when app does not exist and -o json is set, stderr must be a valid JSON error envelope
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
'nonexistent-app-id-json-streaming-e2e',
|
||||
'hello',
|
||||
'--stream',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
expect(result.exitCode, 'should exit non-zero').not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
})
|
||||
107
cli/vitest.e2e.config.ts
Normal file
107
cli/vitest.e2e.config.ts
Normal file
@ -0,0 +1,107 @@
|
||||
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: [],
|
||||
// DIFY_E2E_INCLUDE: comma-separated glob patterns, e.g.
|
||||
// DIFY_E2E_INCLUDE="test/e2e/suites/run/run-app-basic.e2e.ts"
|
||||
// DIFY_E2E_INCLUDE="test/e2e/suites/run/**/*.e2e.ts"
|
||||
// DIFY_E2E_INCLUDE="test/e2e/suites/discovery/**/*.e2e.ts,test/e2e/suites/run/run-app-basic.e2e.ts"
|
||||
// Deprecated alias: DIFY_E2E_SINGLE_FILE (single path only, kept for back-compat)
|
||||
include: (() => {
|
||||
const raw = process.env.DIFY_E2E_INCLUDE ?? process.env.DIFY_E2E_SINGLE_FILE
|
||||
if (raw)
|
||||
return raw.split(',').map(s => s.trim()).filter(Boolean)
|
||||
return undefined
|
||||
})()
|
||||
?? (process.env.DIFY_E2E_MODE === 'local'
|
||||
? ['test/e2e/suites/framework/help.e2e.ts', 'test/e2e/suites/agent/**/*.e2e.ts']
|
||||
: [
|
||||
// auth tests first (most others depend on a valid session)
|
||||
'test/e2e/suites/auth/login.e2e.ts',
|
||||
'test/e2e/suites/auth/status.e2e.ts',
|
||||
'test/e2e/suites/auth/use.e2e.ts',
|
||||
'test/e2e/suites/auth/whoami.e2e.ts',
|
||||
// help (no network, no auth — runs first)
|
||||
'test/e2e/suites/framework/help.e2e.ts',
|
||||
// output format (table / cross-cutting)
|
||||
'test/e2e/suites/output/**/*.e2e.ts',
|
||||
// error handling (cross-cutting error message spec)
|
||||
'test/e2e/suites/error-handling/**/*.e2e.ts',
|
||||
// framework (global flags, non-interactive, debug)
|
||||
'test/e2e/suites/framework/**/*.e2e.ts',
|
||||
// discovery (get app / describe app)
|
||||
'test/e2e/suites/discovery/**/*.e2e.ts',
|
||||
// run tests (require valid token)
|
||||
'test/e2e/suites/run/**/*.e2e.ts',
|
||||
'test/e2e/suites/agent/**/*.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: 120_000,
|
||||
hookTimeout: 30_000,
|
||||
// Retry up to 2 times on staging flakiness.
|
||||
// VITEST_RETRY env var lets CI opt-in to automatic retries for flaky server 500s.
|
||||
// Local default is 0 — per-test withRetry() handles known flaky paths more precisely.
|
||||
retry: Number(process.env.VITEST_RETRY ?? 0),
|
||||
// Run suites sequentially to avoid workspace-level conflicts on staging.
|
||||
pool: 'forks',
|
||||
fileParallelism: false,
|
||||
reporters: ['verbose'],
|
||||
},
|
||||
})
|
||||
@ -225,7 +225,6 @@ OPENAPI_ENABLED=false
|
||||
OPENAPI_CORS_ALLOW_ORIGINS=
|
||||
OPENAPI_KNOWN_CLIENT_IDS=difyctl
|
||||
OPENAPI_RATE_LIMIT_PER_TOKEN=60
|
||||
DEVICE_FLOW_APPROVE_RATE_LIMIT_PER_HOUR=10
|
||||
ENABLE_OAUTH_BEARER=false
|
||||
DSL_EXPORT_ENCRYPT_DATASET_ID=true
|
||||
DATASET_MAX_SEGMENTS_PER_REQUEST=0
|
||||
|
||||
@ -46,7 +46,6 @@ import { test } from './test/orpc.gen'
|
||||
import { trialApps } from './trial-apps/orpc.gen'
|
||||
import { trialModels } from './trial-models/orpc.gen'
|
||||
import { website } from './website/orpc.gen'
|
||||
import { workflowGenerate } from './workflow-generate/orpc.gen'
|
||||
import { workflow } from './workflow/orpc.gen'
|
||||
import { workspaces } from './workspaces/orpc.gen'
|
||||
|
||||
@ -98,6 +97,5 @@ export const contract = {
|
||||
trialModels,
|
||||
website,
|
||||
workflow,
|
||||
workflowGenerate,
|
||||
workspaces,
|
||||
}
|
||||
|
||||
@ -1,35 +0,0 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import { oc } from '@orpc/contract'
|
||||
import * as z from 'zod'
|
||||
|
||||
import { zPostWorkflowGenerateBody, zPostWorkflowGenerateResponse } from './zod.gen'
|
||||
|
||||
/**
|
||||
* Generate a Dify workflow graph from natural language
|
||||
*
|
||||
* Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.
|
||||
*
|
||||
* @deprecated
|
||||
*/
|
||||
export const post = oc
|
||||
.route({
|
||||
deprecated: true,
|
||||
description:
|
||||
'Generate a Dify workflow graph from natural language\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.',
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postWorkflowGenerate',
|
||||
path: '/workflow-generate',
|
||||
tags: ['console'],
|
||||
})
|
||||
.input(z.object({ body: zPostWorkflowGenerateBody }))
|
||||
.output(zPostWorkflowGenerateResponse)
|
||||
|
||||
export const workflowGenerate = {
|
||||
post,
|
||||
}
|
||||
|
||||
export const contract = {
|
||||
workflowGenerate,
|
||||
}
|
||||
@ -1,53 +0,0 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: `${string}://${string}/console/api` | (string & {})
|
||||
}
|
||||
|
||||
export type WorkflowGeneratePayload = {
|
||||
current_graph?: {
|
||||
[key: string]: unknown
|
||||
} | null
|
||||
ideal_output?: string
|
||||
instruction: string
|
||||
mode: 'advanced-chat' | 'workflow'
|
||||
model_config: ModelConfig
|
||||
}
|
||||
|
||||
export type ModelConfig = {
|
||||
completion_params?: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
mode: LlmMode
|
||||
name: string
|
||||
provider: string
|
||||
}
|
||||
|
||||
export type LlmMode = 'chat' | 'completion'
|
||||
|
||||
export type PostWorkflowGenerateData = {
|
||||
body: WorkflowGeneratePayload
|
||||
path?: never
|
||||
query?: never
|
||||
url: '/workflow-generate'
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateErrors = {
|
||||
400: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
402: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateError = PostWorkflowGenerateErrors[keyof PostWorkflowGenerateErrors]
|
||||
|
||||
export type PostWorkflowGenerateResponses = {
|
||||
200: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
}
|
||||
|
||||
export type PostWorkflowGenerateResponse
|
||||
= PostWorkflowGenerateResponses[keyof PostWorkflowGenerateResponses]
|
||||
@ -1,44 +0,0 @@
|
||||
// This file is auto-generated by @hey-api/openapi-ts
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
/**
|
||||
* LLMMode
|
||||
*
|
||||
* Enum class for large language model mode.
|
||||
*/
|
||||
export const zLlmMode = z.enum(['chat', 'completion'])
|
||||
|
||||
/**
|
||||
* ModelConfig
|
||||
*/
|
||||
export const zModelConfig = z.object({
|
||||
completion_params: z.record(z.string(), z.unknown()).optional(),
|
||||
mode: zLlmMode,
|
||||
name: z.string(),
|
||||
provider: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowGeneratePayload
|
||||
*
|
||||
* Payload for the cmd+k `/create` and `/refine` workflow generator endpoint.
|
||||
*
|
||||
* See ``services/workflow_generator_service.py`` for behaviour. Errors are
|
||||
* surfaced through the same envelope as ``/rule-generate`` so the frontend
|
||||
* can reuse its existing handler.
|
||||
*/
|
||||
export const zWorkflowGeneratePayload = z.object({
|
||||
current_graph: z.record(z.string(), z.unknown()).nullish(),
|
||||
ideal_output: z.string().optional().default(''),
|
||||
instruction: z.string(),
|
||||
mode: z.enum(['advanced-chat', 'workflow']),
|
||||
model_config: zModelConfig,
|
||||
})
|
||||
|
||||
export const zPostWorkflowGenerateBody = zWorkflowGeneratePayload
|
||||
|
||||
/**
|
||||
* Workflow graph generated successfully
|
||||
*/
|
||||
export const zPostWorkflowGenerateResponse = z.record(z.string(), z.unknown())
|
||||
@ -17,7 +17,6 @@ describe('Checkbox', () => {
|
||||
await expect.element(checkbox).toHaveAttribute('data-unchecked', '')
|
||||
await expect.element(checkbox).not.toHaveAttribute('data-checked')
|
||||
await expect.element(checkbox).not.toHaveAttribute('data-indeterminate')
|
||||
await expect.element(checkbox).toHaveClass('focus-visible:ring-2', 'focus-visible:ring-components-input-border-hover')
|
||||
})
|
||||
|
||||
it('should expose checked data attributes and icon styling hooks', async () => {
|
||||
|
||||
@ -9,7 +9,7 @@ const checkboxRootClassName = cn(
|
||||
'inline-flex size-4 shrink-0 touch-manipulation items-center justify-center rounded-sm shadow-xs shadow-shadow-shadow-3 transition-colors motion-reduce:transition-none',
|
||||
'border border-components-checkbox-border bg-components-checkbox-bg-unchecked text-components-checkbox-icon',
|
||||
'hover:border-components-checkbox-border-hover hover:bg-components-checkbox-bg-unchecked-hover',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-input-border-hover focus-visible:ring-offset-0',
|
||||
'focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-components-checkbox-bg focus-visible:ring-offset-0',
|
||||
'data-checked:border-transparent data-checked:bg-components-checkbox-bg data-checked:hover:bg-components-checkbox-bg-hover',
|
||||
'data-indeterminate:border-transparent data-indeterminate:bg-components-checkbox-bg data-indeterminate:hover:bg-components-checkbox-bg-hover',
|
||||
'data-disabled:cursor-not-allowed data-disabled:border-components-checkbox-border-disabled data-disabled:bg-components-checkbox-bg-disabled',
|
||||
|
||||
@ -80,17 +80,6 @@ describe('Slider', () => {
|
||||
expect(screen.container.querySelector('.outer-test')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should expose focus-visible ring styles on the thumb wrapper', async () => {
|
||||
const screen = await render(<Slider value={10} onValueChange={vi.fn()} aria-label="Value" />)
|
||||
|
||||
const thumb = screen.getByLabelText('Value').element().parentElement
|
||||
|
||||
expect(thumb).toHaveClass(
|
||||
'has-[:focus-visible]:ring-2',
|
||||
'has-[:focus-visible]:ring-components-input-border-hover',
|
||||
)
|
||||
})
|
||||
|
||||
it('should not render prehydration script tags', async () => {
|
||||
const screen = await render(<Slider value={10} onValueChange={vi.fn()} aria-label="Value" />)
|
||||
|
||||
|
||||
@ -73,7 +73,7 @@ const sliderThumbClassName = cn(
|
||||
'border-components-slider-knob-border bg-components-slider-knob shadow-sm',
|
||||
'transition-[background-color,border-color,box-shadow,opacity] motion-reduce:transition-none',
|
||||
'hover:bg-components-slider-knob-hover',
|
||||
'has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-components-input-border-hover has-[:focus-visible]:ring-offset-0',
|
||||
'focus-visible:ring-2 focus-visible:ring-components-slider-knob-border-hover focus-visible:ring-offset-0 focus-visible:outline-hidden',
|
||||
'active:shadow-md',
|
||||
'group-data-disabled/slider:border-components-slider-knob-border group-data-disabled/slider:bg-components-slider-knob-disabled group-data-disabled/slider:shadow-none',
|
||||
)
|
||||
|
||||
@ -1,65 +0,0 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import useGenGraph from '../../../app/components/workflow/workflow-generator/use-gen-graph'
|
||||
|
||||
describe('useGenGraph', () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
const makeResponse = (label: string) => ({
|
||||
graph: {
|
||||
nodes: [{ id: label, type: 'custom', position: { x: 0, y: 0 }, data: { type: 'start', title: label } }],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
message: label,
|
||||
})
|
||||
|
||||
it('starts with an empty version list and undefined current', () => {
|
||||
const { result } = renderHook(() => useGenGraph({ storageKey: 'k1' }))
|
||||
expect(result.current.versions).toEqual([])
|
||||
expect(result.current.current).toBeUndefined()
|
||||
})
|
||||
|
||||
it('appends versions and tracks the latest one as current', () => {
|
||||
const { result } = renderHook(() => useGenGraph({ storageKey: 'k2' }))
|
||||
|
||||
act(() => {
|
||||
result.current.addVersion(makeResponse('v1') as never)
|
||||
})
|
||||
expect(result.current.versions).toHaveLength(1)
|
||||
expect(result.current.current?.message).toBe('v1')
|
||||
|
||||
act(() => {
|
||||
result.current.addVersion(makeResponse('v2') as never)
|
||||
})
|
||||
expect(result.current.versions).toHaveLength(2)
|
||||
expect(result.current.current?.message).toBe('v2')
|
||||
expect(result.current.currentVersionIndex).toBe(1)
|
||||
})
|
||||
|
||||
it('allows switching back to an older version', () => {
|
||||
const { result } = renderHook(() => useGenGraph({ storageKey: 'k3' }))
|
||||
act(() => {
|
||||
result.current.addVersion(makeResponse('a') as never)
|
||||
result.current.addVersion(makeResponse('b') as never)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.setCurrentVersionIndex(0)
|
||||
})
|
||||
expect(result.current.current?.message).toBe('a')
|
||||
})
|
||||
|
||||
it('isolates state by storageKey', () => {
|
||||
const { result: r1 } = renderHook(() => useGenGraph({ storageKey: 'mode-a' }))
|
||||
const { result: r2 } = renderHook(() => useGenGraph({ storageKey: 'mode-b' }))
|
||||
|
||||
act(() => {
|
||||
r1.current.addVersion(makeResponse('only-a') as never)
|
||||
})
|
||||
|
||||
expect(r1.current.versions).toHaveLength(1)
|
||||
expect(r2.current.versions).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
@ -10,7 +10,6 @@ import Header from '@/app/components/header'
|
||||
import HeaderWrapper from '@/app/components/header/header-wrapper'
|
||||
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
|
||||
import ReadmePanel from '@/app/components/plugins/readme-panel'
|
||||
import WorkflowGeneratorMount from '@/app/components/workflow/workflow-generator/mount'
|
||||
import { AppContextProvider } from '@/context/app-context-provider'
|
||||
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
|
||||
import { ModalContextProvider } from '@/context/modal-context-provider'
|
||||
@ -41,7 +40,6 @@ const Layout = async ({ children }: { children: ReactNode }) => {
|
||||
<PartnerStack />
|
||||
<ReadmePanel />
|
||||
<GotoAnything />
|
||||
<WorkflowGeneratorMount />
|
||||
</ModalContextProvider>
|
||||
</ProviderContextProvider>
|
||||
</EventEmitterContextProvider>
|
||||
|
||||
@ -4,9 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { OAuthRegistrationAnalytics } from '../oauth-registration-analytics'
|
||||
|
||||
const { mockSendGAEvent, mockRememberRegistrationSuccess } = vi.hoisted(() => ({
|
||||
const { mockSendGAEvent, mockTrackEvent } = vi.hoisted(() => ({
|
||||
mockSendGAEvent: vi.fn(),
|
||||
mockRememberRegistrationSuccess: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/gtag', () => ({
|
||||
@ -17,8 +17,8 @@ vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../base/amplitude/registration-tracking', () => ({
|
||||
rememberRegistrationSuccess: (...args: unknown[]) => mockRememberRegistrationSuccess(...args),
|
||||
vi.mock('../base/amplitude', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
const mockUseSearchParams = vi.mocked(useSearchParams)
|
||||
@ -48,9 +48,10 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'oauth',
|
||||
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
utm_source: 'linkedin',
|
||||
slug: 'agent-launch',
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
@ -72,9 +73,8 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRememberRegistrationSuccess).toHaveBeenCalledWith({
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
method: 'oauth',
|
||||
utmInfo: null,
|
||||
})
|
||||
})
|
||||
expect(mockSendGAEvent).toHaveBeenCalledWith('user_registration_success', {
|
||||
@ -87,7 +87,7 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
it('should do nothing without the oauth registration query flag', () => {
|
||||
render(<OAuthRegistrationAnalytics />)
|
||||
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@ -100,7 +100,7 @@ describe('OAuthRegistrationAnalytics', () => {
|
||||
await waitFor(() => {
|
||||
expect(replaceStateSpy).toHaveBeenCalledWith(null, '', '/signin')
|
||||
})
|
||||
expect(mockRememberRegistrationSuccess).not.toHaveBeenCalled()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(mockSendGAEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,191 +0,0 @@
|
||||
import {
|
||||
flushRegistrationSuccess,
|
||||
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||
rememberRegistrationSuccess,
|
||||
} from '../registration-tracking'
|
||||
|
||||
const mockTrackEvent = vi.hoisted(() => vi.fn())
|
||||
|
||||
vi.mock('../utils', () => ({
|
||||
trackEvent: (...args: unknown[]) => mockTrackEvent(...args),
|
||||
}))
|
||||
|
||||
describe('registration tracking', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
window.sessionStorage.clear()
|
||||
})
|
||||
|
||||
// Captures the registration event for a later flush instead of firing it right away.
|
||||
describe('rememberRegistrationSuccess', () => {
|
||||
it('should store the base event and not track immediately when there is no utm info', () => {
|
||||
rememberRegistrationSuccess({ method: 'email' })
|
||||
|
||||
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
|
||||
eventName: 'user_registration_success',
|
||||
properties: { method: 'email' },
|
||||
})
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should store the utm event and merge utm info into properties when utm info is present', () => {
|
||||
rememberRegistrationSuccess({
|
||||
method: 'oauth',
|
||||
utmInfo: { utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
})
|
||||
|
||||
expect(JSON.parse(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)!)).toEqual({
|
||||
eventName: 'user_registration_success_with_utm',
|
||||
properties: { method: 'oauth', utm_source: 'linkedin', slug: 'agent-launch' },
|
||||
})
|
||||
})
|
||||
|
||||
it('should swallow errors when writing to sessionStorage fails', () => {
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: () => {
|
||||
throw new Error('quota exceeded')
|
||||
},
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Replays the remembered event exactly once, after the user ID has been attached.
|
||||
describe('flushRegistrationSuccess', () => {
|
||||
it('should track the remembered event and clear it from storage', () => {
|
||||
rememberRegistrationSuccess({ method: 'email', utmInfo: { utm_source: 'blog' } })
|
||||
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success_with_utm', {
|
||||
method: 'email',
|
||||
utm_source: 'blog',
|
||||
})
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should do nothing when there is no pending event', () => {
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fire the event at most once across repeated flushes', () => {
|
||||
rememberRegistrationSuccess({ method: 'oauth' })
|
||||
|
||||
flushRegistrationSuccess()
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should clear malformed pending data without tracking', () => {
|
||||
window.sessionStorage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, '{not-json')
|
||||
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should clear the pending entry without tracking when it has no event name', () => {
|
||||
window.sessionStorage.setItem(
|
||||
REGISTRATION_SUCCESS_STORAGE_KEY,
|
||||
JSON.stringify({ properties: { method: 'email' } }),
|
||||
)
|
||||
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
expect(window.sessionStorage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it('should stop without tracking when reading from sessionStorage throws', () => {
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: () => {
|
||||
throw new Error('read failed')
|
||||
},
|
||||
setItem: vi.fn(),
|
||||
removeItem: vi.fn(),
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('should still track when clearing the pending entry fails', () => {
|
||||
const pending = { eventName: 'user_registration_success', properties: { method: 'email' } }
|
||||
vi.stubGlobal('window', {
|
||||
sessionStorage: {
|
||||
getItem: () => JSON.stringify(pending),
|
||||
setItem: vi.fn(),
|
||||
removeItem: () => {
|
||||
throw new Error('remove failed')
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
flushRegistrationSuccess()
|
||||
|
||||
expect(mockTrackEvent).toHaveBeenCalledWith('user_registration_success', { method: 'email' })
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Both producers and the consumer must degrade gracefully when sessionStorage is
|
||||
// missing (SSR) or blocked (privacy mode / disabled storage).
|
||||
describe('when sessionStorage is unavailable', () => {
|
||||
it('should no-op without throwing when window is undefined', () => {
|
||||
vi.stubGlobal('window', undefined)
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'email' })).not.toThrow()
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('should no-op without throwing when accessing sessionStorage throws', () => {
|
||||
vi.stubGlobal('window', {
|
||||
get sessionStorage() {
|
||||
throw new Error('storage disabled')
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
expect(() => rememberRegistrationSuccess({ method: 'oauth' })).not.toThrow()
|
||||
expect(() => flushRegistrationSuccess()).not.toThrow()
|
||||
expect(mockTrackEvent).not.toHaveBeenCalled()
|
||||
}
|
||||
finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,88 +0,0 @@
|
||||
import { trackEvent } from './utils'
|
||||
|
||||
/**
|
||||
* Storage key for a registration success event that is waiting to be sent to
|
||||
* Amplitude until a user ID has been attached.
|
||||
*/
|
||||
export const REGISTRATION_SUCCESS_STORAGE_KEY = 'pending_registration_success_event'
|
||||
|
||||
type RegistrationMethod = 'email' | 'oauth'
|
||||
|
||||
type PendingRegistrationSuccessEvent = {
|
||||
eventName: string
|
||||
properties: Record<string, unknown>
|
||||
}
|
||||
|
||||
const getSessionStorage = (): Storage | null => {
|
||||
try {
|
||||
if (typeof window === 'undefined')
|
||||
return null
|
||||
return window.sessionStorage
|
||||
}
|
||||
catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remember a registration success event so it can be sent to Amplitude *after* the
|
||||
* user ID is attached (see `flushRegistrationSuccess`).
|
||||
*
|
||||
* Amplitude attributes events to whatever identity is active when `track` runs. At
|
||||
* registration time the client does not yet know the user ID, so firing the event
|
||||
* immediately records it under an anonymous profile. We persist the event here and
|
||||
* replay it once `setUserId` runs in the app context provider after the redirect.
|
||||
*/
|
||||
export const rememberRegistrationSuccess = (
|
||||
{ method, utmInfo }: { method: RegistrationMethod, utmInfo?: Record<string, unknown> | null },
|
||||
) => {
|
||||
const storage = getSessionStorage()
|
||||
if (!storage)
|
||||
return
|
||||
|
||||
const pending: PendingRegistrationSuccessEvent = {
|
||||
eventName: utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success',
|
||||
properties: { method, ...utmInfo },
|
||||
}
|
||||
|
||||
try {
|
||||
storage.setItem(REGISTRATION_SUCCESS_STORAGE_KEY, JSON.stringify(pending))
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a previously remembered registration success event to Amplitude.
|
||||
*
|
||||
* MUST be called after `setUserId` so the event lands on the identified user profile.
|
||||
* No-op when nothing is pending. The pending entry is removed before tracking so the
|
||||
* event fires at most once even if this runs multiple times.
|
||||
*/
|
||||
export const flushRegistrationSuccess = () => {
|
||||
const storage = getSessionStorage()
|
||||
if (!storage)
|
||||
return
|
||||
|
||||
let raw: string | null = null
|
||||
try {
|
||||
raw = storage.getItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
}
|
||||
catch {
|
||||
return
|
||||
}
|
||||
|
||||
if (!raw)
|
||||
return
|
||||
|
||||
try {
|
||||
storage.removeItem(REGISTRATION_SUCCESS_STORAGE_KEY)
|
||||
}
|
||||
catch {}
|
||||
|
||||
try {
|
||||
const pending = JSON.parse(raw) as PendingRegistrationSuccessEvent
|
||||
if (pending?.eventName)
|
||||
trackEvent(pending.eventName, pending.properties)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
@ -1,178 +0,0 @@
|
||||
import { executeCommand } from '../command-bus'
|
||||
import { createCommand } from '../create'
|
||||
|
||||
// Stub the icon imports — these are React components we don't render here.
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiChat3Line: () => null,
|
||||
RiNodeTree: () => null,
|
||||
}))
|
||||
|
||||
// search() localises its labels via getI18n(); echo the key back so the
|
||||
// filtering/payload assertions stay deterministic without a real i18n init.
|
||||
vi.mock('react-i18next', () => ({
|
||||
getI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
// We spy on the store at module scope so the `create.open` handler that
|
||||
// register() pushes into the command bus can be observed by the tests.
|
||||
const mockOpenGenerator = vi.fn()
|
||||
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
|
||||
useWorkflowGeneratorStore: {
|
||||
getState: () => ({ openGenerator: mockOpenGenerator }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Controllable app-store state — the handler reads `appDetail` to decide
|
||||
// whether to thread the current Studio app through to the generator. Mutated
|
||||
// per-test; getState() reads it lazily so updates land after the mock factory.
|
||||
const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = {
|
||||
appDetail: undefined,
|
||||
}
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: {
|
||||
getState: () => ({ appDetail: mockAppStore.appDetail }),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('/create slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe('handler metadata', () => {
|
||||
// The slash registry relies on this metadata to route /create through the
|
||||
// submenu UX rather than executing immediately.
|
||||
it('should expose submenu mode with the expected name and aliases', () => {
|
||||
expect(createCommand.mode).toBe('submenu')
|
||||
expect(createCommand.name).toBe('create')
|
||||
expect(createCommand.aliases).toEqual(['new', 'generate'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('search()', () => {
|
||||
// An empty arg list should surface every option; the submenu uses this to
|
||||
// render its initial list when the user types just `/create`.
|
||||
it('should surface both workflow and chatflow when args is empty', async () => {
|
||||
const results = await createCommand.search('')
|
||||
expect(results.map(r => r.id)).toEqual(['create-workflow', 'create-chatflow'])
|
||||
})
|
||||
|
||||
// Typing a partial keyword should narrow the list and each result should
|
||||
// carry the right command-bus payload so the navigation hook can fire it.
|
||||
it('should filter by query and attach the right command payload', async () => {
|
||||
const results = await createCommand.search('chat')
|
||||
expect(results.map(r => r.id)).toEqual(['create-chatflow'])
|
||||
expect(results[0]!.data.command).toBe('create.open')
|
||||
expect(results[0]!.data.args).toEqual({ mode: 'advanced-chat' })
|
||||
})
|
||||
|
||||
// A non-matching query returns an empty list rather than throwing, so the
|
||||
// goto-anything dialog can render an empty-state without special-casing.
|
||||
it('should return an empty list when the query matches nothing', async () => {
|
||||
const results = await createCommand.search('zzz-no-match')
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
// Labels/descriptions must be localised through i18n (ns: 'app') rather
|
||||
// than hardcoded English, so the palette renders in the user's language.
|
||||
it('should source titles and descriptions from i18n keys', async () => {
|
||||
const results = await createCommand.search('')
|
||||
expect(results[0]!.title).toBe('gotoAnything.actions.createWorkflow')
|
||||
expect(results[0]!.description).toBe('gotoAnything.actions.createWorkflowDesc')
|
||||
expect(results[1]!.title).toBe('gotoAnything.actions.createChatflow')
|
||||
expect(results[1]!.description).toBe('gotoAnything.actions.createChatflowDesc')
|
||||
})
|
||||
|
||||
// The localised label is also searchable, not just the id — a token that
|
||||
// appears only in the (mocked) title key still narrows the list, proving
|
||||
// the filter consults the translated label.
|
||||
it('should filter by the localised label, not just the id', async () => {
|
||||
const results = await createCommand.search('createChatflow')
|
||||
expect(results.map(r => r.id)).toEqual(['create-chatflow'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('register() — `create.open` command-bus handler', () => {
|
||||
// Register populates the global command bus; tests below rely on it so we
|
||||
// run it once per case and clean up via the symmetric unregister(). Reset
|
||||
// the app-store state so each case controls its own Studio context.
|
||||
beforeEach(() => {
|
||||
mockAppStore.appDetail = undefined
|
||||
createCommand.register?.({} as never)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
createCommand.unregister?.()
|
||||
})
|
||||
|
||||
// No Studio app open (e.g. /create from the apps list) — the modal opens
|
||||
// for new-app creation only, with just the requested mode.
|
||||
it('should open the generator with only the requested mode when no Studio app is open', async () => {
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
|
||||
})
|
||||
|
||||
// In-Studio create-and-apply: when a graph-based app is open and its mode
|
||||
// matches the picked mode, the handler threads id + mode through so the
|
||||
// modal can offer "Apply to current draft".
|
||||
it('should thread the current app context when a matching Studio app is open', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
mode: 'workflow',
|
||||
currentAppId: 'abc-123',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
// Mode mismatch (Workflow Studio open, but the user picked Chatflow) must
|
||||
// NOT capture currentAppId — applying a chatflow graph onto a workflow
|
||||
// draft is the dead-end we explicitly avoid, so it stays new-app only.
|
||||
it('should fall back to new-app only when the picked mode differs from the open app', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'workflow' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'advanced-chat' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'advanced-chat' })
|
||||
})
|
||||
|
||||
// Non-graph Studio apps (Chat / Agent / Completion) have no canvas to
|
||||
// apply onto, so the handler ignores them and opens new-app only.
|
||||
it('should ignore non-graph app modes and open new-app only', async () => {
|
||||
mockAppStore.appDetail = { id: 'abc-123', mode: 'chat' }
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
|
||||
})
|
||||
|
||||
// Defensive fallback: if a caller forgets to pass a mode (or passes none),
|
||||
// the handler must still open the generator with a safe default rather
|
||||
// than crashing the goto-anything dialog.
|
||||
it('should default to workflow mode when no args are passed', async () => {
|
||||
await executeCommand('create.open')
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({ mode: 'workflow' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('unregister()', () => {
|
||||
// After unregister, the bus must drop the handler so a later execute call
|
||||
// becomes a silent no-op (prevents stale references between mounts).
|
||||
it('should remove the command-bus handler so it stops firing', async () => {
|
||||
createCommand.register?.({} as never)
|
||||
createCommand.unregister?.()
|
||||
|
||||
Object.defineProperty(window, 'location', {
|
||||
writable: true,
|
||||
value: { pathname: '/apps' },
|
||||
})
|
||||
|
||||
await executeCommand('create.open', { mode: 'workflow' })
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -1,151 +0,0 @@
|
||||
import { executeCommand } from '../command-bus'
|
||||
import { refineCommand } from '../refine'
|
||||
|
||||
// Stub the icon import — it's a React component we don't render here.
|
||||
vi.mock('@remixicon/react', () => ({
|
||||
RiSparkling2Line: () => null,
|
||||
}))
|
||||
|
||||
// search() localises its title/description via getI18n(); echo the key back
|
||||
// so assertions stay deterministic without a real i18n init.
|
||||
vi.mock('react-i18next', () => ({
|
||||
getI18n: () => ({ t: (key: string) => key }),
|
||||
}))
|
||||
|
||||
// Spy on the generator store so we can observe what /refine opens it with.
|
||||
const mockOpenGenerator = vi.fn()
|
||||
vi.mock('@/app/components/workflow/workflow-generator/store', () => ({
|
||||
useWorkflowGeneratorStore: {
|
||||
getState: () => ({ openGenerator: mockOpenGenerator }),
|
||||
},
|
||||
}))
|
||||
|
||||
// Controllable app-store state — /refine reads appDetail to gate availability
|
||||
// and to pick the mode + id it refines. Mutated per-test; read lazily.
|
||||
const mockAppStore: { appDetail: { id: string, mode: string } | undefined } = {
|
||||
appDetail: undefined,
|
||||
}
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: {
|
||||
getState: () => ({ appDetail: mockAppStore.appDetail }),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('/refine slash command', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockAppStore.appDetail = undefined
|
||||
})
|
||||
|
||||
describe('handler metadata', () => {
|
||||
it('should be a direct command named refine with the expected alias', () => {
|
||||
expect(refineCommand.mode).toBe('direct')
|
||||
expect(refineCommand.name).toBe('refine')
|
||||
expect(refineCommand.aliases).toEqual(['improve'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('isAvailable()', () => {
|
||||
// /refine only makes sense inside a graph-based Studio — elsewhere there's
|
||||
// no draft graph to refine, so the command must hide itself.
|
||||
it('should be unavailable when no app is open', () => {
|
||||
expect(refineCommand.isAvailable?.()).toBe(false)
|
||||
})
|
||||
|
||||
it('should be available in a Workflow Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(true)
|
||||
})
|
||||
|
||||
it('should be available in an Advanced-Chat Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'advanced-chat' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(true)
|
||||
})
|
||||
|
||||
it('should be unavailable for non-graph apps (chat / agent / completion)', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'chat' }
|
||||
expect(refineCommand.isAvailable?.()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('execute()', () => {
|
||||
// The core behaviour: open the generator in refine intent, threading the
|
||||
// current app's id + mode so the modal fetches its draft as context.
|
||||
it('should open the generator in refine intent for a Workflow Studio', () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'workflow',
|
||||
currentAppId: 'app-1',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
it('should map advanced-chat apps to the advanced-chat generator mode', () => {
|
||||
mockAppStore.appDetail = { id: 'app-2', mode: 'advanced-chat' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'advanced-chat',
|
||||
currentAppId: 'app-2',
|
||||
currentAppMode: 'advanced-chat',
|
||||
})
|
||||
})
|
||||
|
||||
it('should be a no-op when no graph-based app is open', () => {
|
||||
mockAppStore.appDetail = { id: 'app-3', mode: 'chat' }
|
||||
|
||||
refineCommand.execute?.()
|
||||
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('search()', () => {
|
||||
// The submenu/result list renders one localised entry carrying the
|
||||
// refine.open command-bus payload.
|
||||
it('should return a single localised refine result', async () => {
|
||||
const results = await refineCommand.search('')
|
||||
expect(results).toHaveLength(1)
|
||||
expect(results[0]!.id).toBe('refine-current')
|
||||
expect(results[0]!.title).toBe('gotoAnything.actions.refineTitle')
|
||||
expect(results[0]!.data.command).toBe('refine.open')
|
||||
})
|
||||
})
|
||||
|
||||
describe('register() — `refine.open` command-bus handler', () => {
|
||||
beforeEach(() => {
|
||||
refineCommand.register?.({} as never)
|
||||
})
|
||||
afterEach(() => {
|
||||
refineCommand.unregister?.()
|
||||
})
|
||||
|
||||
it('should open the generator via the command bus too', async () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
|
||||
await executeCommand('refine.open', {})
|
||||
|
||||
expect(mockOpenGenerator).toHaveBeenCalledWith({
|
||||
intent: 'refine',
|
||||
mode: 'workflow',
|
||||
currentAppId: 'app-1',
|
||||
currentAppMode: 'workflow',
|
||||
})
|
||||
})
|
||||
|
||||
it('should stop firing after unregister', async () => {
|
||||
mockAppStore.appDetail = { id: 'app-1', mode: 'workflow' }
|
||||
refineCommand.unregister?.()
|
||||
|
||||
await executeCommand('refine.open', {})
|
||||
|
||||
expect(mockOpenGenerator).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@ -106,8 +106,6 @@ describe('SlashCommandProvider', () => {
|
||||
'account',
|
||||
'zen',
|
||||
'go',
|
||||
'create',
|
||||
'refine',
|
||||
])
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'theme' }), { setTheme: mockSetTheme })
|
||||
expect(mockRegister).toHaveBeenCalledWith(expect.objectContaining({ name: 'language' }), { setLocale: mockSetLocale })
|
||||
@ -123,8 +121,6 @@ describe('SlashCommandProvider', () => {
|
||||
'account',
|
||||
'zen',
|
||||
'go',
|
||||
'create',
|
||||
'refine',
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,119 +0,0 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
|
||||
import { RiChat3Line, RiNodeTree } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
type CreateOption = {
|
||||
id: string
|
||||
/** i18n key (ns: 'app') for the option's display label. */
|
||||
titleKey: string
|
||||
/** i18n key (ns: 'app') for the option's one-line description. */
|
||||
descKey: string
|
||||
mode: WorkflowGeneratorMode
|
||||
icon: React.ComponentType<{ className?: string }>
|
||||
}
|
||||
|
||||
// `as const` keeps titleKey/descKey as literal types so the typed `i18n.t`
|
||||
// accepts them as known keys; `satisfies` still validates the shape.
|
||||
const OPTIONS = [
|
||||
{
|
||||
id: 'workflow',
|
||||
titleKey: 'gotoAnything.actions.createWorkflow',
|
||||
descKey: 'gotoAnything.actions.createWorkflowDesc',
|
||||
mode: 'workflow',
|
||||
icon: RiNodeTree,
|
||||
},
|
||||
{
|
||||
id: 'chatflow',
|
||||
titleKey: 'gotoAnything.actions.createChatflow',
|
||||
descKey: 'gotoAnything.actions.createChatflowDesc',
|
||||
mode: 'advanced-chat',
|
||||
icon: RiChat3Line,
|
||||
},
|
||||
] as const satisfies readonly CreateOption[]
|
||||
|
||||
/**
|
||||
* `/create` command — generate a Workflow or Chatflow app from a
|
||||
* natural-language description.
|
||||
*
|
||||
* The user-picked mode is passed through to the generator modal explicitly
|
||||
* rather than sniffed from the URL, which avoids the mode-mismatch dead-end
|
||||
* the URL-sniffing approach used to produce.
|
||||
*
|
||||
* When triggered from inside a graph-based Studio (Workflow / Advanced-Chat)
|
||||
* whose app mode matches the picked mode, it threads the current app (id +
|
||||
* mode) through so the modal offers "Apply to current draft" — this is the
|
||||
* in-Studio create-and-apply journey that replaced the old toolbar button.
|
||||
* With no Studio app open, or when the picked mode differs from the open
|
||||
* app's mode, it falls back to new-app creation only.
|
||||
*/
|
||||
export const createCommand: SlashCommandHandler = {
|
||||
name: 'create',
|
||||
aliases: ['new', 'generate'],
|
||||
// Fallback only — the palette localises the root row via the slashKeyMap in
|
||||
// command-selector.tsx (gotoAnything.actions.createCategoryDesc).
|
||||
description: 'Create an AI-generated workflow or chatflow',
|
||||
mode: 'submenu',
|
||||
|
||||
async search(args: string, locale?: string) {
|
||||
const i18n = getI18n()
|
||||
const tr = (key: (typeof OPTIONS)[number]['titleKey' | 'descKey']) =>
|
||||
i18n.t(key, { ns: 'app', lng: locale })
|
||||
const query = args.trim().toLowerCase()
|
||||
const filtered = OPTIONS.filter(
|
||||
opt => !query || opt.id.includes(query) || tr(opt.titleKey).toLowerCase().includes(query),
|
||||
)
|
||||
return filtered.map(opt => ({
|
||||
id: `create-${opt.id}`,
|
||||
title: tr(opt.titleKey),
|
||||
description: tr(opt.descKey),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<opt.icon className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'create.open', args: { mode: opt.mode } },
|
||||
}))
|
||||
},
|
||||
|
||||
register() {
|
||||
registerCommands({
|
||||
'create.open': async (args) => {
|
||||
const mode: WorkflowGeneratorMode = (args?.mode ?? 'workflow') as WorkflowGeneratorMode
|
||||
|
||||
// If a graph-based Studio app is open and its mode matches the picked
|
||||
// mode, thread it through so the modal can offer "Apply to current
|
||||
// draft". A mode mismatch (or no app open) falls back to new-app only,
|
||||
// mirroring the precondition the modal uses for canApplyToCurrent.
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const currentAppMode: WorkflowGeneratorMode | null
|
||||
= appDetail?.mode === AppModeEnum.WORKFLOW
|
||||
? 'workflow'
|
||||
: appDetail?.mode === AppModeEnum.ADVANCED_CHAT
|
||||
? 'advanced-chat'
|
||||
: null
|
||||
|
||||
if (appDetail && currentAppMode === mode) {
|
||||
useWorkflowGeneratorStore.getState().openGenerator({
|
||||
mode,
|
||||
currentAppId: appDetail.id,
|
||||
currentAppMode,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
useWorkflowGeneratorStore.getState().openGenerator({ mode })
|
||||
},
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['create.open'])
|
||||
},
|
||||
}
|
||||
@ -1,91 +0,0 @@
|
||||
import type { SlashCommandHandler } from './types'
|
||||
import type { WorkflowGeneratorMode } from '@/app/components/workflow/workflow-generator/types'
|
||||
import { RiSparkling2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { getI18n } from 'react-i18next'
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { useWorkflowGeneratorStore } from '@/app/components/workflow/workflow-generator/store'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { registerCommands, unregisterCommands } from './command-bus'
|
||||
|
||||
/**
|
||||
* Map the open app's mode to a generator mode, or null when the current app
|
||||
* isn't a graph-based Studio (Chat / Agent / Completion have no canvas to
|
||||
* refine). This is the single source of truth for both the availability gate
|
||||
* and the mode we open the generator with.
|
||||
*/
|
||||
const currentStudioMode = (): WorkflowGeneratorMode | null => {
|
||||
const appMode = useAppStore.getState().appDetail?.mode
|
||||
if (appMode === AppModeEnum.WORKFLOW)
|
||||
return 'workflow'
|
||||
if (appMode === AppModeEnum.ADVANCED_CHAT)
|
||||
return 'advanced-chat'
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the generator in `refine` intent for the current Studio. The modal
|
||||
* fetches the current draft graph and sends it as context so the LLM amends
|
||||
* what's on the canvas instead of starting from scratch. No-op when there's
|
||||
* no graph-based app open (the command is gated by `isAvailable`, but the
|
||||
* guard keeps a stray command-bus call safe).
|
||||
*/
|
||||
const openRefineGenerator = () => {
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const mode = currentStudioMode()
|
||||
if (!appDetail || !mode)
|
||||
return
|
||||
useWorkflowGeneratorStore.getState().openGenerator({
|
||||
intent: 'refine',
|
||||
mode,
|
||||
currentAppId: appDetail.id,
|
||||
currentAppMode: mode,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* `/refine` command — refine the CURRENT Workflow / Chatflow draft graph from
|
||||
* a natural-language change description. Only available inside a graph-based
|
||||
* Studio; the mode is taken from the open app (no submenu to pick), and the
|
||||
* result always applies back to the current draft.
|
||||
*/
|
||||
export const refineCommand: SlashCommandHandler = {
|
||||
name: 'refine',
|
||||
aliases: ['improve'],
|
||||
// Fallback only — the palette localises the root row via the slashKeyMap in
|
||||
// command-selector.tsx (gotoAnything.actions.refineCategoryDesc).
|
||||
description: 'Refine the current workflow or chatflow graph',
|
||||
mode: 'direct',
|
||||
|
||||
// Only surface inside a Workflow / Advanced-Chat Studio — elsewhere there's
|
||||
// no draft graph to refine.
|
||||
isAvailable: () => currentStudioMode() !== null,
|
||||
|
||||
execute: openRefineGenerator,
|
||||
|
||||
async search(_args: string, locale?: string) {
|
||||
const i18n = getI18n()
|
||||
return [{
|
||||
id: 'refine-current',
|
||||
title: i18n.t('gotoAnything.actions.refineTitle', { ns: 'app', lng: locale }),
|
||||
description: i18n.t('gotoAnything.actions.refineDesc', { ns: 'app', lng: locale }),
|
||||
type: 'command' as const,
|
||||
icon: (
|
||||
<div className="flex h-6 w-6 items-center justify-center rounded-md border-[0.5px] border-divider-regular bg-components-panel-bg">
|
||||
<RiSparkling2Line className="size-4 text-text-tertiary" />
|
||||
</div>
|
||||
),
|
||||
data: { command: 'refine.open', args: {} },
|
||||
}]
|
||||
},
|
||||
|
||||
register() {
|
||||
registerCommands({
|
||||
'refine.open': async () => openRefineGenerator(),
|
||||
})
|
||||
},
|
||||
|
||||
unregister() {
|
||||
unregisterCommands(['refine.open'])
|
||||
},
|
||||
}
|
||||
@ -7,12 +7,10 @@ import { setLocaleOnClient } from '@/i18n-config'
|
||||
import { accountCommand } from './account'
|
||||
import { executeCommand } from './command-bus'
|
||||
import { communityCommand } from './community'
|
||||
import { createCommand } from './create'
|
||||
import { docsCommand } from './docs'
|
||||
import { forumCommand } from './forum'
|
||||
import { goCommand } from './go'
|
||||
import { languageCommand } from './language'
|
||||
import { refineCommand } from './refine'
|
||||
import { slashCommandRegistry } from './registry'
|
||||
import { themeCommand } from './theme'
|
||||
import { zenCommand } from './zen'
|
||||
@ -52,8 +50,6 @@ const registerSlashCommands = (deps: Record<string, any>) => {
|
||||
slashCommandRegistry.register(accountCommand, {})
|
||||
slashCommandRegistry.register(zenCommand, {})
|
||||
slashCommandRegistry.register(goCommand, {})
|
||||
slashCommandRegistry.register(createCommand, {})
|
||||
slashCommandRegistry.register(refineCommand, {})
|
||||
}
|
||||
|
||||
const unregisterSlashCommands = () => {
|
||||
@ -66,8 +62,6 @@ const unregisterSlashCommands = () => {
|
||||
slashCommandRegistry.unregister('account')
|
||||
slashCommandRegistry.unregister('zen')
|
||||
slashCommandRegistry.unregister('go')
|
||||
slashCommandRegistry.unregister('create')
|
||||
slashCommandRegistry.unregister('refine')
|
||||
}
|
||||
|
||||
export const SlashCommandProvider = () => {
|
||||
|
||||
@ -109,8 +109,6 @@ const CommandSelector: FC<Props> = ({ actions, onCommandSelect, searchFilter, co
|
||||
? (
|
||||
(() => {
|
||||
const slashKeyMap = {
|
||||
'/create': 'gotoAnything.actions.createCategoryDesc',
|
||||
'/refine': 'gotoAnything.actions.refineCategoryDesc',
|
||||
'/theme': 'gotoAnything.actions.themeCategoryDesc',
|
||||
'/language': 'gotoAnything.actions.languageChangeDesc',
|
||||
'/account': 'gotoAnything.actions.accountDesc',
|
||||
|
||||
@ -4,7 +4,7 @@ import Cookies from 'js-cookie'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useSearchParams } from '@/next/navigation'
|
||||
import { sendGAEvent } from '@/utils/gtag'
|
||||
import { rememberRegistrationSuccess } from './base/amplitude/registration-tracking'
|
||||
import { trackEvent } from './base/amplitude'
|
||||
|
||||
const OAUTH_NEW_USER_PARAM = 'oauth_new_user'
|
||||
|
||||
@ -48,10 +48,10 @@ export function OAuthRegistrationAnalytics() {
|
||||
|
||||
const eventName = utmInfo ? 'user_registration_success_with_utm' : 'user_registration_success'
|
||||
|
||||
// Defer the Amplitude event until the user ID is attached. It is flushed in
|
||||
// AppContextProvider after setUserId runs. Firing it here would record it under an
|
||||
// anonymous Amplitude profile (no user ID set yet).
|
||||
rememberRegistrationSuccess({ method: 'oauth', utmInfo })
|
||||
trackEvent(eventName, {
|
||||
method: 'oauth',
|
||||
...utmInfo,
|
||||
})
|
||||
|
||||
sendGAEvent(eventName, {
|
||||
method: 'oauth',
|
||||
|
||||
@ -77,12 +77,12 @@ const SearchBox = ({
|
||||
{
|
||||
!usedInMarketplace && (
|
||||
<>
|
||||
<div className="flex h-8 min-w-0 grow items-center pr-2 pl-2">
|
||||
<div className="flex grow items-center py-[7px] pr-3 pl-2">
|
||||
<RiSearchLine className="size-4 text-components-input-text-placeholder" />
|
||||
<input
|
||||
autoFocus={autoFocus}
|
||||
className={cn(
|
||||
'mr-1 ml-1.5 inline-block min-w-0 grow appearance-none truncate bg-transparent system-sm-regular text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder',
|
||||
'mr-1 ml-1.5 inline-block grow appearance-none bg-transparent system-sm-regular text-components-input-text-filled outline-hidden placeholder:text-components-input-text-placeholder',
|
||||
search && 'mr-2',
|
||||
)}
|
||||
value={search}
|
||||
@ -94,7 +94,6 @@ const SearchBox = ({
|
||||
{
|
||||
search && (
|
||||
<ActionButton
|
||||
size="xs"
|
||||
onClick={() => onSearchChange('')}
|
||||
className="shrink-0"
|
||||
>
|
||||
|
||||
@ -17,7 +17,6 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@langgenius/dify-ui/popover'
|
||||
import { useDebounce } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import {
|
||||
memo,
|
||||
@ -90,7 +89,6 @@ function NodeSelector({
|
||||
const { t } = useTranslation()
|
||||
const nodes = useNodes()
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const debouncedSearchText = useDebounce(searchText, { wait: 500 })
|
||||
const [tags, setTags] = useState<string[]>([])
|
||||
const [localOpen, setLocalOpen] = useState(false)
|
||||
// Exclude nodes explicitly ignored (such as the node currently being edited) when checking canvas state.
|
||||
@ -161,9 +159,6 @@ function NodeSelector({
|
||||
const handleActiveTabChange = useCallback((newActiveTab: TabsEnum) => {
|
||||
setActiveTab(newActiveTab)
|
||||
}, [setActiveTab])
|
||||
const filterSearchText = activeTab === TabsEnum.Start || activeTab === TabsEnum.Tools
|
||||
? debouncedSearchText
|
||||
: searchText
|
||||
|
||||
const searchPlaceholder = useMemo(() => {
|
||||
if (activeTab === TabsEnum.Start)
|
||||
@ -228,7 +223,7 @@ function NodeSelector({
|
||||
alignOffset={alignOffset}
|
||||
popupClassName="border-none bg-transparent shadow-none"
|
||||
>
|
||||
<div className={cn('w-[400px] min-w-0 overflow-hidden rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg', popupClassName)}>
|
||||
<div className={`rounded-lg border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg ${popupClassName}`}>
|
||||
<Tabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
@ -245,8 +240,7 @@ function NodeSelector({
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={searchPlaceholder}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
inputClassName="min-w-0 grow"
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)}
|
||||
{activeTab === TabsEnum.Blocks && (
|
||||
@ -279,14 +273,13 @@ function NodeSelector({
|
||||
tags={tags}
|
||||
onTagsChange={setTags}
|
||||
placeholder={t('searchTools', { ns: 'plugin' })!}
|
||||
wrapperClassName="w-full min-w-0"
|
||||
inputClassName="min-w-0 grow"
|
||||
inputClassName="grow"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
onSelect={handleSelect}
|
||||
searchText={filterSearchText}
|
||||
searchText={searchText}
|
||||
tags={tags}
|
||||
availableBlocksTypes={availableBlocksTypes}
|
||||
noBlocks={noBlocks}
|
||||
|
||||
@ -219,10 +219,10 @@ const Tabs: FC<TabsProps> = ({
|
||||
}, [normalizedBuiltInTools, normalizedCustomTools, normalizedMcpTools, normalizedWorkflowTools, workflowStore])
|
||||
|
||||
return (
|
||||
<div className="w-full min-w-0" onClick={e => e.stopPropagation()}>
|
||||
<div onClick={e => e.stopPropagation()}>
|
||||
{
|
||||
!noBlocks && (
|
||||
<div className="relative flex w-full min-w-0 bg-background-section-burn pt-1 pl-1">
|
||||
<div className="relative flex bg-background-section-burn pt-1 pl-1">
|
||||
{
|
||||
tabs.map(tab => (
|
||||
<TabHeaderItem
|
||||
|
||||
@ -81,10 +81,9 @@ describe('useAvailableBlocks', () => {
|
||||
expect(result.current.availableNextBlocks).toEqual([])
|
||||
})
|
||||
|
||||
it('should return available nodes for End node', () => {
|
||||
it('should return empty array for End node', () => {
|
||||
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.End), { hooksStoreProps })
|
||||
expect(result.current.availableNextBlocks.length).toBeGreaterThan(0)
|
||||
expect(result.current.availableNextBlocks).toContain(BlockEnum.Code)
|
||||
expect(result.current.availableNextBlocks).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty array for LoopEnd node', () => {
|
||||
@ -144,11 +143,10 @@ describe('useAvailableBlocks', () => {
|
||||
expect(blocks.availablePrevBlocks).toEqual([])
|
||||
})
|
||||
|
||||
it('should return empty nextBlocks for LoopEnd/KnowledgeBase and available nodes for End', () => {
|
||||
it('should return empty nextBlocks for End/LoopEnd/KnowledgeBase', () => {
|
||||
const { result } = renderWorkflowHook(() => useAvailableBlocks(BlockEnum.LLM), { hooksStoreProps })
|
||||
|
||||
expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks.length).toBeGreaterThan(0)
|
||||
expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks).toContain(BlockEnum.Code)
|
||||
expect(result.current.getAvailableBlocks(BlockEnum.End).availableNextBlocks).toEqual([])
|
||||
expect(result.current.getAvailableBlocks(BlockEnum.LoopEnd).availableNextBlocks).toEqual([])
|
||||
expect(result.current.getAvailableBlocks(BlockEnum.KnowledgeBase).availableNextBlocks).toEqual([])
|
||||
})
|
||||
|
||||
@ -372,41 +372,6 @@ describe('useChecklist', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should detect duplicate output variables across end nodes', () => {
|
||||
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
|
||||
const firstEndNode = createNode({
|
||||
id: 'end-1',
|
||||
data: {
|
||||
type: BlockEnum.End,
|
||||
title: 'Output 1',
|
||||
outputs: [{ variable: 'workflow_id', value_selector: ['sys', 'workflow_id'] }],
|
||||
},
|
||||
})
|
||||
const secondEndNode = createNode({
|
||||
id: 'end-2',
|
||||
data: {
|
||||
type: BlockEnum.End,
|
||||
title: 'Output 2',
|
||||
outputs: [{ variable: 'workflow_id', value_selector: ['sys', 'workflow_id'] }],
|
||||
},
|
||||
})
|
||||
|
||||
const edges = [
|
||||
createEdge({ source: 'start', target: 'end-1' }),
|
||||
createEdge({ source: 'start', target: 'end-2' }),
|
||||
]
|
||||
|
||||
const { result } = renderWorkflowHook(
|
||||
() => useChecklist([startNode, firstEndNode, secondEndNode], edges),
|
||||
)
|
||||
|
||||
const firstWarning = result.current.find((item: ChecklistItem) => item.id === 'end-1')
|
||||
const secondWarning = result.current.find((item: ChecklistItem) => item.id === 'end-2')
|
||||
|
||||
expect(firstWarning?.errorMessages.some(message => message.includes('duplicateOutputVariable'))).toBe(true)
|
||||
expect(secondWarning?.errorMessages.some(message => message.includes('duplicateOutputVariable'))).toBe(true)
|
||||
})
|
||||
|
||||
it('should sync checklist items to the workflow store without render phase update warnings', async () => {
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
try {
|
||||
|
||||
@ -30,7 +30,7 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, inContainer?: boolean)
|
||||
return availableNodesType
|
||||
}, [availableNodesType, nodeType])
|
||||
const availableNextBlocks = useMemo(() => {
|
||||
if (!nodeType || nodeType === BlockEnum.LoopEnd || nodeType === BlockEnum.KnowledgeBase)
|
||||
if (!nodeType || nodeType === BlockEnum.End || nodeType === BlockEnum.LoopEnd || nodeType === BlockEnum.KnowledgeBase)
|
||||
return []
|
||||
|
||||
return availableNodesType
|
||||
@ -42,7 +42,7 @@ export const useAvailableBlocks = (nodeType?: BlockEnum, inContainer?: boolean)
|
||||
availablePrevBlocks = []
|
||||
|
||||
let availableNextBlocks = availableNodesType
|
||||
if (!nodeType || nodeType === BlockEnum.LoopEnd || nodeType === BlockEnum.KnowledgeBase)
|
||||
if (!nodeType || nodeType === BlockEnum.End || nodeType === BlockEnum.LoopEnd || nodeType === BlockEnum.KnowledgeBase)
|
||||
availableNextBlocks = []
|
||||
|
||||
return {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user