Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42c3163f90 | ||
|
|
e100e96279 | ||
|
|
a03d5b8ed3 | ||
|
|
ef50e117da | ||
|
|
98e5387356 | ||
|
|
a4cb47d6cd | ||
|
|
1a9a1e821f | ||
|
|
c3e05045bb | ||
|
|
b6420ec6de | ||
|
|
616d4c6fdb | ||
|
|
167058ec51 | ||
|
|
0a27e38170 | ||
|
|
5ab3526845 | ||
|
|
59639ca9b2 | ||
|
|
66b8c42a25 | ||
|
+3 |
449d8c7768 |
@@ -4,7 +4,6 @@ import urllib.parse
|
||||
import httpx
|
||||
from flask import current_app, redirect, request
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import Unauthorized
|
||||
|
||||
from configs import dify_config
|
||||
@@ -180,8 +179,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
|
||||
account: Account | None = Account.get_by_openid(provider, user_info.id)
|
||||
|
||||
if not account:
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
account = AccountService.get_account_by_email_with_case_fallback(user_info.email, session=session)
|
||||
account = AccountService.get_account_by_email_with_case_fallback(user_info.email)
|
||||
|
||||
return account
|
||||
|
||||
|
||||
@@ -607,19 +607,15 @@ class PublishedRagPipelineApi(Resource):
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
current_user, _ = current_account_with_tenant()
|
||||
rag_pipeline_service = RagPipelineService()
|
||||
with Session(db.engine) as session:
|
||||
pipeline = session.merge(pipeline)
|
||||
workflow = rag_pipeline_service.publish_workflow(
|
||||
session=session,
|
||||
pipeline=pipeline,
|
||||
account=current_user,
|
||||
)
|
||||
pipeline.is_published = True
|
||||
pipeline.workflow_id = workflow.id
|
||||
session.add(pipeline)
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
session.commit()
|
||||
workflow = rag_pipeline_service.publish_workflow(
|
||||
session=db.session, # type: ignore[reportArgumentType,arg-type]
|
||||
pipeline=pipeline,
|
||||
account=current_user,
|
||||
)
|
||||
pipeline.is_published = True
|
||||
pipeline.workflow_id = workflow.id
|
||||
db.session.commit()
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
|
||||
return {
|
||||
"result": "success",
|
||||
|
||||
@@ -16,12 +16,14 @@ api = ExternalApi(
|
||||
inner_api_ns = Namespace("inner_api", description="Internal API operations", path="/")
|
||||
|
||||
from . import mail as _mail
|
||||
from .app import dsl as _app_dsl
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
|
||||
__all__ = [
|
||||
"_app_dsl",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_workspace",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""Inner API endpoints for app DSL import/export.
|
||||
|
||||
Called by the enterprise admin-api service. Import requires ``creator_email``
|
||||
to attribute the created app; workspace/membership validation is done by the
|
||||
Go admin-api caller.
|
||||
"""
|
||||
|
||||
from flask import request
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from extensions.ext_database import db
|
||||
from models import Account, App
|
||||
from models.account import AccountStatus
|
||||
from services.app_dsl_service import AppDslService, ImportMode, ImportStatus
|
||||
|
||||
|
||||
class InnerAppDSLImportPayload(BaseModel):
|
||||
yaml_content: str = Field(description="YAML DSL content")
|
||||
creator_email: str = Field(description="Email of the workspace member who will own the imported app")
|
||||
name: str | None = Field(default=None, description="Override app name from DSL")
|
||||
description: str | None = Field(default=None, description="Override app description from DSL")
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InnerAppDSLImportPayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspaces/<string:workspace_id>/dsl/import")
|
||||
class EnterpriseAppDSLImport(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc("enterprise_app_dsl_import")
|
||||
@inner_api_ns.expect(inner_api_ns.models[InnerAppDSLImportPayload.__name__])
|
||||
@inner_api_ns.doc(
|
||||
responses={
|
||||
200: "Import completed",
|
||||
202: "Import pending (DSL version mismatch requires confirmation)",
|
||||
400: "Import failed (business error)",
|
||||
404: "Creator account not found or inactive",
|
||||
}
|
||||
)
|
||||
def post(self, workspace_id: str):
|
||||
"""Import a DSL into a workspace on behalf of a specified creator."""
|
||||
args = InnerAppDSLImportPayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
account = _get_active_account(args.creator_email)
|
||||
if account is None:
|
||||
return {"message": f"account '{args.creator_email}' not found or inactive"}, 404
|
||||
|
||||
account.set_tenant_id(workspace_id)
|
||||
|
||||
with Session(db.engine) as session:
|
||||
dsl_service = AppDslService(session)
|
||||
result = dsl_service.import_app(
|
||||
account=account,
|
||||
import_mode=ImportMode.YAML_CONTENT,
|
||||
yaml_content=args.yaml_content,
|
||||
name=args.name,
|
||||
description=args.description,
|
||||
)
|
||||
session.commit()
|
||||
|
||||
if result.status == ImportStatus.FAILED:
|
||||
return result.model_dump(mode="json"), 400
|
||||
if result.status == ImportStatus.PENDING:
|
||||
return result.model_dump(mode="json"), 202
|
||||
return result.model_dump(mode="json"), 200
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/apps/<string:app_id>/dsl")
|
||||
class EnterpriseAppDSLExport(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_app_dsl_export",
|
||||
responses={
|
||||
200: "Export successful",
|
||||
404: "App not found",
|
||||
},
|
||||
)
|
||||
def get(self, app_id: str):
|
||||
"""Export an app's DSL as YAML."""
|
||||
include_secret = request.args.get("include_secret", "false").lower() == "true"
|
||||
|
||||
app_model = db.session.query(App).filter_by(id=app_id).first()
|
||||
if not app_model:
|
||||
return {"message": "app not found"}, 404
|
||||
|
||||
data = AppDslService.export_dsl(
|
||||
app_model=app_model,
|
||||
include_secret=include_secret,
|
||||
)
|
||||
|
||||
return {"data": data}, 200
|
||||
|
||||
|
||||
def _get_active_account(email: str) -> Account | None:
|
||||
"""Look up an active account by email.
|
||||
|
||||
Workspace membership is already validated by the Go admin-api caller.
|
||||
"""
|
||||
account = db.session.query(Account).filter_by(email=email).first()
|
||||
if account is None or account.status != AccountStatus.ACTIVE:
|
||||
return None
|
||||
return account
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Generator, Iterable, Sequence
|
||||
@@ -7,6 +8,8 @@ from typing import TYPE_CHECKING, Any, Union
|
||||
|
||||
import httpx
|
||||
import qdrant_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from flask import current_app
|
||||
from httpx import DigestAuth
|
||||
from pydantic import BaseModel
|
||||
@@ -288,26 +291,27 @@ class TidbOnQdrantVector(BaseVector):
|
||||
if not ids:
|
||||
return
|
||||
|
||||
try:
|
||||
filter = models.Filter(
|
||||
must=[
|
||||
models.FieldCondition(
|
||||
key="metadata.doc_id",
|
||||
match=models.MatchAny(any=ids),
|
||||
),
|
||||
],
|
||||
)
|
||||
self._client.delete(
|
||||
collection_name=self._collection_name,
|
||||
points_selector=FilterSelector(filter=filter),
|
||||
)
|
||||
except UnexpectedResponse as e:
|
||||
# Collection does not exist, so return
|
||||
if e.status_code == 404:
|
||||
return
|
||||
# Some other error occurred, so re-raise the exception
|
||||
else:
|
||||
raise e
|
||||
batch_size = 1000
|
||||
for i in range(0, len(ids), batch_size):
|
||||
batch = ids[i : i + batch_size]
|
||||
|
||||
try:
|
||||
filter = models.Filter(
|
||||
must=[
|
||||
models.FieldCondition(
|
||||
key="metadata.doc_id",
|
||||
match=models.MatchAny(any=batch),
|
||||
),
|
||||
],
|
||||
)
|
||||
self._client.delete(
|
||||
collection_name=self._collection_name,
|
||||
points_selector=FilterSelector(filter=filter),
|
||||
)
|
||||
except UnexpectedResponse as e:
|
||||
# Collection does not exist, so return
|
||||
if e.status_code != 404:
|
||||
raise e
|
||||
|
||||
def text_exists(self, id: str) -> bool:
|
||||
all_collection_name = []
|
||||
@@ -416,13 +420,16 @@ class TidbOnQdrantVector(BaseVector):
|
||||
|
||||
class TidbOnQdrantVectorFactory(AbstractVectorFactory):
|
||||
def init_vector(self, dataset: Dataset, attributes: list, embeddings: Embeddings) -> TidbOnQdrantVector:
|
||||
logger.info("init_vector: tenant_id=%s, dataset_id=%s", dataset.tenant_id, dataset.id)
|
||||
stmt = select(TidbAuthBinding).where(TidbAuthBinding.tenant_id == dataset.tenant_id)
|
||||
tidb_auth_binding = db.session.scalars(stmt).one_or_none()
|
||||
if not tidb_auth_binding:
|
||||
logger.info("No existing TidbAuthBinding for tenant %s, acquiring lock", dataset.tenant_id)
|
||||
with redis_client.lock("create_tidb_serverless_cluster_lock", timeout=900):
|
||||
stmt = select(TidbAuthBinding).where(TidbAuthBinding.tenant_id == dataset.tenant_id)
|
||||
tidb_auth_binding = db.session.scalars(stmt).one_or_none()
|
||||
if tidb_auth_binding:
|
||||
logger.info("Found binding after lock: cluster_id=%s", tidb_auth_binding.cluster_id)
|
||||
TIDB_ON_QDRANT_API_KEY = f"{tidb_auth_binding.account}:{tidb_auth_binding.password}"
|
||||
|
||||
else:
|
||||
@@ -433,11 +440,18 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory):
|
||||
.one_or_none()
|
||||
)
|
||||
if idle_tidb_auth_binding:
|
||||
logger.info(
|
||||
"Assigning idle cluster %s to tenant %s",
|
||||
idle_tidb_auth_binding.cluster_id,
|
||||
dataset.tenant_id,
|
||||
)
|
||||
idle_tidb_auth_binding.active = True
|
||||
idle_tidb_auth_binding.tenant_id = dataset.tenant_id
|
||||
db.session.commit()
|
||||
tidb_auth_binding = idle_tidb_auth_binding
|
||||
TIDB_ON_QDRANT_API_KEY = f"{idle_tidb_auth_binding.account}:{idle_tidb_auth_binding.password}"
|
||||
else:
|
||||
logger.info("No idle clusters available, creating new cluster for tenant %s", dataset.tenant_id)
|
||||
new_cluster = TidbService.create_tidb_serverless_cluster(
|
||||
dify_config.TIDB_PROJECT_ID or "",
|
||||
dify_config.TIDB_API_URL or "",
|
||||
@@ -446,21 +460,39 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory):
|
||||
dify_config.TIDB_PRIVATE_KEY or "",
|
||||
dify_config.TIDB_REGION or "",
|
||||
)
|
||||
logger.info(
|
||||
"New cluster created: cluster_id=%s, qdrant_endpoint=%s",
|
||||
new_cluster["cluster_id"],
|
||||
new_cluster.get("qdrant_endpoint"),
|
||||
)
|
||||
new_tidb_auth_binding = TidbAuthBinding(
|
||||
cluster_id=new_cluster["cluster_id"],
|
||||
cluster_name=new_cluster["cluster_name"],
|
||||
account=new_cluster["account"],
|
||||
password=new_cluster["password"],
|
||||
qdrant_endpoint=new_cluster.get("qdrant_endpoint"),
|
||||
tenant_id=dataset.tenant_id,
|
||||
active=True,
|
||||
status=TidbAuthBindingStatus.ACTIVE,
|
||||
)
|
||||
db.session.add(new_tidb_auth_binding)
|
||||
db.session.commit()
|
||||
tidb_auth_binding = new_tidb_auth_binding
|
||||
TIDB_ON_QDRANT_API_KEY = f"{new_tidb_auth_binding.account}:{new_tidb_auth_binding.password}"
|
||||
else:
|
||||
logger.info("Existing binding found: cluster_id=%s", tidb_auth_binding.cluster_id)
|
||||
TIDB_ON_QDRANT_API_KEY = f"{tidb_auth_binding.account}:{tidb_auth_binding.password}"
|
||||
|
||||
qdrant_url = (
|
||||
(tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None) or dify_config.TIDB_ON_QDRANT_URL or ""
|
||||
)
|
||||
logger.info(
|
||||
"Using qdrant endpoint: %s (from_binding=%s, fallback_global=%s)",
|
||||
qdrant_url,
|
||||
tidb_auth_binding.qdrant_endpoint if tidb_auth_binding else None,
|
||||
dify_config.TIDB_ON_QDRANT_URL,
|
||||
)
|
||||
|
||||
if dataset.index_struct_dict:
|
||||
class_prefix: str = dataset.index_struct_dict["vector_store"]["class_prefix"]
|
||||
collection_name = class_prefix
|
||||
@@ -475,7 +507,7 @@ class TidbOnQdrantVectorFactory(AbstractVectorFactory):
|
||||
collection_name=collection_name,
|
||||
group_id=dataset.id,
|
||||
config=TidbOnQdrantConfig(
|
||||
endpoint=dify_config.TIDB_ON_QDRANT_URL or "",
|
||||
endpoint=qdrant_url,
|
||||
api_key=TIDB_ON_QDRANT_API_KEY,
|
||||
root_path=str(config.root_path),
|
||||
timeout=dify_config.TIDB_ON_QDRANT_CLIENT_TIMEOUT,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
from collections.abc import Sequence
|
||||
@@ -11,8 +12,50 @@ from extensions.ext_redis import redis_client
|
||||
from models.dataset import TidbAuthBinding
|
||||
from models.enums import TidbAuthBindingStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TidbService:
|
||||
@staticmethod
|
||||
def extract_qdrant_endpoint(cluster_response: dict) -> str | None:
|
||||
"""Extract the qdrant endpoint URL from a Get Cluster API response.
|
||||
|
||||
Reads ``endpoints.public.host`` (e.g. ``gateway01.xx.tidbcloud.com``),
|
||||
prepends ``qdrant-`` and wraps it as an ``https://`` URL.
|
||||
"""
|
||||
endpoints = cluster_response.get("endpoints") or {}
|
||||
public = endpoints.get("public") or {}
|
||||
host = public.get("host")
|
||||
if host:
|
||||
return f"https://qdrant-{host}"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def fetch_qdrant_endpoint(api_url: str, public_key: str, private_key: str, cluster_id: str) -> str | None:
|
||||
"""Call Get Cluster API and extract the qdrant endpoint.
|
||||
|
||||
Use ``extract_qdrant_endpoint`` instead when you already have
|
||||
the cluster response to avoid a redundant API call.
|
||||
"""
|
||||
try:
|
||||
logger.info("Fetching qdrant endpoint for cluster %s", cluster_id)
|
||||
cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id)
|
||||
if not cluster_response:
|
||||
logger.warning("Empty response from Get Cluster API for cluster %s", cluster_id)
|
||||
return None
|
||||
qdrant_url = TidbService.extract_qdrant_endpoint(cluster_response)
|
||||
if qdrant_url:
|
||||
logger.info("Resolved qdrant endpoint for cluster %s: %s", cluster_id, qdrant_url)
|
||||
return qdrant_url
|
||||
logger.warning(
|
||||
"No endpoints.public.host found for cluster %s, response keys: %s",
|
||||
cluster_id,
|
||||
list(cluster_response.keys()),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch qdrant endpoint for cluster %s", cluster_id)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def create_tidb_serverless_cluster(
|
||||
project_id: str, api_url: str, iam_url: str, public_key: str, private_key: str, region: str
|
||||
@@ -50,26 +93,45 @@ class TidbService:
|
||||
"rootPassword": password,
|
||||
}
|
||||
|
||||
logger.info("Creating TiDB serverless cluster: display_name=%s, region=%s", display_name, region)
|
||||
response = httpx.post(f"{api_url}/clusters", json=cluster_data, auth=DigestAuth(public_key, private_key))
|
||||
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
cluster_id = response_data["clusterId"]
|
||||
logger.info("Cluster created, cluster_id=%s, waiting for ACTIVE state", cluster_id)
|
||||
retry_count = 0
|
||||
max_retries = 30
|
||||
while retry_count < max_retries:
|
||||
cluster_response = TidbService.get_tidb_serverless_cluster(api_url, public_key, private_key, cluster_id)
|
||||
if cluster_response["state"] == "ACTIVE":
|
||||
user_prefix = cluster_response["userPrefix"]
|
||||
qdrant_endpoint = TidbService.extract_qdrant_endpoint(cluster_response)
|
||||
logger.info(
|
||||
"Cluster %s is ACTIVE, user_prefix=%s, qdrant_endpoint=%s",
|
||||
cluster_id,
|
||||
user_prefix,
|
||||
qdrant_endpoint,
|
||||
)
|
||||
return {
|
||||
"cluster_id": cluster_id,
|
||||
"cluster_name": display_name,
|
||||
"account": f"{user_prefix}.root",
|
||||
"password": password,
|
||||
"qdrant_endpoint": qdrant_endpoint,
|
||||
}
|
||||
time.sleep(30) # wait 30 seconds before retrying
|
||||
logger.info(
|
||||
"Cluster %s state=%s, retry %d/%d",
|
||||
cluster_id,
|
||||
cluster_response["state"],
|
||||
retry_count + 1,
|
||||
max_retries,
|
||||
)
|
||||
time.sleep(30)
|
||||
retry_count += 1
|
||||
logger.error("Cluster %s did not become ACTIVE after %d retries", cluster_id, max_retries)
|
||||
else:
|
||||
logger.error("Failed to create cluster: status=%d, body=%s", response.status_code, response.text)
|
||||
response.raise_for_status()
|
||||
|
||||
@staticmethod
|
||||
@@ -171,8 +233,20 @@ class TidbService:
|
||||
userPrefix = item["userPrefix"]
|
||||
if state == "ACTIVE" and len(userPrefix) > 0:
|
||||
cluster_info = tidb_serverless_list_map[item["clusterId"]]
|
||||
cluster_info.status = TidbAuthBindingStatus.ACTIVE
|
||||
cluster_info.account = f"{userPrefix}.root"
|
||||
if not cluster_info.qdrant_endpoint:
|
||||
cluster_info.qdrant_endpoint = TidbService.extract_qdrant_endpoint(
|
||||
item
|
||||
) or TidbService.fetch_qdrant_endpoint(
|
||||
api_url, public_key, private_key, item["clusterId"]
|
||||
)
|
||||
if cluster_info.qdrant_endpoint:
|
||||
cluster_info.status = TidbAuthBindingStatus.ACTIVE
|
||||
else:
|
||||
logger.warning(
|
||||
"Cluster %s is ACTIVE but qdrant endpoint is not ready; will retry later",
|
||||
item["clusterId"],
|
||||
)
|
||||
db.session.add(cluster_info)
|
||||
db.session.commit()
|
||||
else:
|
||||
@@ -230,19 +304,29 @@ class TidbService:
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
cluster_infos = []
|
||||
logger.info("Batch created %d clusters", len(response_data.get("clusters", [])))
|
||||
for item in response_data["clusters"]:
|
||||
cache_key = f"tidb_serverless_cluster_password:{item['displayName']}"
|
||||
cached_password = redis_client.get(cache_key)
|
||||
if not cached_password:
|
||||
logger.warning("No cached password for cluster %s, skipping", item["displayName"])
|
||||
continue
|
||||
qdrant_endpoint = TidbService.fetch_qdrant_endpoint(api_url, public_key, private_key, item["clusterId"])
|
||||
logger.info(
|
||||
"Batch cluster %s: qdrant_endpoint=%s",
|
||||
item["clusterId"],
|
||||
qdrant_endpoint,
|
||||
)
|
||||
cluster_info = {
|
||||
"cluster_id": item["clusterId"],
|
||||
"cluster_name": item["displayName"],
|
||||
"account": "root",
|
||||
"password": cached_password.decode("utf-8"),
|
||||
"qdrant_endpoint": qdrant_endpoint,
|
||||
}
|
||||
cluster_infos.append(cluster_info)
|
||||
return cluster_infos
|
||||
else:
|
||||
logger.error("Batch create failed: status=%d, body=%s", response.status_code, response.text)
|
||||
response.raise_for_status()
|
||||
return []
|
||||
|
||||
@@ -1,56 +1,17 @@
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum, auto
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaCharge:
|
||||
"""
|
||||
Result of a quota consumption operation.
|
||||
|
||||
Attributes:
|
||||
success: Whether the quota charge succeeded
|
||||
charge_id: UUID for refund, or None if failed/disabled
|
||||
"""
|
||||
|
||||
success: bool
|
||||
charge_id: str | None
|
||||
_quota_type: "QuotaType"
|
||||
|
||||
def refund(self) -> None:
|
||||
"""
|
||||
Refund this quota charge.
|
||||
|
||||
Safe to call even if charge failed or was disabled.
|
||||
This method guarantees no exceptions will be raised.
|
||||
"""
|
||||
if self.charge_id:
|
||||
self._quota_type.refund(self.charge_id)
|
||||
logger.info("Refunded quota for %s with charge_id: %s", self._quota_type.value, self.charge_id)
|
||||
|
||||
|
||||
class QuotaType(StrEnum):
|
||||
"""
|
||||
Supported quota types for tenant feature usage.
|
||||
|
||||
Add additional types here whenever new billable features become available.
|
||||
"""
|
||||
|
||||
# Trigger execution quota
|
||||
TRIGGER = auto()
|
||||
|
||||
# Workflow execution quota
|
||||
WORKFLOW = auto()
|
||||
|
||||
UNLIMITED = auto()
|
||||
|
||||
@property
|
||||
def billing_key(self) -> str:
|
||||
"""
|
||||
Get the billing key for the feature.
|
||||
"""
|
||||
match self:
|
||||
case QuotaType.TRIGGER:
|
||||
return "trigger_event"
|
||||
@@ -58,152 +19,3 @@ class QuotaType(StrEnum):
|
||||
return "api_rate_limit"
|
||||
case _:
|
||||
raise ValueError(f"Invalid quota type: {self}")
|
||||
|
||||
def consume(self, tenant_id: str, amount: int = 1) -> QuotaCharge:
|
||||
"""
|
||||
Consume quota for the feature.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
amount: Amount to consume (default: 1)
|
||||
|
||||
Returns:
|
||||
QuotaCharge with success status and charge_id for refund
|
||||
|
||||
Raises:
|
||||
QuotaExceededError: When quota is insufficient
|
||||
"""
|
||||
from configs import dify_config
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import QuotaExceededError
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
logger.debug("Billing disabled, allowing request for %s", tenant_id)
|
||||
return QuotaCharge(success=True, charge_id=None, _quota_type=self)
|
||||
|
||||
logger.info("Consuming %d %s quota for tenant %s", amount, self.value, tenant_id)
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount to consume must be greater than 0")
|
||||
|
||||
try:
|
||||
response = BillingService.update_tenant_feature_plan_usage(tenant_id, self.billing_key, delta=amount)
|
||||
|
||||
if response.get("result") != "success":
|
||||
logger.warning(
|
||||
"Failed to consume quota for %s, feature %s details: %s",
|
||||
tenant_id,
|
||||
self.value,
|
||||
response.get("detail"),
|
||||
)
|
||||
raise QuotaExceededError(feature=self.value, tenant_id=tenant_id, required=amount)
|
||||
|
||||
charge_id = response.get("history_id")
|
||||
logger.debug(
|
||||
"Successfully consumed %d %s quota for tenant %s, charge_id: %s",
|
||||
amount,
|
||||
self.value,
|
||||
tenant_id,
|
||||
charge_id,
|
||||
)
|
||||
return QuotaCharge(success=True, charge_id=charge_id, _quota_type=self)
|
||||
|
||||
except QuotaExceededError:
|
||||
raise
|
||||
except Exception:
|
||||
# fail-safe: allow request on billing errors
|
||||
logger.exception("Failed to consume quota for %s, feature %s", tenant_id, self.value)
|
||||
return unlimited()
|
||||
|
||||
def check(self, tenant_id: str, amount: int = 1) -> bool:
|
||||
"""
|
||||
Check if tenant has sufficient quota without consuming.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
amount: Amount to check (default: 1)
|
||||
|
||||
Returns:
|
||||
True if quota is sufficient, False otherwise
|
||||
"""
|
||||
from configs import dify_config
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return True
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount to check must be greater than 0")
|
||||
|
||||
try:
|
||||
remaining = self.get_remaining(tenant_id)
|
||||
return remaining >= amount if remaining != -1 else True
|
||||
except Exception:
|
||||
logger.exception("Failed to check quota for %s, feature %s", tenant_id, self.value)
|
||||
# fail-safe: allow request on billing errors
|
||||
return True
|
||||
|
||||
def refund(self, charge_id: str) -> None:
|
||||
"""
|
||||
Refund quota using charge_id from consume().
|
||||
|
||||
This method guarantees no exceptions will be raised.
|
||||
All errors are logged but silently handled.
|
||||
|
||||
Args:
|
||||
charge_id: The UUID returned from consume()
|
||||
"""
|
||||
try:
|
||||
from configs import dify_config
|
||||
from services.billing_service import BillingService
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return
|
||||
|
||||
if not charge_id:
|
||||
logger.warning("Cannot refund: charge_id is empty")
|
||||
return
|
||||
|
||||
logger.info("Refunding %s quota with charge_id: %s", self.value, charge_id)
|
||||
|
||||
response = BillingService.refund_tenant_feature_plan_usage(charge_id)
|
||||
if response.get("result") == "success":
|
||||
logger.debug("Successfully refunded %s quota, charge_id: %s", self.value, charge_id)
|
||||
else:
|
||||
logger.warning("Refund failed for charge_id: %s", charge_id)
|
||||
|
||||
except Exception:
|
||||
# Catch ALL exceptions - refund must never fail
|
||||
logger.exception("Failed to refund quota for charge_id: %s", charge_id)
|
||||
# Don't raise - refund is best-effort and must be silent
|
||||
|
||||
def get_remaining(self, tenant_id: str) -> int:
|
||||
"""
|
||||
Get remaining quota for the tenant.
|
||||
|
||||
Args:
|
||||
tenant_id: The tenant identifier
|
||||
|
||||
Returns:
|
||||
Remaining quota amount
|
||||
"""
|
||||
from services.billing_service import BillingService
|
||||
|
||||
try:
|
||||
usage_info = BillingService.get_tenant_feature_plan_usage(tenant_id, self.billing_key)
|
||||
# Assuming the API returns a dict with 'remaining' or 'limit' and 'used'
|
||||
if isinstance(usage_info, dict):
|
||||
return usage_info.get("remaining", 0)
|
||||
# If it returns a simple number, treat it as remaining
|
||||
return int(usage_info) if usage_info else 0
|
||||
except Exception:
|
||||
logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, self.value)
|
||||
return -1
|
||||
|
||||
|
||||
def unlimited() -> QuotaCharge:
|
||||
"""
|
||||
Return a quota charge for unlimited quota.
|
||||
|
||||
This is useful for features that are not subject to quota limits, such as the UNLIMITED quota type.
|
||||
"""
|
||||
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
"""add qdrant_endpoint to tidb_auth_bindings
|
||||
|
||||
Revision ID: 8574b23a38fd
|
||||
Revises: 6b5f9f8b1a2c
|
||||
Create Date: 2026-04-14 15:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "8574b23a38fd"
|
||||
down_revision = "6b5f9f8b1a2c"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
with op.batch_alter_table("tidb_auth_bindings", schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column("qdrant_endpoint", sa.String(length=512), nullable=True))
|
||||
|
||||
|
||||
def downgrade():
|
||||
with op.batch_alter_table("tidb_auth_bindings", schema=None) as batch_op:
|
||||
batch_op.drop_column("qdrant_endpoint")
|
||||
@@ -1250,6 +1250,7 @@ class TidbAuthBinding(TypeBase):
|
||||
)
|
||||
account: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
qdrant_endpoint: Mapped[str | None] = mapped_column(String(512), nullable=True, default=None)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
|
||||
@@ -113,6 +113,7 @@ class DataSourceType(StrEnum):
|
||||
WEBSITE_CRAWL = "website_crawl"
|
||||
LOCAL_FILE = "local_file"
|
||||
ONLINE_DOCUMENT = "online_document"
|
||||
ONLINE_DRIVE = "online_drive"
|
||||
|
||||
|
||||
class ProcessRuleMode(StrEnum):
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
||||
@@ -57,6 +57,7 @@ def create_clusters(batch_size):
|
||||
cluster_name=new_cluster["cluster_name"],
|
||||
account=new_cluster["account"],
|
||||
password=new_cluster["password"],
|
||||
qdrant_endpoint=new_cluster.get("qdrant_endpoint"),
|
||||
active=False,
|
||||
status=TidbAuthBindingStatus.CREATING,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
import threading
|
||||
import uuid
|
||||
from collections.abc import Callable, Generator, Mapping
|
||||
from typing import TYPE_CHECKING, Any, Union
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from configs import dify_config
|
||||
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
|
||||
@@ -18,12 +18,13 @@ from core.app.features.rate_limiting import RateLimit
|
||||
from core.app.features.rate_limiting.rate_limit import rate_limit_context
|
||||
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
|
||||
from core.db import session_factory
|
||||
from enums.quota_type import QuotaType, unlimited
|
||||
from enums.quota_type import QuotaType
|
||||
from extensions.otel import AppGenerateHandler, trace_span
|
||||
from models.model import Account, App, AppMode, EndUser
|
||||
from models.workflow import Workflow, WorkflowRun
|
||||
from services.errors.app import QuotaExceededError, WorkflowIdFormatError, WorkflowNotFoundError
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.workflow_service import WorkflowService
|
||||
from tasks.app_generate.workflow_execute_task import AppExecutionParams, workflow_based_app_execution_task
|
||||
|
||||
@@ -88,7 +89,7 @@ class AppGenerateService:
|
||||
def generate(
|
||||
cls,
|
||||
app_model: App,
|
||||
user: Union[Account, EndUser],
|
||||
user: Account | EndUser,
|
||||
args: Mapping[str, Any],
|
||||
invoke_from: InvokeFrom,
|
||||
streaming: bool = True,
|
||||
@@ -106,7 +107,7 @@ class AppGenerateService:
|
||||
quota_charge = unlimited()
|
||||
if dify_config.BILLING_ENABLED:
|
||||
try:
|
||||
quota_charge = QuotaType.WORKFLOW.consume(app_model.tenant_id)
|
||||
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, app_model.tenant_id)
|
||||
except QuotaExceededError:
|
||||
raise InvokeRateLimitError(f"Workflow execution quota limit reached for tenant {app_model.tenant_id}")
|
||||
|
||||
@@ -116,139 +117,150 @@ class AppGenerateService:
|
||||
request_id = RateLimit.gen_request_key()
|
||||
try:
|
||||
request_id = rate_limit.enter(request_id)
|
||||
if app_model.mode == AppMode.COMPLETION:
|
||||
return rate_limit.generate(
|
||||
CompletionAppGenerator.convert_to_event_stream(
|
||||
CompletionAppGenerator().generate(
|
||||
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
|
||||
),
|
||||
),
|
||||
request_id=request_id,
|
||||
)
|
||||
elif app_model.mode == AppMode.AGENT_CHAT or app_model.is_agent:
|
||||
return rate_limit.generate(
|
||||
AgentChatAppGenerator.convert_to_event_stream(
|
||||
AgentChatAppGenerator().generate(
|
||||
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
|
||||
),
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
elif app_model.mode == AppMode.CHAT:
|
||||
return rate_limit.generate(
|
||||
ChatAppGenerator.convert_to_event_stream(
|
||||
ChatAppGenerator().generate(
|
||||
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
|
||||
),
|
||||
),
|
||||
request_id=request_id,
|
||||
)
|
||||
elif app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
workflow_id = args.get("workflow_id")
|
||||
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
|
||||
|
||||
if streaming:
|
||||
# Streaming mode: subscribe to SSE and enqueue the execution on first subscriber
|
||||
with rate_limit_context(rate_limit, request_id):
|
||||
payload = AppExecutionParams.new(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=True,
|
||||
call_depth=0,
|
||||
)
|
||||
payload_json = payload.model_dump_json()
|
||||
|
||||
def on_subscribe():
|
||||
workflow_based_app_execution_task.delay(payload_json)
|
||||
|
||||
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
|
||||
generator = AdvancedChatAppGenerator()
|
||||
quota_charge.commit()
|
||||
effective_mode = (
|
||||
AppMode.AGENT_CHAT if app_model.is_agent and app_model.mode != AppMode.AGENT_CHAT else app_model.mode
|
||||
)
|
||||
match effective_mode:
|
||||
case AppMode.COMPLETION:
|
||||
return rate_limit.generate(
|
||||
generator.convert_to_event_stream(
|
||||
generator.retrieve_events(
|
||||
AppMode.ADVANCED_CHAT,
|
||||
payload.workflow_run_id,
|
||||
on_subscribe=on_subscribe,
|
||||
CompletionAppGenerator.convert_to_event_stream(
|
||||
CompletionAppGenerator().generate(
|
||||
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
|
||||
),
|
||||
),
|
||||
request_id=request_id,
|
||||
)
|
||||
else:
|
||||
# Blocking mode: run synchronously and return JSON instead of SSE
|
||||
# Keep behaviour consistent with WORKFLOW blocking branch.
|
||||
advanced_generator = AdvancedChatAppGenerator()
|
||||
case AppMode.AGENT_CHAT:
|
||||
return rate_limit.generate(
|
||||
advanced_generator.convert_to_event_stream(
|
||||
advanced_generator.generate(
|
||||
AgentChatAppGenerator.convert_to_event_stream(
|
||||
AgentChatAppGenerator().generate(
|
||||
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
|
||||
),
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
case AppMode.CHAT:
|
||||
return rate_limit.generate(
|
||||
ChatAppGenerator.convert_to_event_stream(
|
||||
ChatAppGenerator().generate(
|
||||
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming
|
||||
),
|
||||
),
|
||||
request_id=request_id,
|
||||
)
|
||||
case AppMode.ADVANCED_CHAT:
|
||||
workflow_id = args.get("workflow_id")
|
||||
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
|
||||
|
||||
if streaming:
|
||||
# Streaming mode: subscribe to SSE and enqueue the execution on first subscriber
|
||||
with rate_limit_context(rate_limit, request_id):
|
||||
payload = AppExecutionParams.new(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=True,
|
||||
call_depth=0,
|
||||
workflow_run_id=str(uuid.uuid4()),
|
||||
streaming=False,
|
||||
)
|
||||
),
|
||||
request_id=request_id,
|
||||
)
|
||||
elif app_model.mode == AppMode.WORKFLOW:
|
||||
workflow_id = args.get("workflow_id")
|
||||
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
|
||||
if streaming:
|
||||
with rate_limit_context(rate_limit, request_id):
|
||||
payload = AppExecutionParams.new(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=True,
|
||||
call_depth=0,
|
||||
root_node_id=root_node_id,
|
||||
workflow_run_id=str(uuid.uuid4()),
|
||||
payload_json = payload.model_dump_json()
|
||||
|
||||
def on_subscribe():
|
||||
workflow_based_app_execution_task.delay(payload_json)
|
||||
|
||||
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
|
||||
generator = AdvancedChatAppGenerator()
|
||||
return rate_limit.generate(
|
||||
generator.convert_to_event_stream(
|
||||
generator.retrieve_events(
|
||||
AppMode.ADVANCED_CHAT,
|
||||
payload.workflow_run_id,
|
||||
on_subscribe=on_subscribe,
|
||||
),
|
||||
),
|
||||
request_id=request_id,
|
||||
)
|
||||
payload_json = payload.model_dump_json()
|
||||
else:
|
||||
# Blocking mode: run synchronously and return JSON instead of SSE
|
||||
# Keep behaviour consistent with WORKFLOW blocking branch.
|
||||
pause_config = PauseStateLayerConfig(
|
||||
session_factory=session_factory.get_session_maker(),
|
||||
state_owner_user_id=workflow.created_by,
|
||||
)
|
||||
advanced_generator = AdvancedChatAppGenerator()
|
||||
return rate_limit.generate(
|
||||
advanced_generator.convert_to_event_stream(
|
||||
advanced_generator.generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
workflow_run_id=str(uuid.uuid4()),
|
||||
streaming=False,
|
||||
pause_state_config=pause_config,
|
||||
)
|
||||
),
|
||||
request_id=request_id,
|
||||
)
|
||||
case AppMode.WORKFLOW:
|
||||
workflow_id = args.get("workflow_id")
|
||||
workflow = cls._get_workflow(app_model, invoke_from, workflow_id)
|
||||
if streaming:
|
||||
with rate_limit_context(rate_limit, request_id):
|
||||
payload = AppExecutionParams.new(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=True,
|
||||
call_depth=0,
|
||||
root_node_id=root_node_id,
|
||||
workflow_run_id=str(uuid.uuid4()),
|
||||
)
|
||||
payload_json = payload.model_dump_json()
|
||||
|
||||
def on_subscribe():
|
||||
workflow_based_app_execution_task.delay(payload_json)
|
||||
def on_subscribe():
|
||||
workflow_based_app_execution_task.delay(payload_json)
|
||||
|
||||
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
|
||||
on_subscribe = cls._build_streaming_task_on_subscribe(on_subscribe)
|
||||
return rate_limit.generate(
|
||||
WorkflowAppGenerator.convert_to_event_stream(
|
||||
MessageBasedAppGenerator.retrieve_events(
|
||||
AppMode.WORKFLOW,
|
||||
payload.workflow_run_id,
|
||||
on_subscribe=on_subscribe,
|
||||
),
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
|
||||
pause_config = PauseStateLayerConfig(
|
||||
session_factory=session_factory.get_session_maker(),
|
||||
state_owner_user_id=workflow.created_by,
|
||||
)
|
||||
return rate_limit.generate(
|
||||
WorkflowAppGenerator.convert_to_event_stream(
|
||||
MessageBasedAppGenerator.retrieve_events(
|
||||
AppMode.WORKFLOW,
|
||||
payload.workflow_run_id,
|
||||
on_subscribe=on_subscribe,
|
||||
WorkflowAppGenerator().generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=False,
|
||||
root_node_id=root_node_id,
|
||||
call_depth=0,
|
||||
pause_state_config=pause_config,
|
||||
),
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
|
||||
pause_config = PauseStateLayerConfig(
|
||||
session_factory=session_factory.get_session_maker(),
|
||||
state_owner_user_id=workflow.created_by,
|
||||
)
|
||||
return rate_limit.generate(
|
||||
WorkflowAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
user=user,
|
||||
args=args,
|
||||
invoke_from=invoke_from,
|
||||
streaming=False,
|
||||
root_node_id=root_node_id,
|
||||
call_depth=0,
|
||||
pause_state_config=pause_config,
|
||||
),
|
||||
),
|
||||
request_id,
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
case _:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
except Exception:
|
||||
quota_charge.refund()
|
||||
rate_limit.exit(request_id)
|
||||
@@ -280,53 +292,83 @@ class AppGenerateService:
|
||||
|
||||
@classmethod
|
||||
def generate_single_iteration(cls, app_model: App, user: Account, node_id: str, args: Any, streaming: bool = True):
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
|
||||
return AdvancedChatAppGenerator.convert_to_event_stream(
|
||||
AdvancedChatAppGenerator().single_iteration_generate(
|
||||
app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
|
||||
match app_model.mode:
|
||||
case AppMode.COMPLETION | AppMode.CHAT | AppMode.AGENT_CHAT:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
case AppMode.ADVANCED_CHAT:
|
||||
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
|
||||
return AdvancedChatAppGenerator.convert_to_event_stream(
|
||||
AdvancedChatAppGenerator().single_iteration_generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
node_id=node_id,
|
||||
user=user,
|
||||
args=args,
|
||||
streaming=streaming,
|
||||
)
|
||||
)
|
||||
)
|
||||
elif app_model.mode == AppMode.WORKFLOW:
|
||||
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
|
||||
return AdvancedChatAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_iteration_generate(
|
||||
app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
|
||||
case AppMode.WORKFLOW:
|
||||
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
|
||||
return AdvancedChatAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_iteration_generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
node_id=node_id,
|
||||
user=user,
|
||||
args=args,
|
||||
streaming=streaming,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
case AppMode.CHANNEL | AppMode.RAG_PIPELINE:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
case _:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
|
||||
@classmethod
|
||||
def generate_single_loop(
|
||||
cls, app_model: App, user: Account, node_id: str, args: LoopNodeRunPayload, streaming: bool = True
|
||||
):
|
||||
if app_model.mode == AppMode.ADVANCED_CHAT:
|
||||
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
|
||||
return AdvancedChatAppGenerator.convert_to_event_stream(
|
||||
AdvancedChatAppGenerator().single_loop_generate(
|
||||
app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
|
||||
match app_model.mode:
|
||||
case AppMode.COMPLETION | AppMode.CHAT | AppMode.AGENT_CHAT:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
case AppMode.ADVANCED_CHAT:
|
||||
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
|
||||
return AdvancedChatAppGenerator.convert_to_event_stream(
|
||||
AdvancedChatAppGenerator().single_loop_generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
node_id=node_id,
|
||||
user=user,
|
||||
args=args,
|
||||
streaming=streaming,
|
||||
)
|
||||
)
|
||||
)
|
||||
elif app_model.mode == AppMode.WORKFLOW:
|
||||
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
|
||||
return AdvancedChatAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_loop_generate(
|
||||
app_model=app_model, workflow=workflow, node_id=node_id, user=user, args=args, streaming=streaming
|
||||
case AppMode.WORKFLOW:
|
||||
workflow = cls._get_workflow(app_model, InvokeFrom.DEBUGGER)
|
||||
return AdvancedChatAppGenerator.convert_to_event_stream(
|
||||
WorkflowAppGenerator().single_loop_generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
node_id=node_id,
|
||||
user=user,
|
||||
args=args,
|
||||
streaming=streaming,
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
case AppMode.CHANNEL | AppMode.RAG_PIPELINE:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
case _:
|
||||
raise ValueError(f"Invalid app mode {app_model.mode}")
|
||||
|
||||
@classmethod
|
||||
def generate_more_like_this(
|
||||
cls,
|
||||
app_model: App,
|
||||
user: Union[Account, EndUser],
|
||||
user: Account | EndUser,
|
||||
message_id: str,
|
||||
invoke_from: InvokeFrom,
|
||||
streaming: bool = True,
|
||||
) -> Union[Mapping, Generator]:
|
||||
) -> Mapping | Generator:
|
||||
"""
|
||||
Generate more like this
|
||||
:param app_model: app model
|
||||
|
||||
@@ -22,6 +22,7 @@ from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict
|
||||
from models.workflow import Workflow
|
||||
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
|
||||
from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData
|
||||
from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority
|
||||
from services.workflow_service import WorkflowService
|
||||
@@ -88,7 +89,10 @@ class AsyncWorkflowService:
|
||||
raise WorkflowNotFoundError(f"App not found: {trigger_data.app_id}")
|
||||
|
||||
# 2. Get workflow
|
||||
workflow = cls._get_workflow(workflow_service, app_model, trigger_data.workflow_id)
|
||||
workflow = cls._get_workflow(workflow_service, app_model, trigger_data.workflow_id, session=session)
|
||||
|
||||
# commit read only session before starting the billig rpc call
|
||||
session.commit()
|
||||
|
||||
# 3. Get dispatcher based on tenant subscription
|
||||
dispatcher = dispatcher_manager.get_dispatcher(trigger_data.tenant_id)
|
||||
@@ -131,9 +135,10 @@ class AsyncWorkflowService:
|
||||
trigger_log = trigger_log_repo.create(trigger_log)
|
||||
session.commit()
|
||||
|
||||
# 7. Check and consume quota
|
||||
# 7. Reserve quota (commit after successful dispatch)
|
||||
quota_charge = unlimited()
|
||||
try:
|
||||
QuotaType.WORKFLOW.consume(trigger_data.tenant_id)
|
||||
quota_charge = QuotaService.reserve(QuotaType.WORKFLOW, trigger_data.tenant_id)
|
||||
except QuotaExceededError as e:
|
||||
# Update trigger log status
|
||||
trigger_log.status = WorkflowTriggerStatus.RATE_LIMITED
|
||||
@@ -153,13 +158,18 @@ class AsyncWorkflowService:
|
||||
# 9. Dispatch to appropriate queue
|
||||
task_data_dict = task_data.model_dump(mode="json")
|
||||
|
||||
task: AsyncResult[Any] | None = None
|
||||
if queue_name == QueuePriority.PROFESSIONAL:
|
||||
task = execute_workflow_professional.delay(task_data_dict)
|
||||
elif queue_name == QueuePriority.TEAM:
|
||||
task = execute_workflow_team.delay(task_data_dict)
|
||||
else: # SANDBOX
|
||||
task = execute_workflow_sandbox.delay(task_data_dict)
|
||||
try:
|
||||
task: AsyncResult[Any] | None = None
|
||||
if queue_name == QueuePriority.PROFESSIONAL:
|
||||
task = execute_workflow_professional.delay(task_data_dict)
|
||||
elif queue_name == QueuePriority.TEAM:
|
||||
task = execute_workflow_team.delay(task_data_dict)
|
||||
else: # SANDBOX
|
||||
task = execute_workflow_sandbox.delay(task_data_dict)
|
||||
quota_charge.commit()
|
||||
except Exception:
|
||||
quota_charge.refund()
|
||||
raise
|
||||
|
||||
# 10. Update trigger log with task info
|
||||
trigger_log.status = WorkflowTriggerStatus.QUEUED
|
||||
@@ -295,13 +305,21 @@ class AsyncWorkflowService:
|
||||
return [log.to_dict() for log in logs]
|
||||
|
||||
@staticmethod
|
||||
def _get_workflow(workflow_service: WorkflowService, app_model: App, workflow_id: str | None = None) -> Workflow:
|
||||
def _get_workflow(
|
||||
workflow_service: WorkflowService,
|
||||
app_model: App,
|
||||
workflow_id: str | None = None,
|
||||
session: Session | None = None,
|
||||
) -> Workflow:
|
||||
"""
|
||||
Get workflow for the app
|
||||
|
||||
Args:
|
||||
app_model: App model instance
|
||||
workflow_id: Optional specific workflow ID
|
||||
session: Reuse this SQLAlchemy session for the lookup when provided,
|
||||
so the caller's explicit session bears the connection cost
|
||||
instead of Flask's request-scoped ``db.session``.
|
||||
|
||||
Returns:
|
||||
Workflow instance
|
||||
@@ -311,12 +329,12 @@ class AsyncWorkflowService:
|
||||
"""
|
||||
if workflow_id:
|
||||
# Get specific published workflow
|
||||
workflow = workflow_service.get_published_workflow_by_id(app_model, workflow_id)
|
||||
workflow = workflow_service.get_published_workflow_by_id(app_model, workflow_id, session=session)
|
||||
if not workflow:
|
||||
raise WorkflowNotFoundError(f"Published workflow not found: {workflow_id}")
|
||||
else:
|
||||
# Get default published workflow
|
||||
workflow = workflow_service.get_published_workflow(app_model)
|
||||
workflow = workflow_service.get_published_workflow(app_model, session=session)
|
||||
if not workflow:
|
||||
raise WorkflowNotFoundError(f"No published workflow found for app: {app_model.id}")
|
||||
|
||||
|
||||
@@ -2,12 +2,11 @@ import json
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Sequence
|
||||
from typing import Literal
|
||||
from typing import Literal, NotRequired, TypedDict
|
||||
|
||||
import httpx
|
||||
from pydantic import TypeAdapter
|
||||
from tenacity import retry, retry_if_exception_type, stop_before_delay, wait_fixed
|
||||
from typing_extensions import TypedDict
|
||||
from werkzeug.exceptions import InternalServerError
|
||||
|
||||
from enums.cloud_plan import CloudPlan
|
||||
@@ -26,6 +25,147 @@ class SubscriptionPlan(TypedDict):
|
||||
expiration_date: int
|
||||
|
||||
|
||||
class QuotaReserveResult(TypedDict):
|
||||
reservation_id: str
|
||||
available: int
|
||||
reserved: int
|
||||
|
||||
|
||||
class QuotaCommitResult(TypedDict):
|
||||
available: int
|
||||
reserved: int
|
||||
refunded: int
|
||||
|
||||
|
||||
class QuotaReleaseResult(TypedDict):
|
||||
available: int
|
||||
reserved: int
|
||||
released: int
|
||||
|
||||
|
||||
_quota_reserve_adapter = TypeAdapter(QuotaReserveResult)
|
||||
_quota_commit_adapter = TypeAdapter(QuotaCommitResult)
|
||||
_quota_release_adapter = TypeAdapter(QuotaReleaseResult)
|
||||
|
||||
|
||||
class _TenantFeatureQuota(TypedDict):
|
||||
usage: int
|
||||
limit: int
|
||||
reset_date: NotRequired[int]
|
||||
|
||||
|
||||
class TenantFeatureQuotaInfo(TypedDict):
|
||||
"""Response of /quota/info.
|
||||
|
||||
NOTE (hj24):
|
||||
- Same convention as BillingInfo: billing may return int fields as str,
|
||||
always keep non-strict mode to auto-coerce.
|
||||
"""
|
||||
|
||||
trigger_event: _TenantFeatureQuota
|
||||
api_rate_limit: _TenantFeatureQuota
|
||||
|
||||
|
||||
_tenant_feature_quota_info_adapter = TypeAdapter(TenantFeatureQuotaInfo)
|
||||
|
||||
|
||||
class _BillingQuota(TypedDict):
|
||||
size: int
|
||||
limit: int
|
||||
|
||||
|
||||
class _VectorSpaceQuota(TypedDict):
|
||||
size: float
|
||||
limit: int
|
||||
|
||||
|
||||
class _KnowledgeRateLimit(TypedDict):
|
||||
# NOTE (hj24):
|
||||
# 1. Return for sandbox users but is null for other plans, it's defined but never used.
|
||||
# 2. Keep it for compatibility for now, can be deprecated in future versions.
|
||||
size: NotRequired[int]
|
||||
# NOTE END
|
||||
limit: int
|
||||
|
||||
|
||||
class _BillingSubscription(TypedDict):
|
||||
plan: str
|
||||
interval: str
|
||||
education: bool
|
||||
|
||||
|
||||
class BillingInfo(TypedDict):
|
||||
"""Response of /subscription/info.
|
||||
|
||||
NOTE (hj24):
|
||||
- Fields not listed here (e.g. trigger_event, api_rate_limit) are stripped by TypeAdapter.validate_python()
|
||||
- To ensure the precision, billing may convert fields like int as str, be careful when use TypeAdapter:
|
||||
1. validate_python in non-strict mode will coerce it to the expected type
|
||||
2. In strict mode, it will raise ValidationError
|
||||
3. To preserve compatibility, always keep non-strict mode here and avoid strict mode
|
||||
"""
|
||||
|
||||
enabled: bool
|
||||
subscription: _BillingSubscription
|
||||
members: _BillingQuota
|
||||
apps: _BillingQuota
|
||||
vector_space: _VectorSpaceQuota
|
||||
knowledge_rate_limit: _KnowledgeRateLimit
|
||||
documents_upload_quota: _BillingQuota
|
||||
annotation_quota_limit: _BillingQuota
|
||||
docs_processing: str
|
||||
can_replace_logo: bool
|
||||
model_load_balancing_enabled: bool
|
||||
knowledge_pipeline_publish_enabled: bool
|
||||
next_credit_reset_date: NotRequired[int]
|
||||
|
||||
|
||||
_billing_info_adapter = TypeAdapter(BillingInfo)
|
||||
|
||||
|
||||
class KnowledgeRateLimitDict(TypedDict):
|
||||
limit: int
|
||||
subscription_plan: str
|
||||
|
||||
|
||||
class TenantFeaturePlanUsageDict(TypedDict):
|
||||
result: str
|
||||
history_id: str
|
||||
|
||||
|
||||
class LangContentDict(TypedDict):
|
||||
lang: str
|
||||
title: str
|
||||
subtitle: str
|
||||
body: str
|
||||
title_pic_url: str
|
||||
|
||||
|
||||
class NotificationDict(TypedDict):
|
||||
notification_id: str
|
||||
contents: dict[str, LangContentDict]
|
||||
frequency: Literal["once", "every_page_load"]
|
||||
|
||||
|
||||
class AccountNotificationDict(TypedDict, total=False):
|
||||
should_show: bool
|
||||
notification: NotificationDict
|
||||
shouldShow: bool
|
||||
notifications: list[dict]
|
||||
|
||||
|
||||
class UpsertNotificationDict(TypedDict):
|
||||
notification_id: str
|
||||
|
||||
|
||||
class BatchAddNotificationAccountsDict(TypedDict):
|
||||
count: int
|
||||
|
||||
|
||||
class DismissNotificationDict(TypedDict):
|
||||
success: bool
|
||||
|
||||
|
||||
class BillingService:
|
||||
base_url = os.environ.get("BILLING_API_URL", "BILLING_API_URL")
|
||||
secret_key = os.environ.get("BILLING_API_SECRET_KEY", "BILLING_API_SECRET_KEY")
|
||||
@@ -38,21 +178,73 @@ class BillingService:
|
||||
_PLAN_CACHE_TTL = 600
|
||||
|
||||
@classmethod
|
||||
def get_info(cls, tenant_id: str):
|
||||
def get_info(cls, tenant_id: str) -> BillingInfo:
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
billing_info = cls._send_request("GET", "/subscription/info", params=params)
|
||||
return billing_info
|
||||
return _billing_info_adapter.validate_python(billing_info)
|
||||
|
||||
@classmethod
|
||||
def get_tenant_feature_plan_usage_info(cls, tenant_id: str):
|
||||
"""Deprecated: Use get_quota_info instead."""
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
usage_info = cls._send_request("GET", "/tenant-feature-usage/info", params=params)
|
||||
return usage_info
|
||||
|
||||
@classmethod
|
||||
def get_knowledge_rate_limit(cls, tenant_id: str):
|
||||
def get_quota_info(cls, tenant_id: str) -> TenantFeatureQuotaInfo:
|
||||
params = {"tenant_id": tenant_id}
|
||||
return _tenant_feature_quota_info_adapter.validate_python(
|
||||
cls._send_request("GET", "/quota/info", params=params)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def quota_reserve(
|
||||
cls, tenant_id: str, feature_key: str, request_id: str, amount: int = 1, meta: dict | None = None
|
||||
) -> QuotaReserveResult:
|
||||
"""Reserve quota before task execution."""
|
||||
payload: dict = {
|
||||
"tenant_id": tenant_id,
|
||||
"feature_key": feature_key,
|
||||
"request_id": request_id,
|
||||
"amount": amount,
|
||||
}
|
||||
if meta:
|
||||
payload["meta"] = meta
|
||||
return _quota_reserve_adapter.validate_python(cls._send_request("POST", "/quota/reserve", json=payload))
|
||||
|
||||
@classmethod
|
||||
def quota_commit(
|
||||
cls, tenant_id: str, feature_key: str, reservation_id: str, actual_amount: int, meta: dict | None = None
|
||||
) -> QuotaCommitResult:
|
||||
"""Commit a reservation with actual consumption."""
|
||||
payload: dict = {
|
||||
"tenant_id": tenant_id,
|
||||
"feature_key": feature_key,
|
||||
"reservation_id": reservation_id,
|
||||
"actual_amount": actual_amount,
|
||||
}
|
||||
if meta:
|
||||
payload["meta"] = meta
|
||||
return _quota_commit_adapter.validate_python(cls._send_request("POST", "/quota/commit", json=payload))
|
||||
|
||||
@classmethod
|
||||
def quota_release(cls, tenant_id: str, feature_key: str, reservation_id: str) -> QuotaReleaseResult:
|
||||
"""Release a reservation (cancel, return frozen quota)."""
|
||||
return _quota_release_adapter.validate_python(
|
||||
cls._send_request(
|
||||
"POST",
|
||||
"/quota/release",
|
||||
json={
|
||||
"tenant_id": tenant_id,
|
||||
"feature_key": feature_key,
|
||||
"reservation_id": reservation_id,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_knowledge_rate_limit(cls, tenant_id: str) -> KnowledgeRateLimitDict:
|
||||
params = {"tenant_id": tenant_id}
|
||||
|
||||
knowledge_rate_limit = cls._send_request("GET", "/subscription/knowledge-rate-limit", params=params)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import logging
|
||||
|
||||
from sqlalchemy import update
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import select, update
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from core.errors.error import QuotaExceededError
|
||||
@@ -29,14 +29,15 @@ class CreditPoolService:
|
||||
@classmethod
|
||||
def get_pool(cls, tenant_id: str, pool_type: str = "trial") -> TenantCreditPool | None:
|
||||
"""get tenant credit pool"""
|
||||
return (
|
||||
db.session.query(TenantCreditPool)
|
||||
.filter_by(
|
||||
tenant_id=tenant_id,
|
||||
pool_type=pool_type,
|
||||
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
|
||||
return session.scalar(
|
||||
select(TenantCreditPool)
|
||||
.where(
|
||||
TenantCreditPool.tenant_id == tenant_id,
|
||||
TenantCreditPool.pool_type == pool_type,
|
||||
)
|
||||
.limit(1)
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def check_credits_available(
|
||||
|
||||
@@ -281,7 +281,7 @@ class FeatureService:
|
||||
def _fulfill_params_from_billing_api(cls, features: FeatureModel, tenant_id: str):
|
||||
billing_info = BillingService.get_info(tenant_id)
|
||||
|
||||
features_usage_info = BillingService.get_tenant_feature_plan_usage_info(tenant_id)
|
||||
features_usage_info = BillingService.get_quota_info(tenant_id)
|
||||
|
||||
features.billing.enabled = billing_info["enabled"]
|
||||
features.billing.subscription.plan = billing_info["subscription"]["plan"]
|
||||
@@ -312,7 +312,10 @@ class FeatureService:
|
||||
features.apps.limit = billing_info["apps"]["limit"]
|
||||
|
||||
if "vector_space" in billing_info:
|
||||
features.vector_space.size = billing_info["vector_space"]["size"]
|
||||
# NOTE (hj24): billing API returns vector_space.size as float (e.g. 0.0)
|
||||
# but LimitationModel.size is int; truncate here for compatibility
|
||||
features.vector_space.size = int(billing_info["vector_space"]["size"])
|
||||
# NOTE END
|
||||
features.vector_space.limit = billing_info["vector_space"]["limit"]
|
||||
|
||||
if "documents_upload_quota" in billing_info:
|
||||
@@ -333,7 +336,11 @@ class FeatureService:
|
||||
features.model_load_balancing_enabled = billing_info["model_load_balancing_enabled"]
|
||||
|
||||
if "knowledge_rate_limit" in billing_info:
|
||||
# NOTE (hj24):
|
||||
# 1. knowledge_rate_limit size is nullable, currently it's defined but never used, only limit is used.
|
||||
# 2. So be careful if later we decide to use [size], we cannot assume it is always present.
|
||||
features.knowledge_rate_limit = billing_info["knowledge_rate_limit"]["limit"]
|
||||
# NOTE END
|
||||
|
||||
if "knowledge_pipeline_publish_enabled" in billing_info:
|
||||
features.knowledge_pipeline.publish_enabled = billing_info["knowledge_pipeline_publish_enabled"]
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from configs import dify_config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from enums.quota_type import QuotaType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuotaCharge:
|
||||
"""
|
||||
Result of a quota reservation (Reserve phase).
|
||||
|
||||
Lifecycle:
|
||||
charge = QuotaService.consume(QuotaType.TRIGGER, tenant_id)
|
||||
try:
|
||||
do_work()
|
||||
charge.commit() # Confirm consumption
|
||||
except:
|
||||
charge.refund() # Release frozen quota
|
||||
|
||||
If neither commit() nor refund() is called, the billing system's
|
||||
cleanup CronJob will auto-release the reservation within ~75 seconds.
|
||||
"""
|
||||
|
||||
success: bool
|
||||
charge_id: str | None # reservation_id
|
||||
_quota_type: QuotaType
|
||||
_tenant_id: str | None = None
|
||||
_feature_key: str | None = None
|
||||
_amount: int = 0
|
||||
_committed: bool = field(default=False, repr=False)
|
||||
|
||||
def commit(self, actual_amount: int | None = None) -> None:
|
||||
"""
|
||||
Confirm the consumption with actual amount.
|
||||
|
||||
Args:
|
||||
actual_amount: Actual amount consumed. Defaults to the reserved amount.
|
||||
If less than reserved, the difference is refunded automatically.
|
||||
"""
|
||||
if self._committed or not self.charge_id or not self._tenant_id or not self._feature_key:
|
||||
return
|
||||
|
||||
try:
|
||||
from services.billing_service import BillingService
|
||||
|
||||
amount = actual_amount if actual_amount is not None else self._amount
|
||||
BillingService.quota_commit(
|
||||
tenant_id=self._tenant_id,
|
||||
feature_key=self._feature_key,
|
||||
reservation_id=self.charge_id,
|
||||
actual_amount=amount,
|
||||
)
|
||||
self._committed = True
|
||||
logger.debug(
|
||||
"Committed %s quota for tenant %s, reservation_id: %s, amount: %d",
|
||||
self._quota_type,
|
||||
self._tenant_id,
|
||||
self.charge_id,
|
||||
amount,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to commit quota, reservation_id: %s", self.charge_id)
|
||||
|
||||
def refund(self) -> None:
|
||||
"""
|
||||
Release the reserved quota (cancel the charge).
|
||||
|
||||
Safe to call even if:
|
||||
- charge failed or was disabled (charge_id is None)
|
||||
- already committed (Release after Commit is a no-op)
|
||||
- already refunded (idempotent)
|
||||
|
||||
This method guarantees no exceptions will be raised.
|
||||
"""
|
||||
if not self.charge_id or not self._tenant_id or not self._feature_key:
|
||||
return
|
||||
|
||||
QuotaService.release(self._quota_type, self.charge_id, self._tenant_id, self._feature_key)
|
||||
|
||||
|
||||
def unlimited() -> QuotaCharge:
|
||||
from enums.quota_type import QuotaType
|
||||
|
||||
return QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.UNLIMITED)
|
||||
|
||||
|
||||
class QuotaService:
|
||||
"""Orchestrates quota reserve / commit / release lifecycle via BillingService."""
|
||||
|
||||
@staticmethod
|
||||
def consume(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge:
|
||||
"""
|
||||
Reserve + immediate Commit (one-shot mode).
|
||||
|
||||
The returned QuotaCharge supports .refund() which calls Release.
|
||||
For two-phase usage (e.g. streaming), use reserve() directly.
|
||||
"""
|
||||
charge = QuotaService.reserve(quota_type, tenant_id, amount)
|
||||
if charge.success and charge.charge_id:
|
||||
charge.commit()
|
||||
return charge
|
||||
|
||||
@staticmethod
|
||||
def reserve(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> QuotaCharge:
|
||||
"""
|
||||
Reserve quota before task execution (Reserve phase only).
|
||||
|
||||
The caller MUST call charge.commit() after the task succeeds,
|
||||
or charge.refund() if the task fails.
|
||||
|
||||
Raises:
|
||||
QuotaExceededError: When quota is insufficient
|
||||
"""
|
||||
from services.billing_service import BillingService
|
||||
from services.errors.app import QuotaExceededError
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
logger.debug("Billing disabled, allowing request for %s", tenant_id)
|
||||
return QuotaCharge(success=True, charge_id=None, _quota_type=quota_type)
|
||||
|
||||
logger.info("Reserving %d %s quota for tenant %s", amount, quota_type.value, tenant_id)
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount to reserve must be greater than 0")
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
feature_key = quota_type.billing_key
|
||||
|
||||
try:
|
||||
reserve_resp = BillingService.quota_reserve(
|
||||
tenant_id=tenant_id,
|
||||
feature_key=feature_key,
|
||||
request_id=request_id,
|
||||
amount=amount,
|
||||
)
|
||||
|
||||
reservation_id = reserve_resp.get("reservation_id")
|
||||
if not reservation_id:
|
||||
logger.warning(
|
||||
"Reserve returned no reservation_id for %s, feature %s, response: %s",
|
||||
tenant_id,
|
||||
quota_type.value,
|
||||
reserve_resp,
|
||||
)
|
||||
raise QuotaExceededError(feature=quota_type.value, tenant_id=tenant_id, required=amount)
|
||||
|
||||
logger.debug(
|
||||
"Reserved %d %s quota for tenant %s, reservation_id: %s",
|
||||
amount,
|
||||
quota_type.value,
|
||||
tenant_id,
|
||||
reservation_id,
|
||||
)
|
||||
return QuotaCharge(
|
||||
success=True,
|
||||
charge_id=reservation_id,
|
||||
_quota_type=quota_type,
|
||||
_tenant_id=tenant_id,
|
||||
_feature_key=feature_key,
|
||||
_amount=amount,
|
||||
)
|
||||
|
||||
except QuotaExceededError:
|
||||
raise
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Failed to reserve quota for %s, feature %s", tenant_id, quota_type.value)
|
||||
return unlimited()
|
||||
|
||||
@staticmethod
|
||||
def check(quota_type: QuotaType, tenant_id: str, amount: int = 1) -> bool:
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return True
|
||||
|
||||
if amount <= 0:
|
||||
raise ValueError("Amount to check must be greater than 0")
|
||||
|
||||
try:
|
||||
remaining = QuotaService.get_remaining(quota_type, tenant_id)
|
||||
return remaining >= amount if remaining != -1 else True
|
||||
except Exception:
|
||||
logger.exception("Failed to check quota for %s, feature %s", tenant_id, quota_type.value)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def release(quota_type: QuotaType, reservation_id: str, tenant_id: str, feature_key: str) -> None:
|
||||
"""Release a reservation. Guarantees no exceptions."""
|
||||
try:
|
||||
from services.billing_service import BillingService
|
||||
|
||||
if not dify_config.BILLING_ENABLED:
|
||||
return
|
||||
|
||||
if not reservation_id:
|
||||
return
|
||||
|
||||
logger.info("Releasing %s quota, reservation_id: %s", quota_type.value, reservation_id)
|
||||
BillingService.quota_release(
|
||||
tenant_id=tenant_id,
|
||||
feature_key=feature_key,
|
||||
reservation_id=reservation_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to release quota, reservation_id: %s", reservation_id)
|
||||
|
||||
@staticmethod
|
||||
def get_remaining(quota_type: QuotaType, tenant_id: str) -> int:
|
||||
from services.billing_service import BillingService
|
||||
|
||||
try:
|
||||
usage_info = BillingService.get_quota_info(tenant_id)
|
||||
if isinstance(usage_info, dict):
|
||||
feature_info = usage_info.get(quota_type.billing_key, {})
|
||||
if isinstance(feature_info, dict):
|
||||
limit = feature_info.get("limit", 0)
|
||||
usage = feature_info.get("usage", 0)
|
||||
if limit == -1:
|
||||
return -1
|
||||
return max(0, limit - usage)
|
||||
return 0
|
||||
except Exception:
|
||||
logger.exception("Failed to get remaining quota for %s, feature %s", tenant_id, quota_type.value)
|
||||
return -1
|
||||
@@ -37,6 +37,7 @@ from models.workflow import Workflow
|
||||
from services.async_workflow_service import AsyncWorkflowService
|
||||
from services.end_user_service import EndUserService
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.quota_service import QuotaService
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from services.workflow.entities import WebhookTriggerData
|
||||
|
||||
@@ -758,45 +759,47 @@ class WebhookService:
|
||||
Exception: If workflow execution fails
|
||||
"""
|
||||
try:
|
||||
with Session(db.engine) as session:
|
||||
# Prepare inputs for the webhook node
|
||||
# The webhook node expects webhook_data in the inputs
|
||||
workflow_inputs = cls.build_workflow_inputs(webhook_data)
|
||||
workflow_inputs = cls.build_workflow_inputs(webhook_data)
|
||||
|
||||
# Create trigger data
|
||||
trigger_data = WebhookTriggerData(
|
||||
app_id=webhook_trigger.app_id,
|
||||
workflow_id=workflow.id,
|
||||
root_node_id=webhook_trigger.node_id, # Start from the webhook node
|
||||
inputs=workflow_inputs,
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
trigger_data = WebhookTriggerData(
|
||||
app_id=webhook_trigger.app_id,
|
||||
workflow_id=workflow.id,
|
||||
root_node_id=webhook_trigger.node_id,
|
||||
inputs=workflow_inputs,
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
)
|
||||
|
||||
end_user = EndUserService.get_or_create_end_user_by_type(
|
||||
type=InvokeFrom.TRIGGER,
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
app_id=webhook_trigger.app_id,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
try:
|
||||
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, webhook_trigger.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id)
|
||||
logger.info(
|
||||
"Tenant %s rate limited, skipping webhook trigger %s",
|
||||
webhook_trigger.tenant_id,
|
||||
webhook_trigger.webhook_id,
|
||||
)
|
||||
raise
|
||||
|
||||
end_user = EndUserService.get_or_create_end_user_by_type(
|
||||
type=InvokeFrom.TRIGGER,
|
||||
tenant_id=webhook_trigger.tenant_id,
|
||||
app_id=webhook_trigger.app_id,
|
||||
user_id=None,
|
||||
)
|
||||
|
||||
# consume quota before triggering workflow execution
|
||||
try:
|
||||
QuotaType.TRIGGER.consume(webhook_trigger.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id)
|
||||
logger.info(
|
||||
"Tenant %s rate limited, skipping webhook trigger %s",
|
||||
webhook_trigger.tenant_id,
|
||||
webhook_trigger.webhook_id,
|
||||
try:
|
||||
# NOTE: don not use `with sessionmaker(bind=db.engine, expire_on_commit=False).begin()`
|
||||
# trigger_workflow_async need to handle multipe session commits internally
|
||||
with Session(db.engine, expire_on_commit=False) as session:
|
||||
AsyncWorkflowService.trigger_workflow_async(
|
||||
session,
|
||||
end_user,
|
||||
trigger_data,
|
||||
)
|
||||
raise
|
||||
|
||||
# Trigger workflow execution asynchronously
|
||||
AsyncWorkflowService.trigger_workflow_async(
|
||||
session,
|
||||
end_user,
|
||||
trigger_data,
|
||||
)
|
||||
quota_charge.commit()
|
||||
except Exception:
|
||||
quota_charge.refund()
|
||||
raise
|
||||
|
||||
except Exception:
|
||||
logger.exception("Failed to trigger workflow for webhook %s", webhook_trigger.webhook_id)
|
||||
|
||||
@@ -132,31 +132,38 @@ class WorkflowService:
|
||||
if workflow_id:
|
||||
return self.get_published_workflow_by_id(app_model, workflow_id)
|
||||
# fetch draft workflow by app_model
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
workflow = db.session.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
Workflow.version == Workflow.VERSION_DRAFT,
|
||||
)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
# return draft workflow
|
||||
return workflow
|
||||
|
||||
def get_published_workflow_by_id(self, app_model: App, workflow_id: str) -> Workflow | None:
|
||||
def get_published_workflow_by_id(
|
||||
self, app_model: App, workflow_id: str, session: Session | None = None
|
||||
) -> Workflow | None:
|
||||
"""
|
||||
fetch published workflow by workflow_id
|
||||
|
||||
When ``session`` is provided, reuse it so callers that already hold a
|
||||
Session avoid checking out an extra request-scoped ``db.session``
|
||||
connection. Falls back to ``db.session`` for backward compatibility.
|
||||
"""
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
bind = session if session is not None else db.session
|
||||
workflow = bind.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
Workflow.id == workflow_id,
|
||||
)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
if not workflow:
|
||||
return None
|
||||
@@ -167,23 +174,27 @@ class WorkflowService:
|
||||
)
|
||||
return workflow
|
||||
|
||||
def get_published_workflow(self, app_model: App) -> Workflow | None:
|
||||
def get_published_workflow(self, app_model: App, session: Session | None = None) -> Workflow | None:
|
||||
"""
|
||||
Get published workflow
|
||||
|
||||
When ``session`` is provided, reuse it so callers that already hold a
|
||||
Session avoid checking out an extra request-scoped ``db.session``
|
||||
connection. Falls back to ``db.session`` for backward compatibility.
|
||||
"""
|
||||
|
||||
if not app_model.workflow_id:
|
||||
return None
|
||||
|
||||
# fetch published workflow by workflow_id
|
||||
workflow = (
|
||||
db.session.query(Workflow)
|
||||
bind = session if session is not None else db.session
|
||||
workflow = bind.scalar(
|
||||
select(Workflow)
|
||||
.where(
|
||||
Workflow.tenant_id == app_model.tenant_id,
|
||||
Workflow.app_id == app_model.id,
|
||||
Workflow.id == app_model.workflow_id,
|
||||
)
|
||||
.first()
|
||||
.limit(1)
|
||||
)
|
||||
|
||||
return workflow
|
||||
|
||||
@@ -156,7 +156,12 @@ def _execute_workflow_common(
|
||||
state_owner_user_id=workflow.created_by,
|
||||
)
|
||||
|
||||
# Execute the workflow with the trigger type
|
||||
# NOTE (hj24)
|
||||
# Release the transaction before the blocking generate() call,
|
||||
# otherwise the connection stays "idle in transaction" for hours.
|
||||
session.commit()
|
||||
# NOTE END
|
||||
|
||||
generator.generate(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
|
||||
@@ -28,7 +28,7 @@ from core.trigger.provider import PluginTriggerProviderController
|
||||
from core.trigger.trigger_manager import TriggerManager
|
||||
from core.workflow.nodes.trigger_plugin.entities import TriggerEventNodeData
|
||||
from dify_graph.enums import WorkflowExecutionStatus
|
||||
from enums.quota_type import QuotaType, unlimited
|
||||
from enums.quota_type import QuotaType
|
||||
from models.enums import (
|
||||
AppTriggerType,
|
||||
CreatorUserRole,
|
||||
@@ -42,6 +42,7 @@ from models.workflow import Workflow, WorkflowAppLog, WorkflowAppLogCreatedFrom,
|
||||
from services.async_workflow_service import AsyncWorkflowService
|
||||
from services.end_user_service import EndUserService
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from services.trigger.trigger_provider_service import TriggerProviderService
|
||||
from services.trigger.trigger_request_service import TriggerHttpRequestCachingService
|
||||
@@ -258,59 +259,58 @@ def dispatch_triggered_workflow(
|
||||
tenant_id=subscription.tenant_id, provider_id=TriggerProviderID(subscription.provider_id)
|
||||
)
|
||||
trigger_entity: TriggerProviderEntity = provider_controller.entity
|
||||
|
||||
# Ensure expire_on_commit is set to False to remain workflows available
|
||||
with session_factory.create_session() as session:
|
||||
workflows: Mapping[str, Workflow] = _get_latest_workflows_by_app_ids(session, subscribers)
|
||||
|
||||
end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.TRIGGER,
|
||||
tenant_id=subscription.tenant_id,
|
||||
app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers],
|
||||
user_id=user_id,
|
||||
)
|
||||
for plugin_trigger in subscribers:
|
||||
# Get workflow from mapping
|
||||
workflow: Workflow | None = workflows.get(plugin_trigger.app_id)
|
||||
if not workflow:
|
||||
logger.error(
|
||||
"Workflow not found for app %s",
|
||||
plugin_trigger.app_id,
|
||||
)
|
||||
continue
|
||||
end_users: Mapping[str, EndUser] = EndUserService.create_end_user_batch(
|
||||
type=InvokeFrom.TRIGGER,
|
||||
tenant_id=subscription.tenant_id,
|
||||
app_ids=[plugin_trigger.app_id for plugin_trigger in subscribers],
|
||||
user_id=user_id,
|
||||
)
|
||||
|
||||
# Find the trigger node in the workflow
|
||||
event_node = None
|
||||
for node_id, node_config in workflow.walk_nodes(TRIGGER_PLUGIN_NODE_TYPE):
|
||||
if node_id == plugin_trigger.node_id:
|
||||
event_node = node_config
|
||||
break
|
||||
|
||||
if not event_node:
|
||||
logger.error("Trigger event node not found for app %s", plugin_trigger.app_id)
|
||||
continue
|
||||
|
||||
# invoke trigger
|
||||
trigger_metadata = PluginTriggerMetadata(
|
||||
plugin_unique_identifier=provider_controller.plugin_unique_identifier or "",
|
||||
endpoint_id=subscription.endpoint_id,
|
||||
provider_id=subscription.provider_id,
|
||||
event_name=event_name,
|
||||
icon_filename=trigger_entity.identity.icon or "",
|
||||
icon_dark_filename=trigger_entity.identity.icon_dark or "",
|
||||
for plugin_trigger in subscribers:
|
||||
workflow: Workflow | None = workflows.get(plugin_trigger.app_id)
|
||||
if not workflow:
|
||||
logger.error(
|
||||
"Workflow not found for app %s",
|
||||
plugin_trigger.app_id,
|
||||
)
|
||||
continue
|
||||
|
||||
# consume quota before invoking trigger
|
||||
quota_charge = unlimited()
|
||||
try:
|
||||
quota_charge = QuotaType.TRIGGER.consume(subscription.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id)
|
||||
logger.info(
|
||||
"Tenant %s rate limited, skipping plugin trigger %s", subscription.tenant_id, plugin_trigger.id
|
||||
)
|
||||
return 0
|
||||
event_node = None
|
||||
for node_id, node_config in workflow.walk_nodes(TRIGGER_PLUGIN_NODE_TYPE):
|
||||
if node_id == plugin_trigger.node_id:
|
||||
event_node = node_config
|
||||
break
|
||||
|
||||
node_data: TriggerEventNodeData = TriggerEventNodeData.model_validate(event_node)
|
||||
invoke_response: TriggerInvokeEventResponse | None = None
|
||||
if not event_node:
|
||||
logger.error("Trigger event node not found for app %s", plugin_trigger.app_id)
|
||||
continue
|
||||
|
||||
trigger_metadata = PluginTriggerMetadata(
|
||||
plugin_unique_identifier=provider_controller.plugin_unique_identifier or "",
|
||||
endpoint_id=subscription.endpoint_id,
|
||||
provider_id=subscription.provider_id,
|
||||
event_name=event_name,
|
||||
icon_filename=trigger_entity.identity.icon or "",
|
||||
icon_dark_filename=trigger_entity.identity.icon_dark or "",
|
||||
)
|
||||
|
||||
quota_charge = unlimited()
|
||||
try:
|
||||
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, subscription.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(subscription.tenant_id)
|
||||
logger.info("Tenant %s rate limited, skipping plugin trigger %s", subscription.tenant_id, plugin_trigger.id)
|
||||
return dispatched_count
|
||||
|
||||
node_data: TriggerEventNodeData = TriggerEventNodeData.model_validate(event_node)
|
||||
invoke_response: TriggerInvokeEventResponse | None = None
|
||||
|
||||
with session_factory.create_session() as session:
|
||||
try:
|
||||
invoke_response = TriggerManager.invoke_trigger_event(
|
||||
tenant_id=subscription.tenant_id,
|
||||
@@ -387,6 +387,7 @@ def dispatch_triggered_workflow(
|
||||
raise ValueError(f"End user not found for app {plugin_trigger.app_id}")
|
||||
|
||||
AsyncWorkflowService.trigger_workflow_async(session=session, user=end_user, trigger_data=trigger_data)
|
||||
quota_charge.commit()
|
||||
dispatched_count += 1
|
||||
logger.info(
|
||||
"Triggered workflow for app %s with trigger event %s",
|
||||
@@ -401,7 +402,7 @@ def dispatch_triggered_workflow(
|
||||
plugin_trigger.app_id,
|
||||
)
|
||||
|
||||
return dispatched_count
|
||||
return dispatched_count
|
||||
|
||||
|
||||
def dispatch_triggered_workflows(
|
||||
|
||||
@@ -8,10 +8,11 @@ from core.workflow.nodes.trigger_schedule.exc import (
|
||||
ScheduleNotFoundError,
|
||||
TenantOwnerNotFoundError,
|
||||
)
|
||||
from enums.quota_type import QuotaType, unlimited
|
||||
from enums.quota_type import QuotaType
|
||||
from models.trigger import WorkflowSchedulePlan
|
||||
from services.async_workflow_service import AsyncWorkflowService
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.quota_service import QuotaService, unlimited
|
||||
from services.trigger.app_trigger_service import AppTriggerService
|
||||
from services.trigger.schedule_service import ScheduleService
|
||||
from services.workflow.entities import ScheduleTriggerData
|
||||
@@ -32,6 +33,7 @@ def run_schedule_trigger(schedule_id: str) -> None:
|
||||
TenantOwnerNotFoundError: If no owner/admin for tenant
|
||||
ScheduleExecutionError: If workflow trigger fails
|
||||
"""
|
||||
# Ensure expire_on_commit is set to False to remain schedule/tenant_owner available
|
||||
with session_factory.create_session() as session:
|
||||
schedule = session.get(WorkflowSchedulePlan, schedule_id)
|
||||
if not schedule:
|
||||
@@ -41,16 +43,16 @@ def run_schedule_trigger(schedule_id: str) -> None:
|
||||
if not tenant_owner:
|
||||
raise TenantOwnerNotFoundError(f"No owner or admin found for tenant {schedule.tenant_id}")
|
||||
|
||||
quota_charge = unlimited()
|
||||
try:
|
||||
quota_charge = QuotaType.TRIGGER.consume(schedule.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id)
|
||||
logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id)
|
||||
return
|
||||
quota_charge = unlimited()
|
||||
try:
|
||||
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, schedule.tenant_id)
|
||||
except QuotaExceededError:
|
||||
AppTriggerService.mark_tenant_triggers_rate_limited(schedule.tenant_id)
|
||||
logger.info("Tenant %s rate limited, skipping schedule trigger %s", schedule.tenant_id, schedule_id)
|
||||
return
|
||||
|
||||
try:
|
||||
# Production dispatch: Trigger the workflow normally
|
||||
try:
|
||||
with session_factory.create_session() as session:
|
||||
response = AsyncWorkflowService.trigger_workflow_async(
|
||||
session=session,
|
||||
user=tenant_owner,
|
||||
@@ -61,9 +63,10 @@ def run_schedule_trigger(schedule_id: str) -> None:
|
||||
tenant_id=schedule.tenant_id,
|
||||
),
|
||||
)
|
||||
logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id)
|
||||
except Exception as e:
|
||||
quota_charge.refund()
|
||||
raise ScheduleExecutionError(
|
||||
f"Failed to trigger workflow for schedule {schedule_id}, app {schedule.app_id}"
|
||||
) from e
|
||||
quota_charge.commit()
|
||||
logger.info("Schedule %s triggered workflow: %s", schedule_id, response.workflow_trigger_log_id)
|
||||
except Exception as e:
|
||||
quota_charge.refund()
|
||||
raise ScheduleExecutionError(
|
||||
f"Failed to trigger workflow for schedule {schedule_id}, app {schedule.app_id}"
|
||||
) from e
|
||||
|
||||
@@ -163,11 +163,9 @@ class DifyTestContainers:
|
||||
wait_for_logs(self.redis, "Ready to accept connections", timeout=30)
|
||||
logger.info("Redis container is ready and accepting connections")
|
||||
|
||||
# Start Dify Sandbox container for code execution environment
|
||||
# Dify Sandbox provides a secure environment for executing user code
|
||||
# Use pinned version 0.2.12 to match production docker-compose configuration
|
||||
# Start Dify Sandbox container for code execution environment.
|
||||
logger.info("Initializing Dify Sandbox container...")
|
||||
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.12").with_network(self.network)
|
||||
self.dify_sandbox = DockerContainer(image="langgenius/dify-sandbox:0.2.14").with_network(self.network)
|
||||
self.dify_sandbox.with_exposed_ports(8194)
|
||||
self.dify_sandbox.env = {
|
||||
"API_KEY": "test_api_key",
|
||||
@@ -187,7 +185,7 @@ class DifyTestContainers:
|
||||
# Start Dify Plugin Daemon container for plugin management
|
||||
# Dify Plugin Daemon provides plugin lifecycle management and execution
|
||||
logger.info("Initializing Dify Plugin Daemon container...")
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.4-local").with_network(
|
||||
self.dify_plugin_daemon = DockerContainer(image="langgenius/dify-plugin-daemon:0.5.3-local").with_network(
|
||||
self.network
|
||||
)
|
||||
self.dify_plugin_daemon.with_exposed_ports(5002)
|
||||
|
||||
@@ -36,12 +36,19 @@ class TestAppGenerateService:
|
||||
) as mock_message_based_generator,
|
||||
patch("services.account_service.FeatureService", autospec=True) as mock_account_feature_service,
|
||||
patch("services.app_generate_service.dify_config", autospec=True) as mock_dify_config,
|
||||
patch("services.quota_service.dify_config", autospec=True) as mock_quota_dify_config,
|
||||
patch("configs.dify_config", autospec=True) as mock_global_dify_config,
|
||||
):
|
||||
# Setup default mock returns for billing service
|
||||
mock_billing_service.update_tenant_feature_plan_usage.return_value = {
|
||||
"result": "success",
|
||||
"history_id": "test_history_id",
|
||||
mock_billing_service.quota_reserve.return_value = {
|
||||
"reservation_id": "test-reservation-id",
|
||||
"available": 100,
|
||||
"reserved": 1,
|
||||
}
|
||||
mock_billing_service.quota_commit.return_value = {
|
||||
"available": 99,
|
||||
"reserved": 0,
|
||||
"refunded": 0,
|
||||
}
|
||||
|
||||
# Setup default mock returns for workflow service
|
||||
@@ -101,6 +108,8 @@ class TestAppGenerateService:
|
||||
mock_dify_config.APP_DEFAULT_ACTIVE_REQUESTS = 100
|
||||
mock_dify_config.APP_DAILY_RATE_LIMIT = 1000
|
||||
|
||||
mock_quota_dify_config.BILLING_ENABLED = False
|
||||
|
||||
mock_global_dify_config.BILLING_ENABLED = False
|
||||
mock_global_dify_config.APP_MAX_ACTIVE_REQUESTS = 100
|
||||
mock_global_dify_config.APP_DAILY_RATE_LIMIT = 1000
|
||||
@@ -118,6 +127,7 @@ class TestAppGenerateService:
|
||||
"message_based_generator": mock_message_based_generator,
|
||||
"account_feature_service": mock_account_feature_service,
|
||||
"dify_config": mock_dify_config,
|
||||
"quota_dify_config": mock_quota_dify_config,
|
||||
"global_dify_config": mock_global_dify_config,
|
||||
}
|
||||
|
||||
@@ -465,6 +475,7 @@ class TestAppGenerateService:
|
||||
|
||||
# Set BILLING_ENABLED to True for this test
|
||||
mock_external_service_dependencies["dify_config"].BILLING_ENABLED = True
|
||||
mock_external_service_dependencies["quota_dify_config"].BILLING_ENABLED = True
|
||||
mock_external_service_dependencies["global_dify_config"].BILLING_ENABLED = True
|
||||
|
||||
# Setup test arguments
|
||||
@@ -478,8 +489,10 @@ class TestAppGenerateService:
|
||||
# Verify the result
|
||||
assert result == ["test_response"]
|
||||
|
||||
# Verify billing service was called to consume quota
|
||||
mock_external_service_dependencies["billing_service"].update_tenant_feature_plan_usage.assert_called_once()
|
||||
# Verify billing two-phase quota (reserve + commit)
|
||||
billing = mock_external_service_dependencies["billing_service"]
|
||||
billing.quota_reserve.assert_called_once()
|
||||
billing.quota_commit.assert_called_once()
|
||||
|
||||
def test_generate_with_invalid_app_mode(
|
||||
self, db_session_with_containers: Session, mock_external_service_dependencies
|
||||
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.trigger.constants import TRIGGER_WEBHOOK_NODE_TYPE
|
||||
from enums.quota_type import QuotaType
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import AppTriggerStatus, AppTriggerType
|
||||
from models.model import App
|
||||
from models.trigger import AppTrigger, WorkflowWebhookTrigger
|
||||
from models.workflow import Workflow
|
||||
from services.errors.app import QuotaExceededError
|
||||
from services.trigger.webhook_service import WebhookService
|
||||
|
||||
|
||||
class WebhookServiceRelationshipFactory:
|
||||
@staticmethod
|
||||
def create_account_and_tenant(db_session_with_containers: Session) -> tuple[Account, Tenant]:
|
||||
account = Account(
|
||||
name=f"Account {uuid4()}",
|
||||
email=f"webhook-{uuid4()}@example.com",
|
||||
password="hashed-password",
|
||||
password_salt="salt",
|
||||
interface_language="en-US",
|
||||
timezone="UTC",
|
||||
)
|
||||
db_session_with_containers.add(account)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
tenant = Tenant(name=f"Tenant {uuid4()}", plan="basic", status="normal")
|
||||
db_session_with_containers.add(tenant)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant.id,
|
||||
account_id=account.id,
|
||||
role=TenantAccountRole.OWNER,
|
||||
current=True,
|
||||
)
|
||||
db_session_with_containers.add(join)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
account.current_tenant = tenant
|
||||
return account, tenant
|
||||
|
||||
@staticmethod
|
||||
def create_app(db_session_with_containers: Session, tenant: Tenant, account: Account) -> App:
|
||||
app = App(
|
||||
tenant_id=tenant.id,
|
||||
name=f"Webhook App {uuid4()}",
|
||||
description="",
|
||||
mode="workflow",
|
||||
icon_type="emoji",
|
||||
icon="bot",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
api_rpm=100,
|
||||
api_rph=100,
|
||||
is_demo=False,
|
||||
is_public=False,
|
||||
is_universal=False,
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
)
|
||||
db_session_with_containers.add(app)
|
||||
db_session_with_containers.commit()
|
||||
return app
|
||||
|
||||
@staticmethod
|
||||
def create_workflow(
|
||||
db_session_with_containers: Session,
|
||||
*,
|
||||
app: App,
|
||||
account: Account,
|
||||
node_ids: list[str],
|
||||
version: str,
|
||||
) -> Workflow:
|
||||
graph = {
|
||||
"nodes": [
|
||||
{
|
||||
"id": node_id,
|
||||
"data": {
|
||||
"type": TRIGGER_WEBHOOK_NODE_TYPE,
|
||||
"title": f"Webhook {node_id}",
|
||||
"method": "post",
|
||||
"content_type": "application/json",
|
||||
"headers": [],
|
||||
"params": [],
|
||||
"body": [],
|
||||
"status_code": 200,
|
||||
"response_body": '{"status": "ok"}',
|
||||
"timeout": 30,
|
||||
},
|
||||
}
|
||||
for node_id in node_ids
|
||||
],
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
workflow = Workflow(
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
type="workflow",
|
||||
graph=json.dumps(graph),
|
||||
features=json.dumps({}),
|
||||
created_by=account.id,
|
||||
updated_by=account.id,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
version=version,
|
||||
)
|
||||
db_session_with_containers.add(workflow)
|
||||
db_session_with_containers.commit()
|
||||
return workflow
|
||||
|
||||
@staticmethod
|
||||
def create_webhook_trigger(
|
||||
db_session_with_containers: Session,
|
||||
*,
|
||||
app: App,
|
||||
account: Account,
|
||||
node_id: str,
|
||||
webhook_id: str | None = None,
|
||||
) -> WorkflowWebhookTrigger:
|
||||
webhook_trigger = WorkflowWebhookTrigger(
|
||||
app_id=app.id,
|
||||
node_id=node_id,
|
||||
tenant_id=app.tenant_id,
|
||||
webhook_id=webhook_id or uuid4().hex[:24],
|
||||
created_by=account.id,
|
||||
)
|
||||
db_session_with_containers.add(webhook_trigger)
|
||||
db_session_with_containers.commit()
|
||||
return webhook_trigger
|
||||
|
||||
@staticmethod
|
||||
def create_app_trigger(
|
||||
db_session_with_containers: Session,
|
||||
*,
|
||||
app: App,
|
||||
node_id: str,
|
||||
status: AppTriggerStatus,
|
||||
) -> AppTrigger:
|
||||
app_trigger = AppTrigger(
|
||||
tenant_id=app.tenant_id,
|
||||
app_id=app.id,
|
||||
node_id=node_id,
|
||||
trigger_type=AppTriggerType.TRIGGER_WEBHOOK,
|
||||
provider_name="webhook",
|
||||
title=f"Webhook {node_id}",
|
||||
status=status,
|
||||
)
|
||||
db_session_with_containers.add(app_trigger)
|
||||
db_session_with_containers.commit()
|
||||
return app_trigger
|
||||
|
||||
|
||||
class TestWebhookServiceLookupWithContainers:
|
||||
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_missing(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="App trigger not found"):
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_rate_limited(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.RATE_LIMITED
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="rate limited"):
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_disabled(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.DISABLED
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="disabled"):
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_raises_when_workflow_missing(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
factory.create_app_trigger(
|
||||
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.ENABLED
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
|
||||
|
||||
def test_get_webhook_trigger_and_workflow_returns_debug_draft_workflow(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
factory.create_workflow(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_ids=["published-node"],
|
||||
version="2026-04-14.001",
|
||||
)
|
||||
draft_workflow = factory.create_workflow(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_ids=["debug-node"],
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="debug-node"
|
||||
)
|
||||
|
||||
got_trigger, got_workflow, got_node_config = WebhookService.get_webhook_trigger_and_workflow(
|
||||
webhook_trigger.webhook_id,
|
||||
is_debug=True,
|
||||
)
|
||||
|
||||
assert got_trigger.id == webhook_trigger.id
|
||||
assert got_workflow.id == draft_workflow.id
|
||||
assert got_node_config["id"] == "debug-node"
|
||||
|
||||
|
||||
class TestWebhookServiceTriggerExecutionWithContainers:
|
||||
def test_trigger_workflow_execution_triggers_async_workflow_successfully(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
|
||||
end_user = SimpleNamespace(id=str(uuid4()))
|
||||
webhook_data = {"body": {"value": 1}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"}
|
||||
|
||||
quota_charge = MagicMock()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type",
|
||||
return_value=end_user,
|
||||
),
|
||||
patch(
|
||||
"services.trigger.webhook_service.QuotaService.reserve",
|
||||
return_value=quota_charge,
|
||||
) as mock_reserve,
|
||||
patch("services.trigger.webhook_service.AsyncWorkflowService.trigger_workflow_async") as mock_trigger,
|
||||
):
|
||||
WebhookService.trigger_workflow_execution(webhook_trigger, webhook_data, workflow)
|
||||
|
||||
mock_reserve.assert_called_once()
|
||||
reserve_args = mock_reserve.call_args.args
|
||||
assert reserve_args[0] == QuotaType.TRIGGER
|
||||
assert reserve_args[1] == webhook_trigger.tenant_id
|
||||
quota_charge.commit.assert_called_once()
|
||||
mock_trigger.assert_called_once()
|
||||
trigger_args = mock_trigger.call_args.args
|
||||
assert trigger_args[1] is end_user
|
||||
assert trigger_args[2].workflow_id == workflow.id
|
||||
assert trigger_args[2].root_node_id == webhook_trigger.node_id
|
||||
|
||||
def test_trigger_workflow_execution_marks_tenant_rate_limited_when_quota_exceeded(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type",
|
||||
return_value=SimpleNamespace(id=str(uuid4())),
|
||||
),
|
||||
patch(
|
||||
"services.trigger.webhook_service.QuotaService.reserve",
|
||||
side_effect=QuotaExceededError(feature="trigger", tenant_id=tenant.id, required=1),
|
||||
),
|
||||
patch(
|
||||
"services.trigger.webhook_service.AppTriggerService.mark_tenant_triggers_rate_limited"
|
||||
) as mock_mark_rate_limited,
|
||||
):
|
||||
with pytest.raises(QuotaExceededError):
|
||||
WebhookService.trigger_workflow_execution(
|
||||
webhook_trigger,
|
||||
{"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"},
|
||||
workflow,
|
||||
)
|
||||
|
||||
mock_mark_rate_limited.assert_called_once_with(tenant.id)
|
||||
|
||||
def test_trigger_workflow_execution_logs_and_reraises_unexpected_errors(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version="2026-04-14.001"
|
||||
)
|
||||
webhook_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers, app=app, account=account, node_id="node-1"
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type",
|
||||
side_effect=RuntimeError("boom"),
|
||||
),
|
||||
patch("services.trigger.webhook_service.logger.exception") as mock_logger_exception,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
WebhookService.trigger_workflow_execution(
|
||||
webhook_trigger,
|
||||
{"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"},
|
||||
workflow,
|
||||
)
|
||||
|
||||
mock_logger_exception.assert_called_once()
|
||||
|
||||
|
||||
class TestWebhookServiceRelationshipSyncWithContainers:
|
||||
def test_sync_webhook_relationships_raises_when_workflow_exceeds_node_limit(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
node_ids = [f"node-{index}" for index in range(WebhookService.MAX_WEBHOOK_NODES_PER_WORKFLOW + 1)]
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=node_ids, version=Workflow.VERSION_DRAFT
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="maximum webhook node limit"):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
def test_sync_webhook_relationships_raises_when_lock_not_acquired(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=["node-1"], version=Workflow.VERSION_DRAFT
|
||||
)
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = False
|
||||
|
||||
with patch("services.trigger.webhook_service.redis_client.lock", return_value=lock):
|
||||
with pytest.raises(RuntimeError, match="Failed to acquire lock"):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
def test_sync_webhook_relationships_creates_missing_records_and_deletes_stale_records(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
stale_trigger = factory.create_webhook_trigger(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_id="node-stale",
|
||||
webhook_id="stale-webhook-id-000001",
|
||||
)
|
||||
stale_trigger_id = stale_trigger.id
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_ids=["node-new"],
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"services.trigger.webhook_service.WebhookService.generate_webhook_id", return_value="new-webhook-id-000001"
|
||||
):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
db_session_with_containers.expire_all()
|
||||
records = db_session_with_containers.scalars(
|
||||
select(WorkflowWebhookTrigger).where(WorkflowWebhookTrigger.app_id == app.id)
|
||||
).all()
|
||||
|
||||
assert [record.node_id for record in records] == ["node-new"]
|
||||
assert records[0].webhook_id == "new-webhook-id-000001"
|
||||
assert db_session_with_containers.get(WorkflowWebhookTrigger, stale_trigger_id) is None
|
||||
|
||||
def test_sync_webhook_relationships_sets_redis_cache_for_new_record(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers,
|
||||
app=app,
|
||||
account=account,
|
||||
node_ids=["node-cache"],
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
)
|
||||
cache_key = f"{WebhookService.__WEBHOOK_NODE_CACHE_KEY__}:{app.id}:node-cache"
|
||||
|
||||
with patch(
|
||||
"services.trigger.webhook_service.WebhookService.generate_webhook_id", return_value="cache-webhook-id-00001"
|
||||
):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
cached_payload = WebhookServiceRelationshipFactory._read_cache(cache_key)
|
||||
assert cached_payload is not None
|
||||
assert cached_payload["node_id"] == "node-cache"
|
||||
assert cached_payload["webhook_id"] == "cache-webhook-id-00001"
|
||||
|
||||
def test_sync_webhook_relationships_logs_when_lock_release_fails(
|
||||
self, db_session_with_containers: Session, flask_app_with_containers
|
||||
):
|
||||
del flask_app_with_containers
|
||||
factory = WebhookServiceRelationshipFactory
|
||||
account, tenant = factory.create_account_and_tenant(db_session_with_containers)
|
||||
app = factory.create_app(db_session_with_containers, tenant, account)
|
||||
workflow = factory.create_workflow(
|
||||
db_session_with_containers, app=app, account=account, node_ids=[], version=Workflow.VERSION_DRAFT
|
||||
)
|
||||
lock = MagicMock()
|
||||
lock.acquire.return_value = True
|
||||
lock.release.side_effect = RuntimeError("release failed")
|
||||
|
||||
with (
|
||||
patch("services.trigger.webhook_service.redis_client.lock", return_value=lock),
|
||||
patch("services.trigger.webhook_service.logger.exception") as mock_logger_exception,
|
||||
):
|
||||
WebhookService.sync_webhook_relationships(app, workflow)
|
||||
|
||||
mock_logger_exception.assert_called_once()
|
||||
|
||||
|
||||
def _read_cache(cache_key: str) -> dict[str, str] | None:
|
||||
from extensions.ext_redis import redis_client
|
||||
|
||||
cached = redis_client.get(cache_key)
|
||||
if not cached:
|
||||
return None
|
||||
if isinstance(cached, bytes):
|
||||
cached = cached.decode("utf-8")
|
||||
return json.loads(cached)
|
||||
|
||||
|
||||
WebhookServiceRelationshipFactory._read_cache = staticmethod(_read_cache)
|
||||
@@ -602,9 +602,9 @@ def test_schedule_trigger_creates_trigger_log(
|
||||
)
|
||||
|
||||
# Mock quota to avoid rate limiting
|
||||
from enums import quota_type
|
||||
from services import quota_service
|
||||
|
||||
monkeypatch.setattr(quota_type.QuotaType.TRIGGER, "consume", lambda _tenant_id: quota_type.unlimited())
|
||||
monkeypatch.setattr(quota_service.QuotaService, "reserve", lambda *_args, **_kwargs: quota_service.unlimited())
|
||||
|
||||
# Execute schedule trigger
|
||||
workflow_schedule_tasks.run_schedule_trigger(plan.id)
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Unit tests for inner_api app DSL import/export endpoints.
|
||||
|
||||
Tests Pydantic model validation, endpoint handler logic, and the
|
||||
_get_active_account helper. Auth/setup decorators are tested separately
|
||||
in test_auth_wraps.py; handler tests use inspect.unwrap() to bypass them.
|
||||
"""
|
||||
|
||||
import inspect
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.inner_api.app.dsl import (
|
||||
EnterpriseAppDSLExport,
|
||||
EnterpriseAppDSLImport,
|
||||
InnerAppDSLImportPayload,
|
||||
_get_active_account,
|
||||
)
|
||||
from services.app_dsl_service import ImportStatus
|
||||
|
||||
|
||||
class TestInnerAppDSLImportPayload:
|
||||
"""Test InnerAppDSLImportPayload Pydantic model validation."""
|
||||
|
||||
def test_valid_payload_all_fields(self):
|
||||
data = {
|
||||
"yaml_content": "version: 0.6.0\nkind: app\n",
|
||||
"creator_email": "user@example.com",
|
||||
"name": "My App",
|
||||
"description": "A test app",
|
||||
}
|
||||
payload = InnerAppDSLImportPayload.model_validate(data)
|
||||
assert payload.yaml_content == data["yaml_content"]
|
||||
assert payload.creator_email == "user@example.com"
|
||||
assert payload.name == "My App"
|
||||
assert payload.description == "A test app"
|
||||
|
||||
def test_valid_payload_optional_fields_omitted(self):
|
||||
data = {
|
||||
"yaml_content": "version: 0.6.0\n",
|
||||
"creator_email": "user@example.com",
|
||||
}
|
||||
payload = InnerAppDSLImportPayload.model_validate(data)
|
||||
assert payload.name is None
|
||||
assert payload.description is None
|
||||
|
||||
def test_missing_yaml_content_fails(self):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InnerAppDSLImportPayload.model_validate({"creator_email": "a@b.com"})
|
||||
assert "yaml_content" in str(exc_info.value)
|
||||
|
||||
def test_missing_creator_email_fails(self):
|
||||
with pytest.raises(ValidationError) as exc_info:
|
||||
InnerAppDSLImportPayload.model_validate({"yaml_content": "test"})
|
||||
assert "creator_email" in str(exc_info.value)
|
||||
|
||||
|
||||
class TestGetActiveAccount:
|
||||
"""Test the _get_active_account helper function."""
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_returns_active_account(self, mock_db):
|
||||
mock_account = MagicMock()
|
||||
mock_account.status = "active"
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
|
||||
|
||||
result = _get_active_account("user@example.com")
|
||||
|
||||
assert result is mock_account
|
||||
mock_db.session.query.return_value.filter_by.assert_called_once_with(email="user@example.com")
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_returns_none_for_inactive_account(self, mock_db):
|
||||
mock_account = MagicMock()
|
||||
mock_account.status = "banned"
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_account
|
||||
|
||||
result = _get_active_account("banned@example.com")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_returns_none_for_nonexistent_email(self, mock_db):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
result = _get_active_account("missing@example.com")
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestEnterpriseAppDSLImport:
|
||||
"""Test EnterpriseAppDSLImport endpoint handler logic.
|
||||
|
||||
Uses inspect.unwrap() to bypass auth/setup decorators.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterpriseAppDSLImport()
|
||||
|
||||
@pytest.fixture
|
||||
def _mock_import_deps(self):
|
||||
"""Patch db, Session, and AppDslService for import handler tests."""
|
||||
with (
|
||||
patch("controllers.inner_api.app.dsl.db"),
|
||||
patch("controllers.inner_api.app.dsl.Session") as mock_session,
|
||||
patch("controllers.inner_api.app.dsl.AppDslService") as mock_dsl_cls,
|
||||
):
|
||||
mock_session.return_value.__enter__ = MagicMock(return_value=MagicMock())
|
||||
mock_session.return_value.__exit__ = MagicMock(return_value=False)
|
||||
self._mock_dsl = MagicMock()
|
||||
mock_dsl_cls.return_value = self._mock_dsl
|
||||
yield
|
||||
|
||||
def _make_import_result(self, status: ImportStatus, **kwargs) -> "Import":
|
||||
from services.app_dsl_service import Import
|
||||
|
||||
result = Import(
|
||||
id="import-id",
|
||||
status=status,
|
||||
app_id=kwargs.get("app_id", "app-123"),
|
||||
app_mode=kwargs.get("app_mode", "workflow"),
|
||||
)
|
||||
return result
|
||||
|
||||
@pytest.mark.usefixtures("_mock_import_deps")
|
||||
@patch("controllers.inner_api.app.dsl._get_active_account")
|
||||
def test_import_success_returns_200(self, mock_get_account, api_instance, app: Flask):
|
||||
mock_account = MagicMock()
|
||||
mock_get_account.return_value = mock_account
|
||||
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.COMPLETED)
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {
|
||||
"yaml_content": "version: 0.6.0\n",
|
||||
"creator_email": "user@example.com",
|
||||
}
|
||||
result = unwrapped(api_instance, workspace_id="ws-123")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 200
|
||||
assert body["status"] == "completed"
|
||||
mock_account.set_tenant_id.assert_called_once_with("ws-123")
|
||||
|
||||
@pytest.mark.usefixtures("_mock_import_deps")
|
||||
@patch("controllers.inner_api.app.dsl._get_active_account")
|
||||
def test_import_pending_returns_202(self, mock_get_account, api_instance, app: Flask):
|
||||
mock_get_account.return_value = MagicMock()
|
||||
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.PENDING)
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
|
||||
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
|
||||
|
||||
assert status_code == 202
|
||||
assert body["status"] == "pending"
|
||||
|
||||
@pytest.mark.usefixtures("_mock_import_deps")
|
||||
@patch("controllers.inner_api.app.dsl._get_active_account")
|
||||
def test_import_failed_returns_400(self, mock_get_account, api_instance, app: Flask):
|
||||
mock_get_account.return_value = MagicMock()
|
||||
self._mock_dsl.import_app.return_value = self._make_import_result(ImportStatus.FAILED)
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {"yaml_content": "test", "creator_email": "u@e.com"}
|
||||
body, status_code = unwrapped(api_instance, workspace_id="ws-123")
|
||||
|
||||
assert status_code == 400
|
||||
assert body["status"] == "failed"
|
||||
|
||||
@patch("controllers.inner_api.app.dsl._get_active_account")
|
||||
def test_import_account_not_found_returns_404(self, mock_get_account, api_instance, app: Flask):
|
||||
mock_get_account.return_value = None
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.app.dsl.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = {"yaml_content": "test", "creator_email": "missing@e.com"}
|
||||
result = unwrapped(api_instance, workspace_id="ws-123")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 404
|
||||
assert "missing@e.com" in body["message"]
|
||||
|
||||
|
||||
class TestEnterpriseAppDSLExport:
|
||||
"""Test EnterpriseAppDSLExport endpoint handler logic.
|
||||
|
||||
Uses inspect.unwrap() to bypass auth/setup decorators.
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterpriseAppDSLExport()
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.AppDslService")
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_success_returns_200(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
|
||||
mock_app = MagicMock()
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
|
||||
mock_dsl_cls.export_dsl.return_value = "version: 0.6.0\nkind: app\n"
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
with app.test_request_context("?include_secret=false"):
|
||||
result = unwrapped(api_instance, app_id="app-123")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 200
|
||||
assert body["data"] == "version: 0.6.0\nkind: app\n"
|
||||
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=False)
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.AppDslService")
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_with_secret(self, mock_db, mock_dsl_cls, api_instance, app: Flask):
|
||||
mock_app = MagicMock()
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = mock_app
|
||||
mock_dsl_cls.export_dsl.return_value = "yaml-data"
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
with app.test_request_context("?include_secret=true"):
|
||||
result = unwrapped(api_instance, app_id="app-123")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 200
|
||||
mock_dsl_cls.export_dsl.assert_called_once_with(app_model=mock_app, include_secret=True)
|
||||
|
||||
@patch("controllers.inner_api.app.dsl.db")
|
||||
def test_export_app_not_found_returns_404(self, mock_db, api_instance, app: Flask):
|
||||
mock_db.session.query.return_value.filter_by.return_value.first.return_value = None
|
||||
|
||||
unwrapped = inspect.unwrap(api_instance.get)
|
||||
with app.test_request_context("?include_secret=false"):
|
||||
result = unwrapped(api_instance, app_id="nonexistent")
|
||||
|
||||
body, status_code = result
|
||||
assert status_code == 404
|
||||
assert "app not found" in body["message"]
|
||||
@@ -8,6 +8,7 @@ import core.app.apps.pipeline.pipeline_generator as module
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.datasource.entities.datasource_entities import DatasourceProviderType
|
||||
from models.enums import DataSourceType
|
||||
|
||||
|
||||
class FakeRagPipelineGenerateEntity(SimpleNamespace):
|
||||
@@ -558,6 +559,24 @@ def test_build_document_sets_metadata_for_builtin_fields(generator, mocker):
|
||||
assert document.doc_metadata
|
||||
|
||||
|
||||
def test_build_document_supports_online_drive_datasource_type(generator):
|
||||
document = generator._build_document(
|
||||
tenant_id="tenant",
|
||||
dataset_id="ds",
|
||||
built_in_field_enabled=True,
|
||||
datasource_type=DatasourceProviderType.ONLINE_DRIVE,
|
||||
datasource_info={"id": "file-1", "bucket": "bucket-1", "name": "drive.pdf", "type": "file"},
|
||||
created_from="rag-pipeline",
|
||||
position=1,
|
||||
account=_build_user(),
|
||||
batch="batch",
|
||||
document_form="text",
|
||||
)
|
||||
|
||||
assert DataSourceType(document.data_source_type) == DataSourceType.ONLINE_DRIVE
|
||||
assert document.name == "drive.pdf"
|
||||
|
||||
|
||||
def test_build_document_invalid_datasource_type(generator):
|
||||
with pytest.raises(ValueError):
|
||||
generator._build_document(
|
||||
|
||||
+74
-5
@@ -115,14 +115,12 @@ class TestTidbOnQdrantVectorDeleteByIds:
|
||||
|
||||
assert exc_info.value.status_code == 500
|
||||
|
||||
def test_delete_by_ids_with_large_batch(self, vector_instance):
|
||||
"""Test deletion with a large batch of IDs."""
|
||||
# Create 1000 IDs
|
||||
def test_delete_by_ids_with_exactly_1000(self, vector_instance):
|
||||
"""Test deletion with exactly 1000 IDs triggers a single batch."""
|
||||
ids = [f"doc_{i}" for i in range(1000)]
|
||||
|
||||
vector_instance.delete_by_ids(ids)
|
||||
|
||||
# Verify single delete call with all IDs
|
||||
vector_instance._client.delete.assert_called_once()
|
||||
call_args = vector_instance._client.delete.call_args
|
||||
|
||||
@@ -130,11 +128,28 @@ class TestTidbOnQdrantVectorDeleteByIds:
|
||||
filter_obj = filter_selector.filter
|
||||
field_condition = filter_obj.must[0]
|
||||
|
||||
# Verify all 1000 IDs are in the batch
|
||||
assert len(field_condition.match.any) == 1000
|
||||
assert "doc_0" in field_condition.match.any
|
||||
assert "doc_999" in field_condition.match.any
|
||||
|
||||
def test_delete_by_ids_splits_into_batches(self, vector_instance):
|
||||
"""Test deletion with >1000 IDs triggers multiple batched calls."""
|
||||
ids = [f"doc_{i}" for i in range(2500)]
|
||||
|
||||
vector_instance.delete_by_ids(ids)
|
||||
|
||||
assert vector_instance._client.delete.call_count == 3
|
||||
|
||||
batches = []
|
||||
for call in vector_instance._client.delete.call_args_list:
|
||||
filter_selector = call[1]["points_selector"]
|
||||
field_condition = filter_selector.filter.must[0]
|
||||
batches.append(field_condition.match.any)
|
||||
|
||||
assert len(batches[0]) == 1000
|
||||
assert len(batches[1]) == 1000
|
||||
assert len(batches[2]) == 500
|
||||
|
||||
def test_delete_by_ids_filter_structure(self, vector_instance):
|
||||
"""Test that the filter structure is correctly constructed."""
|
||||
ids = ["doc1", "doc2"]
|
||||
@@ -158,3 +173,57 @@ class TestTidbOnQdrantVectorDeleteByIds:
|
||||
# Verify MatchAny structure
|
||||
assert isinstance(field_condition.match, rest.MatchAny)
|
||||
assert field_condition.match.any == ids
|
||||
|
||||
|
||||
class TestInitVectorEndpointSelection:
|
||||
"""Test that init_vector selects the correct qdrant endpoint.
|
||||
|
||||
We avoid importing the full module (which triggers Flask app context)
|
||||
by testing the endpoint selection logic directly on TidbOnQdrantConfig.
|
||||
"""
|
||||
|
||||
def test_uses_binding_endpoint_when_present(self):
|
||||
binding_endpoint = "https://qdrant-custom.tidb.com"
|
||||
global_url = "https://qdrant-global.tidb.com"
|
||||
|
||||
qdrant_url = binding_endpoint or global_url or ""
|
||||
|
||||
assert qdrant_url == "https://qdrant-custom.tidb.com"
|
||||
config = TidbOnQdrantConfig(endpoint=qdrant_url)
|
||||
assert config.endpoint == "https://qdrant-custom.tidb.com"
|
||||
|
||||
def test_falls_back_to_global_when_binding_endpoint_is_none(self):
|
||||
binding_endpoint = None
|
||||
global_url = "https://qdrant-global.tidb.com"
|
||||
|
||||
qdrant_url = binding_endpoint or global_url or ""
|
||||
|
||||
assert qdrant_url == "https://qdrant-global.tidb.com"
|
||||
config = TidbOnQdrantConfig(endpoint=qdrant_url)
|
||||
assert config.endpoint == "https://qdrant-global.tidb.com"
|
||||
|
||||
def test_falls_back_to_empty_when_both_none(self):
|
||||
binding_endpoint = None
|
||||
global_url = None
|
||||
|
||||
qdrant_url = binding_endpoint or global_url or ""
|
||||
|
||||
assert qdrant_url == ""
|
||||
config = TidbOnQdrantConfig(endpoint=qdrant_url)
|
||||
assert config.endpoint == ""
|
||||
|
||||
def test_binding_endpoint_takes_precedence_over_global(self):
|
||||
binding_endpoint = "https://qdrant-ap-southeast.tidb.com"
|
||||
global_url = "https://qdrant-us-east.tidb.com"
|
||||
|
||||
qdrant_url = binding_endpoint or global_url or ""
|
||||
|
||||
assert qdrant_url == "https://qdrant-ap-southeast.tidb.com"
|
||||
|
||||
def test_empty_string_binding_endpoint_falls_back_to_global(self):
|
||||
binding_endpoint = ""
|
||||
global_url = "https://qdrant-global.tidb.com"
|
||||
|
||||
qdrant_url = binding_endpoint or global_url or ""
|
||||
|
||||
assert qdrant_url == "https://qdrant-global.tidb.com"
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from core.rag.datasource.vdb.tidb_on_qdrant.tidb_service import TidbService
|
||||
from models.enums import TidbAuthBindingStatus
|
||||
|
||||
|
||||
class TestExtractQdrantEndpoint:
|
||||
"""Unit tests for TidbService.extract_qdrant_endpoint."""
|
||||
|
||||
def test_returns_endpoint_when_host_present(self):
|
||||
response = {"endpoints": {"public": {"host": "gateway01.us-east-1.tidbcloud.com", "port": 4000}}}
|
||||
result = TidbService.extract_qdrant_endpoint(response)
|
||||
assert result == "https://qdrant-gateway01.us-east-1.tidbcloud.com"
|
||||
|
||||
def test_returns_none_when_host_missing(self):
|
||||
response = {"endpoints": {"public": {}}}
|
||||
assert TidbService.extract_qdrant_endpoint(response) is None
|
||||
|
||||
def test_returns_none_when_public_missing(self):
|
||||
response = {"endpoints": {}}
|
||||
assert TidbService.extract_qdrant_endpoint(response) is None
|
||||
|
||||
def test_returns_none_when_endpoints_missing(self):
|
||||
assert TidbService.extract_qdrant_endpoint({}) is None
|
||||
|
||||
|
||||
class TestFetchQdrantEndpoint:
|
||||
"""Unit tests for TidbService.fetch_qdrant_endpoint."""
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
def test_returns_endpoint_when_host_present(self, mock_get_cluster):
|
||||
mock_get_cluster.return_value = {
|
||||
"endpoints": {"public": {"host": "gateway01.us-east-1.tidbcloud.com", "port": 4000}}
|
||||
}
|
||||
result = TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123")
|
||||
assert result == "https://qdrant-gateway01.us-east-1.tidbcloud.com"
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
def test_returns_none_when_cluster_response_is_none(self, mock_get_cluster):
|
||||
mock_get_cluster.return_value = None
|
||||
assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
def test_returns_none_when_host_missing(self, mock_get_cluster):
|
||||
mock_get_cluster.return_value = {"endpoints": {"public": {}}}
|
||||
assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
def test_returns_none_when_endpoints_missing(self, mock_get_cluster):
|
||||
mock_get_cluster.return_value = {}
|
||||
assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
def test_returns_none_on_exception(self, mock_get_cluster):
|
||||
mock_get_cluster.side_effect = RuntimeError("network error")
|
||||
assert TidbService.fetch_qdrant_endpoint("url", "pub", "priv", "c-123") is None
|
||||
|
||||
|
||||
class TestCreateTidbServerlessClusterQdrantEndpoint:
|
||||
"""Verify that create_tidb_serverless_cluster includes qdrant_endpoint in its result."""
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
|
||||
def test_result_contains_qdrant_endpoint(self, mock_config, mock_http, mock_get_cluster):
|
||||
mock_config.TIDB_SPEND_LIMIT = 10
|
||||
mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"})
|
||||
mock_get_cluster.return_value = {
|
||||
"state": "ACTIVE",
|
||||
"userPrefix": "pfx",
|
||||
"endpoints": {"public": {"host": "gw.tidbcloud.com", "port": 4000}},
|
||||
}
|
||||
|
||||
result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
|
||||
|
||||
assert result is not None
|
||||
assert result["qdrant_endpoint"] == "https://qdrant-gw.tidbcloud.com"
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
|
||||
def test_result_qdrant_endpoint_none_when_no_endpoints(self, mock_config, mock_http, mock_get_cluster):
|
||||
mock_config.TIDB_SPEND_LIMIT = 10
|
||||
mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"})
|
||||
mock_get_cluster.return_value = {"state": "ACTIVE", "userPrefix": "pfx"}
|
||||
|
||||
result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
|
||||
|
||||
assert result is not None
|
||||
assert result["qdrant_endpoint"] is None
|
||||
|
||||
|
||||
class TestBatchCreateTidbServerlessClusterQdrantEndpoint:
|
||||
"""Verify that batch_create includes qdrant_endpoint per cluster."""
|
||||
|
||||
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.redis_client")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
|
||||
def test_batch_result_contains_qdrant_endpoint(self, mock_config, mock_http, mock_redis, mock_fetch_ep):
|
||||
mock_config.TIDB_SPEND_LIMIT = 10
|
||||
cluster_name = "abc123"
|
||||
mock_http.post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"clusters": [{"clusterId": "c-1", "displayName": cluster_name}]},
|
||||
)
|
||||
mock_redis.setex = MagicMock()
|
||||
mock_redis.get.return_value = b"password123"
|
||||
|
||||
result = TidbService.batch_create_tidb_serverless_cluster(
|
||||
batch_size=1,
|
||||
project_id="proj",
|
||||
api_url="url",
|
||||
iam_url="iam",
|
||||
public_key="pub",
|
||||
private_key="priv",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]["qdrant_endpoint"] == "https://qdrant-gw.tidbcloud.com"
|
||||
|
||||
|
||||
class TestCreateTidbServerlessClusterRetry:
|
||||
"""Cover retry/logging paths in create_tidb_serverless_cluster."""
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
|
||||
def test_polls_until_active(self, mock_config, mock_http, mock_get_cluster):
|
||||
mock_config.TIDB_SPEND_LIMIT = 10
|
||||
mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"})
|
||||
mock_get_cluster.side_effect = [
|
||||
{"state": "CREATING", "userPrefix": ""},
|
||||
{"state": "ACTIVE", "userPrefix": "pfx", "endpoints": {"public": {"host": "gw.tidb.com"}}},
|
||||
]
|
||||
|
||||
with patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.time.sleep"):
|
||||
result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
|
||||
|
||||
assert result is not None
|
||||
assert result["qdrant_endpoint"] == "https://qdrant-gw.tidb.com"
|
||||
assert mock_get_cluster.call_count == 2
|
||||
|
||||
@patch.object(TidbService, "get_tidb_serverless_cluster")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
|
||||
def test_returns_none_after_max_retries(self, mock_config, mock_http, mock_get_cluster):
|
||||
mock_config.TIDB_SPEND_LIMIT = 10
|
||||
mock_http.post.return_value = MagicMock(status_code=200, json=lambda: {"clusterId": "c-1"})
|
||||
mock_get_cluster.return_value = {"state": "CREATING", "userPrefix": ""}
|
||||
|
||||
with patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.time.sleep"):
|
||||
result = TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
|
||||
|
||||
assert result is None
|
||||
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
|
||||
def test_raises_on_post_failure(self, mock_config, mock_http):
|
||||
mock_config.TIDB_SPEND_LIMIT = 10
|
||||
mock_response = MagicMock(status_code=400, text="Bad Request")
|
||||
mock_response.raise_for_status.side_effect = Exception("HTTP 400")
|
||||
mock_http.post.return_value = mock_response
|
||||
|
||||
with pytest.raises(Exception, match="HTTP 400"):
|
||||
TidbService.create_tidb_serverless_cluster("proj", "url", "iam", "pub", "priv", "us-east-1")
|
||||
|
||||
|
||||
class TestBatchCreateEdgeCases:
|
||||
"""Cover logging/edge-case branches in batch_create."""
|
||||
|
||||
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value=None)
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.redis_client")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
|
||||
def test_skips_cluster_when_no_cached_password(self, mock_config, mock_http, mock_redis, mock_fetch_ep):
|
||||
mock_config.TIDB_SPEND_LIMIT = 10
|
||||
mock_http.post.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"clusters": [{"clusterId": "c-1", "displayName": "name1"}]},
|
||||
)
|
||||
mock_redis.setex = MagicMock()
|
||||
mock_redis.get.return_value = None
|
||||
|
||||
result = TidbService.batch_create_tidb_serverless_cluster(
|
||||
batch_size=1,
|
||||
project_id="proj",
|
||||
api_url="url",
|
||||
iam_url="iam",
|
||||
public_key="pub",
|
||||
private_key="priv",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
assert len(result) == 0
|
||||
mock_fetch_ep.assert_not_called()
|
||||
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.redis_client")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.dify_config")
|
||||
def test_raises_on_post_failure(self, mock_config, mock_http, mock_redis):
|
||||
mock_config.TIDB_SPEND_LIMIT = 10
|
||||
mock_response = MagicMock(status_code=500, text="Server Error")
|
||||
mock_response.raise_for_status.side_effect = Exception("HTTP 500")
|
||||
mock_http.post.return_value = mock_response
|
||||
mock_redis.setex = MagicMock()
|
||||
|
||||
with pytest.raises(Exception, match="HTTP 500"):
|
||||
TidbService.batch_create_tidb_serverless_cluster(
|
||||
batch_size=1,
|
||||
project_id="proj",
|
||||
api_url="url",
|
||||
iam_url="iam",
|
||||
public_key="pub",
|
||||
private_key="priv",
|
||||
region="us-east-1",
|
||||
)
|
||||
|
||||
|
||||
class TestBatchUpdateTidbServerlessClusterStatus:
|
||||
"""Verify that status updates only expose clusters after qdrant endpoint is ready."""
|
||||
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.db")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
def test_sets_active_when_batch_response_contains_endpoint(self, mock_http, mock_db):
|
||||
binding = SimpleNamespace(
|
||||
cluster_id="c-1",
|
||||
status=TidbAuthBindingStatus.CREATING,
|
||||
account="root",
|
||||
qdrant_endpoint=None,
|
||||
)
|
||||
mock_http.get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"clusters": [
|
||||
{
|
||||
"clusterId": "c-1",
|
||||
"state": "ACTIVE",
|
||||
"userPrefix": "pfx",
|
||||
"endpoints": {"public": {"host": "gw.tidbcloud.com"}},
|
||||
}
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
|
||||
|
||||
assert binding.account == "pfx.root"
|
||||
assert binding.qdrant_endpoint == "https://qdrant-gw.tidbcloud.com"
|
||||
assert binding.status == TidbAuthBindingStatus.ACTIVE
|
||||
mock_db.session.add.assert_called_once_with(binding)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value="https://qdrant-gw.tidbcloud.com")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.db")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
def test_fetches_endpoint_when_batch_response_omits_it(self, mock_http, mock_db, mock_fetch_endpoint):
|
||||
binding = SimpleNamespace(
|
||||
cluster_id="c-1",
|
||||
status=TidbAuthBindingStatus.CREATING,
|
||||
account="root",
|
||||
qdrant_endpoint=None,
|
||||
)
|
||||
mock_http.get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"clusters": [{"clusterId": "c-1", "state": "ACTIVE", "userPrefix": "pfx", "endpoints": {}}]
|
||||
},
|
||||
)
|
||||
|
||||
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
|
||||
|
||||
assert binding.account == "pfx.root"
|
||||
assert binding.qdrant_endpoint == "https://qdrant-gw.tidbcloud.com"
|
||||
assert binding.status == TidbAuthBindingStatus.ACTIVE
|
||||
mock_fetch_endpoint.assert_called_once_with("url", "pub", "priv", "c-1")
|
||||
mock_db.session.add.assert_called_once_with(binding)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
|
||||
@patch.object(TidbService, "fetch_qdrant_endpoint", return_value=None)
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.db")
|
||||
@patch("core.rag.datasource.vdb.tidb_on_qdrant.tidb_service.httpx")
|
||||
def test_keeps_creating_when_endpoint_is_not_ready(self, mock_http, mock_db, mock_fetch_endpoint):
|
||||
binding = SimpleNamespace(
|
||||
cluster_id="c-1",
|
||||
status=TidbAuthBindingStatus.CREATING,
|
||||
account="root",
|
||||
qdrant_endpoint=None,
|
||||
)
|
||||
mock_http.get.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {
|
||||
"clusters": [{"clusterId": "c-1", "state": "ACTIVE", "userPrefix": "pfx", "endpoints": {}}]
|
||||
},
|
||||
)
|
||||
|
||||
TidbService.batch_update_tidb_serverless_cluster_status([binding], "proj", "url", "iam", "pub", "priv")
|
||||
|
||||
assert binding.account == "pfx.root"
|
||||
assert binding.qdrant_endpoint is None
|
||||
assert binding.status == TidbAuthBindingStatus.CREATING
|
||||
mock_fetch_endpoint.assert_called_once_with("url", "pub", "priv", "c-1")
|
||||
mock_db.session.add.assert_called_once_with(binding)
|
||||
mock_db.session.commit.assert_called_once()
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Unit tests for QuotaType, QuotaService, and QuotaCharge."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from enums.quota_type import QuotaType
|
||||
from services.quota_service import QuotaCharge, QuotaService, unlimited
|
||||
|
||||
|
||||
class TestQuotaType:
|
||||
def test_billing_key_trigger(self):
|
||||
assert QuotaType.TRIGGER.billing_key == "trigger_event"
|
||||
|
||||
def test_billing_key_workflow(self):
|
||||
assert QuotaType.WORKFLOW.billing_key == "api_rate_limit"
|
||||
|
||||
def test_billing_key_unlimited_raises(self):
|
||||
with pytest.raises(ValueError, match="Invalid quota type"):
|
||||
_ = QuotaType.UNLIMITED.billing_key
|
||||
|
||||
|
||||
class TestQuotaService:
|
||||
def test_reserve_billing_disabled(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService"),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = False
|
||||
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1")
|
||||
assert charge.success is True
|
||||
assert charge.charge_id is None
|
||||
|
||||
def test_reserve_zero_amount_raises(self):
|
||||
with patch("services.quota_service.dify_config") as mock_cfg:
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=0)
|
||||
|
||||
def test_reserve_success(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.return_value = {"reservation_id": "rid-1", "available": 99}
|
||||
|
||||
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1", amount=1)
|
||||
|
||||
assert charge.success is True
|
||||
assert charge.charge_id == "rid-1"
|
||||
assert charge._tenant_id == "t1"
|
||||
assert charge._feature_key == "trigger_event"
|
||||
assert charge._amount == 1
|
||||
mock_bs.quota_reserve.assert_called_once()
|
||||
|
||||
def test_reserve_no_reservation_id_raises(self):
|
||||
from services.errors.app import QuotaExceededError
|
||||
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.return_value = {}
|
||||
|
||||
with pytest.raises(QuotaExceededError):
|
||||
QuotaService.reserve(QuotaType.TRIGGER, "t1")
|
||||
|
||||
def test_reserve_quota_exceeded_propagates(self):
|
||||
from services.errors.app import QuotaExceededError
|
||||
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.side_effect = QuotaExceededError(feature="trigger", tenant_id="t1", required=1)
|
||||
|
||||
with pytest.raises(QuotaExceededError):
|
||||
QuotaService.reserve(QuotaType.TRIGGER, "t1")
|
||||
|
||||
def test_reserve_api_exception_returns_unlimited(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.side_effect = RuntimeError("network")
|
||||
|
||||
charge = QuotaService.reserve(QuotaType.TRIGGER, "t1")
|
||||
assert charge.success is True
|
||||
assert charge.charge_id is None
|
||||
|
||||
def test_consume_calls_reserve_and_commit(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_reserve.return_value = {"reservation_id": "rid-c"}
|
||||
mock_bs.quota_commit.return_value = {}
|
||||
|
||||
charge = QuotaService.consume(QuotaType.TRIGGER, "t1")
|
||||
assert charge.success is True
|
||||
mock_bs.quota_commit.assert_called_once()
|
||||
|
||||
def test_check_billing_disabled(self):
|
||||
with patch("services.quota_service.dify_config") as mock_cfg:
|
||||
mock_cfg.BILLING_ENABLED = False
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1") is True
|
||||
|
||||
def test_check_zero_amount_raises(self):
|
||||
with patch("services.quota_service.dify_config") as mock_cfg:
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
with pytest.raises(ValueError, match="greater than 0"):
|
||||
QuotaService.check(QuotaType.TRIGGER, "t1", amount=0)
|
||||
|
||||
def test_check_sufficient_quota(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch.object(QuotaService, "get_remaining", return_value=100),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=50) is True
|
||||
|
||||
def test_check_insufficient_quota(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch.object(QuotaService, "get_remaining", return_value=5),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=10) is False
|
||||
|
||||
def test_check_unlimited_quota(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch.object(QuotaService, "get_remaining", return_value=-1),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1", amount=999) is True
|
||||
|
||||
def test_check_exception_returns_true(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch.object(QuotaService, "get_remaining", side_effect=RuntimeError),
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
assert QuotaService.check(QuotaType.TRIGGER, "t1") is True
|
||||
|
||||
def test_release_billing_disabled(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = False
|
||||
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
|
||||
mock_bs.quota_release.assert_not_called()
|
||||
|
||||
def test_release_empty_reservation(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
QuotaService.release(QuotaType.TRIGGER, "", "t1", "trigger_event")
|
||||
mock_bs.quota_release.assert_not_called()
|
||||
|
||||
def test_release_success(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_release.return_value = {}
|
||||
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
|
||||
mock_bs.quota_release.assert_called_once_with(
|
||||
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1"
|
||||
)
|
||||
|
||||
def test_release_exception_swallowed(self):
|
||||
with (
|
||||
patch("services.quota_service.dify_config") as mock_cfg,
|
||||
patch("services.billing_service.BillingService") as mock_bs,
|
||||
):
|
||||
mock_cfg.BILLING_ENABLED = True
|
||||
mock_bs.quota_release.side_effect = RuntimeError("fail")
|
||||
QuotaService.release(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
|
||||
|
||||
def test_get_remaining_normal(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 100, "usage": 30}}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 70
|
||||
|
||||
def test_get_remaining_unlimited(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": -1, "usage": 0}}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1
|
||||
|
||||
def test_get_remaining_over_limit_returns_zero(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"trigger_event": {"limit": 10, "usage": 15}}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
|
||||
|
||||
def test_get_remaining_exception_returns_neg1(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.side_effect = RuntimeError
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == -1
|
||||
|
||||
def test_get_remaining_empty_response(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
|
||||
|
||||
def test_get_remaining_non_dict_response(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = "invalid"
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
|
||||
|
||||
def test_get_remaining_feature_not_in_response(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"other_feature": {"limit": 100, "usage": 0}}
|
||||
remaining = QuotaService.get_remaining(QuotaType.TRIGGER, "t1")
|
||||
assert remaining == 0
|
||||
|
||||
def test_get_remaining_non_dict_feature_info(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.get_quota_info.return_value = {"trigger_event": "not_a_dict"}
|
||||
assert QuotaService.get_remaining(QuotaType.TRIGGER, "t1") == 0
|
||||
|
||||
|
||||
class TestQuotaCharge:
|
||||
def test_commit_success(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.quota_commit.return_value = {}
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
_amount=1,
|
||||
)
|
||||
charge.commit()
|
||||
mock_bs.quota_commit.assert_called_once_with(
|
||||
tenant_id="t1",
|
||||
feature_key="trigger_event",
|
||||
reservation_id="rid-1",
|
||||
actual_amount=1,
|
||||
)
|
||||
assert charge._committed is True
|
||||
|
||||
def test_commit_with_actual_amount(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.quota_commit.return_value = {}
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
_amount=10,
|
||||
)
|
||||
charge.commit(actual_amount=5)
|
||||
call_kwargs = mock_bs.quota_commit.call_args[1]
|
||||
assert call_kwargs["actual_amount"] == 5
|
||||
|
||||
def test_commit_idempotent(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.quota_commit.return_value = {}
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
_amount=1,
|
||||
)
|
||||
charge.commit()
|
||||
charge.commit()
|
||||
assert mock_bs.quota_commit.call_count == 1
|
||||
|
||||
def test_commit_no_charge_id_noop(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER)
|
||||
charge.commit()
|
||||
mock_bs.quota_commit.assert_not_called()
|
||||
|
||||
def test_commit_no_tenant_id_noop(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id=None,
|
||||
_feature_key="trigger_event",
|
||||
)
|
||||
charge.commit()
|
||||
mock_bs.quota_commit.assert_not_called()
|
||||
|
||||
def test_commit_exception_swallowed(self):
|
||||
with patch("services.billing_service.BillingService") as mock_bs:
|
||||
mock_bs.quota_commit.side_effect = RuntimeError("fail")
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
_amount=1,
|
||||
)
|
||||
charge.commit()
|
||||
|
||||
def test_refund_success(self):
|
||||
with patch.object(QuotaService, "release") as mock_rel:
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id="t1",
|
||||
_feature_key="trigger_event",
|
||||
)
|
||||
charge.refund()
|
||||
mock_rel.assert_called_once_with(QuotaType.TRIGGER, "rid-1", "t1", "trigger_event")
|
||||
|
||||
def test_refund_no_charge_id_noop(self):
|
||||
with patch.object(QuotaService, "release") as mock_rel:
|
||||
charge = QuotaCharge(success=True, charge_id=None, _quota_type=QuotaType.TRIGGER)
|
||||
charge.refund()
|
||||
mock_rel.assert_not_called()
|
||||
|
||||
def test_refund_no_tenant_id_noop(self):
|
||||
with patch.object(QuotaService, "release") as mock_rel:
|
||||
charge = QuotaCharge(
|
||||
success=True,
|
||||
charge_id="rid-1",
|
||||
_quota_type=QuotaType.TRIGGER,
|
||||
_tenant_id=None,
|
||||
)
|
||||
charge.refund()
|
||||
mock_rel.assert_not_called()
|
||||
|
||||
|
||||
class TestUnlimited:
|
||||
def test_unlimited_returns_success_with_no_charge_id(self):
|
||||
charge = unlimited()
|
||||
assert charge.success is True
|
||||
assert charge.charge_id is None
|
||||
assert charge._quota_type == QuotaType.UNLIMITED
|
||||
@@ -23,6 +23,7 @@ import pytest
|
||||
|
||||
import services.app_generate_service as ags_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from enums.quota_type import QuotaType
|
||||
from models.model import AppMode
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.errors.app import WorkflowIdFormatError, WorkflowNotFoundError
|
||||
@@ -447,8 +448,8 @@ class TestGenerateBilling:
|
||||
def test_billing_enabled_consumes_quota(self, mocker, monkeypatch):
|
||||
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
|
||||
quota_charge = MagicMock()
|
||||
consume_mock = mocker.patch(
|
||||
"services.app_generate_service.QuotaType.WORKFLOW.consume",
|
||||
reserve_mock = mocker.patch(
|
||||
"services.app_generate_service.QuotaService.reserve",
|
||||
return_value=quota_charge,
|
||||
)
|
||||
mocker.patch(
|
||||
@@ -467,7 +468,8 @@ class TestGenerateBilling:
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
streaming=False,
|
||||
)
|
||||
consume_mock.assert_called_once_with("tenant-id")
|
||||
reserve_mock.assert_called_once_with(QuotaType.WORKFLOW, "tenant-id")
|
||||
quota_charge.commit.assert_called_once()
|
||||
|
||||
def test_billing_quota_exceeded_raises_rate_limit_error(self, mocker, monkeypatch):
|
||||
from services.errors.app import QuotaExceededError
|
||||
@@ -475,7 +477,7 @@ class TestGenerateBilling:
|
||||
|
||||
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
|
||||
mocker.patch(
|
||||
"services.app_generate_service.QuotaType.WORKFLOW.consume",
|
||||
"services.app_generate_service.QuotaService.reserve",
|
||||
side_effect=QuotaExceededError(feature="workflow", tenant_id="t", required=1),
|
||||
)
|
||||
|
||||
@@ -492,7 +494,7 @@ class TestGenerateBilling:
|
||||
monkeypatch.setattr(ags_module.dify_config, "BILLING_ENABLED", True)
|
||||
quota_charge = MagicMock()
|
||||
mocker.patch(
|
||||
"services.app_generate_service.QuotaType.WORKFLOW.consume",
|
||||
"services.app_generate_service.QuotaService.reserve",
|
||||
return_value=quota_charge,
|
||||
)
|
||||
mocker.patch(
|
||||
|
||||
@@ -57,7 +57,7 @@ class TestAsyncWorkflowService:
|
||||
- repo: SQLAlchemyWorkflowTriggerLogRepository
|
||||
- dispatcher_manager_class: QueueDispatcherManager class
|
||||
- dispatcher: dispatcher instance
|
||||
- quota_workflow: QuotaType.WORKFLOW
|
||||
- quota_service: QuotaService mock
|
||||
- get_workflow: AsyncWorkflowService._get_workflow method
|
||||
- professional_task: execute_workflow_professional
|
||||
- team_task: execute_workflow_team
|
||||
@@ -72,12 +72,7 @@ class TestAsyncWorkflowService:
|
||||
mock_repo.create.side_effect = _create_side_effect
|
||||
|
||||
mock_dispatcher = MagicMock()
|
||||
quota_workflow = MagicMock()
|
||||
mock_get_workflow = MagicMock()
|
||||
|
||||
mock_professional_task = MagicMock()
|
||||
mock_team_task = MagicMock()
|
||||
mock_sandbox_task = MagicMock()
|
||||
mock_quota_service = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
@@ -93,8 +88,8 @@ class TestAsyncWorkflowService:
|
||||
) as mock_get_workflow,
|
||||
patch.object(
|
||||
async_workflow_service_module,
|
||||
"QuotaType",
|
||||
new=SimpleNamespace(WORKFLOW=quota_workflow),
|
||||
"QuotaService",
|
||||
new=mock_quota_service,
|
||||
),
|
||||
patch.object(async_workflow_service_module, "execute_workflow_professional") as mock_professional_task,
|
||||
patch.object(async_workflow_service_module, "execute_workflow_team") as mock_team_task,
|
||||
@@ -107,7 +102,7 @@ class TestAsyncWorkflowService:
|
||||
"repo": mock_repo,
|
||||
"dispatcher_manager_class": mock_dispatcher_manager_class,
|
||||
"dispatcher": mock_dispatcher,
|
||||
"quota_workflow": quota_workflow,
|
||||
"quota_service": mock_quota_service,
|
||||
"get_workflow": mock_get_workflow,
|
||||
"professional_task": mock_professional_task,
|
||||
"team_task": mock_team_task,
|
||||
@@ -146,6 +141,9 @@ class TestAsyncWorkflowService:
|
||||
mocks["team_task"].delay.return_value = task_result
|
||||
mocks["sandbox_task"].delay.return_value = task_result
|
||||
|
||||
quota_charge_mock = MagicMock()
|
||||
mocks["quota_service"].reserve.return_value = quota_charge_mock
|
||||
|
||||
class DummyAccount:
|
||||
def __init__(self, user_id: str):
|
||||
self.id = user_id
|
||||
@@ -163,8 +161,9 @@ class TestAsyncWorkflowService:
|
||||
assert result.status == "queued"
|
||||
assert result.queue == queue_name
|
||||
|
||||
mocks["quota_workflow"].consume.assert_called_once_with("tenant-123")
|
||||
assert session.commit.call_count == 2
|
||||
mocks["quota_service"].reserve.assert_called_once()
|
||||
quota_charge_mock.commit.assert_called_once()
|
||||
assert session.commit.call_count == 3
|
||||
|
||||
created_log = mocks["repo"].create.call_args[0][0]
|
||||
assert created_log.status == WorkflowTriggerStatus.QUEUED
|
||||
@@ -250,7 +249,7 @@ class TestAsyncWorkflowService:
|
||||
mocks = async_workflow_trigger_mocks
|
||||
mocks["dispatcher"].get_queue_name.return_value = QueuePriority.TEAM
|
||||
mocks["get_workflow"].return_value = workflow
|
||||
mocks["quota_workflow"].consume.side_effect = QuotaExceededError(
|
||||
mocks["quota_service"].reserve.side_effect = QuotaExceededError(
|
||||
feature="workflow",
|
||||
tenant_id="tenant-123",
|
||||
required=1,
|
||||
@@ -267,7 +266,7 @@ class TestAsyncWorkflowService:
|
||||
trigger_data=trigger_data,
|
||||
)
|
||||
|
||||
assert session.commit.call_count == 2
|
||||
assert session.commit.call_count == 3
|
||||
updated_log = mocks["repo"].update.call_args[0][0]
|
||||
assert updated_log.status == WorkflowTriggerStatus.RATE_LIMITED
|
||||
assert "Quota limit reached" in updated_log.error
|
||||
@@ -463,7 +462,7 @@ class TestAsyncWorkflowServiceGetWorkflow:
|
||||
|
||||
# Assert
|
||||
assert result == workflow
|
||||
workflow_service.get_published_workflow_by_id.assert_called_once_with(app_model, "workflow-123")
|
||||
workflow_service.get_published_workflow_by_id.assert_called_once_with(app_model, "workflow-123", session=None)
|
||||
workflow_service.get_published_workflow.assert_not_called()
|
||||
|
||||
def test_should_raise_when_specific_workflow_id_not_found(self):
|
||||
@@ -491,7 +490,7 @@ class TestAsyncWorkflowServiceGetWorkflow:
|
||||
|
||||
# Assert
|
||||
assert result == workflow
|
||||
workflow_service.get_published_workflow.assert_called_once_with(app_model)
|
||||
workflow_service.get_published_workflow.assert_called_once_with(app_model, session=None)
|
||||
workflow_service.get_published_workflow_by_id.assert_not_called()
|
||||
|
||||
def test_should_raise_when_default_published_workflow_not_found(self):
|
||||
|
||||
@@ -290,9 +290,19 @@ class TestBillingServiceSubscriptionInfo:
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
expected_response = {
|
||||
"subscription_plan": "professional",
|
||||
"billing_cycle": "monthly",
|
||||
"status": "active",
|
||||
"enabled": True,
|
||||
"subscription": {"plan": "professional", "interval": "month", "education": False},
|
||||
"members": {"size": 1, "limit": 50},
|
||||
"apps": {"size": 1, "limit": 200},
|
||||
"vector_space": {"size": 0.0, "limit": 20480},
|
||||
"knowledge_rate_limit": {"limit": 1000},
|
||||
"documents_upload_quota": {"size": 0, "limit": 1000},
|
||||
"annotation_quota_limit": {"size": 0, "limit": 5000},
|
||||
"docs_processing": "top-priority",
|
||||
"can_replace_logo": True,
|
||||
"model_load_balancing_enabled": True,
|
||||
"knowledge_pipeline_publish_enabled": True,
|
||||
"next_credit_reset_date": 1775952000,
|
||||
}
|
||||
mock_send_request.return_value = expected_response
|
||||
|
||||
@@ -415,7 +425,7 @@ class TestBillingServiceUsageCalculation:
|
||||
yield mock
|
||||
|
||||
def test_get_tenant_feature_plan_usage_info(self, mock_send_request):
|
||||
"""Test retrieval of tenant feature plan usage information."""
|
||||
"""Test retrieval of tenant feature plan usage information (legacy endpoint)."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
expected_response = {"features": {"trigger": {"used": 50, "limit": 100}, "workflow": {"used": 20, "limit": 50}}}
|
||||
@@ -428,6 +438,20 @@ class TestBillingServiceUsageCalculation:
|
||||
assert result == expected_response
|
||||
mock_send_request.assert_called_once_with("GET", "/tenant-feature-usage/info", params={"tenant_id": tenant_id})
|
||||
|
||||
def test_get_quota_info(self, mock_send_request):
|
||||
"""Test retrieval of quota info from new endpoint."""
|
||||
# Arrange
|
||||
tenant_id = "tenant-123"
|
||||
expected_response = {"trigger_event": {"limit": 100, "usage": 30}, "api_rate_limit": {"limit": -1, "usage": 0}}
|
||||
mock_send_request.return_value = expected_response
|
||||
|
||||
# Act
|
||||
result = BillingService.get_quota_info(tenant_id)
|
||||
|
||||
# Assert
|
||||
assert result == expected_response
|
||||
mock_send_request.assert_called_once_with("GET", "/quota/info", params={"tenant_id": tenant_id})
|
||||
|
||||
def test_update_tenant_feature_plan_usage_positive_delta(self, mock_send_request):
|
||||
"""Test updating tenant feature usage with positive delta (adding credits)."""
|
||||
# Arrange
|
||||
@@ -505,6 +529,150 @@ class TestBillingServiceUsageCalculation:
|
||||
)
|
||||
|
||||
|
||||
class TestBillingServiceQuotaOperations:
|
||||
"""Unit tests for quota reserve/commit/release operations."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_send_request(self):
|
||||
with patch.object(BillingService, "_send_request") as mock:
|
||||
yield mock
|
||||
|
||||
def test_quota_reserve_success(self, mock_send_request):
|
||||
expected = {"reservation_id": "rid-1", "available": 99, "reserved": 1}
|
||||
mock_send_request.return_value = expected
|
||||
|
||||
result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-1", amount=1)
|
||||
|
||||
assert result == expected
|
||||
mock_send_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/quota/reserve",
|
||||
json={"tenant_id": "t1", "feature_key": "trigger_event", "request_id": "req-1", "amount": 1},
|
||||
)
|
||||
|
||||
def test_quota_reserve_coerces_string_to_int(self, mock_send_request):
|
||||
"""Test that TypeAdapter coerces string values to int."""
|
||||
mock_send_request.return_value = {"reservation_id": "rid-str", "available": "99", "reserved": "1"}
|
||||
|
||||
result = BillingService.quota_reserve(tenant_id="t1", feature_key="trigger_event", request_id="req-s", amount=1)
|
||||
|
||||
assert result["available"] == 99
|
||||
assert isinstance(result["available"], int)
|
||||
assert result["reserved"] == 1
|
||||
assert isinstance(result["reserved"], int)
|
||||
|
||||
def test_quota_reserve_with_meta(self, mock_send_request):
|
||||
mock_send_request.return_value = {"reservation_id": "rid-2", "available": 98, "reserved": 1}
|
||||
meta = {"source": "webhook"}
|
||||
|
||||
BillingService.quota_reserve(
|
||||
tenant_id="t1", feature_key="trigger_event", request_id="req-2", amount=1, meta=meta
|
||||
)
|
||||
|
||||
call_json = mock_send_request.call_args[1]["json"]
|
||||
assert call_json["meta"] == {"source": "webhook"}
|
||||
|
||||
def test_quota_commit_success(self, mock_send_request):
|
||||
expected = {"available": 98, "reserved": 0, "refunded": 0}
|
||||
mock_send_request.return_value = expected
|
||||
|
||||
result = BillingService.quota_commit(
|
||||
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1
|
||||
)
|
||||
|
||||
assert result == expected
|
||||
mock_send_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/quota/commit",
|
||||
json={
|
||||
"tenant_id": "t1",
|
||||
"feature_key": "trigger_event",
|
||||
"reservation_id": "rid-1",
|
||||
"actual_amount": 1,
|
||||
},
|
||||
)
|
||||
|
||||
def test_quota_commit_coerces_string_to_int(self, mock_send_request):
|
||||
"""Test that TypeAdapter coerces string values to int."""
|
||||
mock_send_request.return_value = {"available": "97", "reserved": "0", "refunded": "1"}
|
||||
|
||||
result = BillingService.quota_commit(
|
||||
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s", actual_amount=1
|
||||
)
|
||||
|
||||
assert result["available"] == 97
|
||||
assert isinstance(result["available"], int)
|
||||
assert result["refunded"] == 1
|
||||
assert isinstance(result["refunded"], int)
|
||||
|
||||
def test_quota_commit_with_meta(self, mock_send_request):
|
||||
mock_send_request.return_value = {"available": 97, "reserved": 0, "refunded": 0}
|
||||
meta = {"reason": "partial"}
|
||||
|
||||
BillingService.quota_commit(
|
||||
tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1", actual_amount=1, meta=meta
|
||||
)
|
||||
|
||||
call_json = mock_send_request.call_args[1]["json"]
|
||||
assert call_json["meta"] == {"reason": "partial"}
|
||||
|
||||
def test_quota_release_success(self, mock_send_request):
|
||||
expected = {"available": 100, "reserved": 0, "released": 1}
|
||||
mock_send_request.return_value = expected
|
||||
|
||||
result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-1")
|
||||
|
||||
assert result == expected
|
||||
mock_send_request.assert_called_once_with(
|
||||
"POST",
|
||||
"/quota/release",
|
||||
json={"tenant_id": "t1", "feature_key": "trigger_event", "reservation_id": "rid-1"},
|
||||
)
|
||||
|
||||
def test_quota_release_coerces_string_to_int(self, mock_send_request):
|
||||
"""Test that TypeAdapter coerces string values to int."""
|
||||
mock_send_request.return_value = {"available": "100", "reserved": "0", "released": "1"}
|
||||
|
||||
result = BillingService.quota_release(tenant_id="t1", feature_key="trigger_event", reservation_id="rid-s")
|
||||
|
||||
assert result["available"] == 100
|
||||
assert isinstance(result["available"], int)
|
||||
assert result["released"] == 1
|
||||
assert isinstance(result["released"], int)
|
||||
|
||||
def test_get_quota_info_coerces_string_to_int(self, mock_send_request):
|
||||
"""Test that TypeAdapter coerces string values to int for get_quota_info."""
|
||||
mock_send_request.return_value = {
|
||||
"trigger_event": {"usage": "42", "limit": "3000", "reset_date": "1700000000"},
|
||||
"api_rate_limit": {"usage": "10", "limit": "-1", "reset_date": "-1"},
|
||||
}
|
||||
|
||||
result = BillingService.get_quota_info("t1")
|
||||
|
||||
assert result["trigger_event"]["usage"] == 42
|
||||
assert isinstance(result["trigger_event"]["usage"], int)
|
||||
assert result["trigger_event"]["limit"] == 3000
|
||||
assert isinstance(result["trigger_event"]["limit"], int)
|
||||
assert result["trigger_event"]["reset_date"] == 1700000000
|
||||
assert isinstance(result["trigger_event"]["reset_date"], int)
|
||||
assert result["api_rate_limit"]["limit"] == -1
|
||||
assert isinstance(result["api_rate_limit"]["limit"], int)
|
||||
|
||||
def test_get_quota_info_accepts_int_values(self, mock_send_request):
|
||||
"""Test that get_quota_info works with native int values."""
|
||||
expected = {
|
||||
"trigger_event": {"usage": 42, "limit": 3000, "reset_date": 1700000000},
|
||||
"api_rate_limit": {"usage": 0, "limit": -1},
|
||||
}
|
||||
mock_send_request.return_value = expected
|
||||
|
||||
result = BillingService.get_quota_info("t1")
|
||||
|
||||
assert result["trigger_event"]["usage"] == 42
|
||||
assert result["trigger_event"]["limit"] == 3000
|
||||
assert result["api_rate_limit"]["limit"] == -1
|
||||
|
||||
|
||||
class TestBillingServiceRateLimitEnforcement:
|
||||
"""Unit tests for rate limit enforcement mechanisms.
|
||||
|
||||
@@ -1009,17 +1177,14 @@ class TestBillingServiceEdgeCases:
|
||||
yield mock
|
||||
|
||||
def test_get_info_empty_response(self, mock_send_request):
|
||||
"""Test handling of empty billing info response."""
|
||||
# Arrange
|
||||
"""Empty response from billing API should raise ValidationError due to missing required fields."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
tenant_id = "tenant-empty"
|
||||
mock_send_request.return_value = {}
|
||||
|
||||
# Act
|
||||
result = BillingService.get_info(tenant_id)
|
||||
|
||||
# Assert
|
||||
assert result == {}
|
||||
mock_send_request.assert_called_once()
|
||||
with pytest.raises(ValidationError):
|
||||
BillingService.get_info(tenant_id)
|
||||
|
||||
def test_update_tenant_feature_plan_usage_zero_delta(self, mock_send_request):
|
||||
"""Test updating tenant feature usage with zero delta (no change)."""
|
||||
@@ -1434,12 +1599,21 @@ class TestBillingServiceIntegrationScenarios:
|
||||
|
||||
# Step 1: Get current billing info
|
||||
mock_send_request.return_value = {
|
||||
"subscription_plan": "sandbox",
|
||||
"billing_cycle": "monthly",
|
||||
"status": "active",
|
||||
"enabled": True,
|
||||
"subscription": {"plan": "sandbox", "interval": "", "education": False},
|
||||
"members": {"size": 0, "limit": 1},
|
||||
"apps": {"size": 0, "limit": 5},
|
||||
"vector_space": {"size": 0.0, "limit": 50},
|
||||
"knowledge_rate_limit": {"limit": 10},
|
||||
"documents_upload_quota": {"size": 0, "limit": 50},
|
||||
"annotation_quota_limit": {"size": 0, "limit": 10},
|
||||
"docs_processing": "standard",
|
||||
"can_replace_logo": False,
|
||||
"model_load_balancing_enabled": False,
|
||||
"knowledge_pipeline_publish_enabled": False,
|
||||
}
|
||||
current_info = BillingService.get_info(tenant_id)
|
||||
assert current_info["subscription_plan"] == "sandbox"
|
||||
assert current_info["subscription"]["plan"] == "sandbox"
|
||||
|
||||
# Step 2: Get payment link for upgrade
|
||||
mock_send_request.return_value = {"payment_link": "https://payment.example.com/upgrade"}
|
||||
@@ -1553,3 +1727,140 @@ class TestBillingServiceIntegrationScenarios:
|
||||
mock_send_request.return_value = {"result": "success", "activated": True}
|
||||
activate_result = BillingService.EducationIdentity.activate(account, "token-123", "MIT", "student")
|
||||
assert activate_result["activated"] is True
|
||||
|
||||
|
||||
class TestBillingServiceSubscriptionInfoDataType:
|
||||
"""Unit tests for data type coercion in BillingService.get_info
|
||||
|
||||
1. Verifies the get_info returns correct Python types for numeric fields
|
||||
2. Ensure the compatibility regardless of what results the upstream billing API returns
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_send_request(self):
|
||||
with patch.object(BillingService, "_send_request") as mock:
|
||||
yield mock
|
||||
|
||||
@pytest.fixture
|
||||
def normal_billing_response(self) -> dict:
|
||||
return {
|
||||
"enabled": True,
|
||||
"subscription": {
|
||||
"plan": "team",
|
||||
"interval": "year",
|
||||
"education": False,
|
||||
},
|
||||
"members": {"size": 10, "limit": 50},
|
||||
"apps": {"size": 80, "limit": 200},
|
||||
"vector_space": {"size": 5120.75, "limit": 20480},
|
||||
"knowledge_rate_limit": {"limit": 1000},
|
||||
"documents_upload_quota": {"size": 450, "limit": 1000},
|
||||
"annotation_quota_limit": {"size": 1200, "limit": 5000},
|
||||
"docs_processing": "top-priority",
|
||||
"can_replace_logo": True,
|
||||
"model_load_balancing_enabled": True,
|
||||
"knowledge_pipeline_publish_enabled": True,
|
||||
"next_credit_reset_date": 1745971200,
|
||||
}
|
||||
|
||||
@pytest.fixture
|
||||
def string_billing_response(self) -> dict:
|
||||
return {
|
||||
"enabled": True,
|
||||
"subscription": {
|
||||
"plan": "team",
|
||||
"interval": "year",
|
||||
"education": False,
|
||||
},
|
||||
"members": {"size": "10", "limit": "50"},
|
||||
"apps": {"size": "80", "limit": "200"},
|
||||
"vector_space": {"size": 5120.75, "limit": "20480"},
|
||||
"knowledge_rate_limit": {"limit": "1000"},
|
||||
"documents_upload_quota": {"size": "450", "limit": "1000"},
|
||||
"annotation_quota_limit": {"size": "1200", "limit": "5000"},
|
||||
"docs_processing": "top-priority",
|
||||
"can_replace_logo": True,
|
||||
"model_load_balancing_enabled": True,
|
||||
"knowledge_pipeline_publish_enabled": True,
|
||||
"next_credit_reset_date": "1745971200",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _assert_billing_info_types(result: dict):
|
||||
assert isinstance(result["enabled"], bool)
|
||||
assert isinstance(result["subscription"]["plan"], str)
|
||||
assert isinstance(result["subscription"]["interval"], str)
|
||||
assert isinstance(result["subscription"]["education"], bool)
|
||||
|
||||
assert isinstance(result["members"]["size"], int)
|
||||
assert isinstance(result["members"]["limit"], int)
|
||||
|
||||
assert isinstance(result["apps"]["size"], int)
|
||||
assert isinstance(result["apps"]["limit"], int)
|
||||
|
||||
assert isinstance(result["vector_space"]["size"], float)
|
||||
assert isinstance(result["vector_space"]["limit"], int)
|
||||
|
||||
assert isinstance(result["knowledge_rate_limit"]["limit"], int)
|
||||
|
||||
assert isinstance(result["documents_upload_quota"]["size"], int)
|
||||
assert isinstance(result["documents_upload_quota"]["limit"], int)
|
||||
|
||||
assert isinstance(result["annotation_quota_limit"]["size"], int)
|
||||
assert isinstance(result["annotation_quota_limit"]["limit"], int)
|
||||
|
||||
assert isinstance(result["docs_processing"], str)
|
||||
assert isinstance(result["can_replace_logo"], bool)
|
||||
assert isinstance(result["model_load_balancing_enabled"], bool)
|
||||
assert isinstance(result["knowledge_pipeline_publish_enabled"], bool)
|
||||
if "next_credit_reset_date" in result:
|
||||
assert isinstance(result["next_credit_reset_date"], int)
|
||||
|
||||
def test_get_info_with_normal_types(self, mock_send_request, normal_billing_response):
|
||||
"""When the billing API returns native numeric types, get_info should preserve them."""
|
||||
mock_send_request.return_value = normal_billing_response
|
||||
|
||||
result = BillingService.get_info("tenant-type-test")
|
||||
|
||||
self._assert_billing_info_types(result)
|
||||
mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"})
|
||||
|
||||
def test_get_info_with_string_types(self, mock_send_request, string_billing_response):
|
||||
"""When the billing API returns numeric values as strings, get_info should coerce them."""
|
||||
mock_send_request.return_value = string_billing_response
|
||||
|
||||
result = BillingService.get_info("tenant-type-test")
|
||||
|
||||
self._assert_billing_info_types(result)
|
||||
mock_send_request.assert_called_once_with("GET", "/subscription/info", params={"tenant_id": "tenant-type-test"})
|
||||
|
||||
def test_get_info_without_optional_fields(self, mock_send_request, string_billing_response):
|
||||
"""NotRequired fields can be absent without raising."""
|
||||
del string_billing_response["next_credit_reset_date"]
|
||||
mock_send_request.return_value = string_billing_response
|
||||
|
||||
result = BillingService.get_info("tenant-type-test")
|
||||
|
||||
assert "next_credit_reset_date" not in result
|
||||
self._assert_billing_info_types(result)
|
||||
|
||||
def test_get_info_with_extra_fields(self, mock_send_request, string_billing_response):
|
||||
"""Undefined fields are silently stripped by validate_python."""
|
||||
string_billing_response["new_feature"] = "something"
|
||||
mock_send_request.return_value = string_billing_response
|
||||
|
||||
result = BillingService.get_info("tenant-type-test")
|
||||
|
||||
# extra fields are dropped by TypeAdapter on TypedDict
|
||||
assert "new_feature" not in result
|
||||
self._assert_billing_info_types(result)
|
||||
|
||||
def test_get_info_missing_required_field_raises(self, mock_send_request, string_billing_response):
|
||||
"""Missing a required field should raise ValidationError."""
|
||||
from pydantic import ValidationError
|
||||
|
||||
del string_billing_response["members"]
|
||||
mock_send_request.return_value = string_billing_response
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
BillingService.get_info("tenant-type-test")
|
||||
|
||||
@@ -337,10 +337,7 @@ class TestWorkflowService:
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock()
|
||||
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_draft_workflow(app)
|
||||
|
||||
@@ -350,10 +347,7 @@ class TestWorkflowService:
|
||||
"""Test get_draft_workflow returns None when no draft exists."""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
|
||||
# Mock database query to return None
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = None
|
||||
mock_db_session.session.scalar.return_value = None
|
||||
|
||||
result = workflow_service.get_draft_workflow(app)
|
||||
|
||||
@@ -365,10 +359,7 @@ class TestWorkflowService:
|
||||
workflow_id = "workflow-123"
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(version="v1")
|
||||
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_draft_workflow(app, workflow_id=workflow_id)
|
||||
|
||||
@@ -383,10 +374,7 @@ class TestWorkflowService:
|
||||
workflow_id = "workflow-123"
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
|
||||
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_published_workflow_by_id(app, workflow_id)
|
||||
|
||||
@@ -405,10 +393,7 @@ class TestWorkflowService:
|
||||
workflow_id=workflow_id, version=Workflow.VERSION_DRAFT
|
||||
)
|
||||
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
|
||||
with pytest.raises(IsDraftWorkflowError):
|
||||
workflow_service.get_published_workflow_by_id(app, workflow_id)
|
||||
@@ -418,10 +403,7 @@ class TestWorkflowService:
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock()
|
||||
workflow_id = "nonexistent-workflow"
|
||||
|
||||
# Mock database query to return None
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = None
|
||||
mock_db_session.session.scalar.return_value = None
|
||||
|
||||
result = workflow_service.get_published_workflow_by_id(app, workflow_id)
|
||||
|
||||
@@ -433,10 +415,7 @@ class TestWorkflowService:
|
||||
app = TestWorkflowAssociatedDataFactory.create_app_mock(workflow_id=workflow_id)
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(workflow_id=workflow_id, version="v1")
|
||||
|
||||
# Mock database query
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
|
||||
result = workflow_service.get_published_workflow(app)
|
||||
|
||||
@@ -465,11 +444,7 @@ class TestWorkflowService:
|
||||
graph = TestWorkflowAssociatedDataFactory.create_valid_workflow_graph()
|
||||
features = {"file_upload": {"enabled": False}}
|
||||
|
||||
# Mock get_draft_workflow to return None (no existing draft)
|
||||
# This simulates the first time a workflow is created for an app
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = None
|
||||
mock_db_session.session.scalar.return_value = None
|
||||
|
||||
with (
|
||||
patch.object(workflow_service, "validate_features_structure"),
|
||||
@@ -506,9 +481,7 @@ class TestWorkflowService:
|
||||
# Mock existing draft workflow
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(unique_hash=unique_hash)
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
|
||||
with (
|
||||
patch.object(workflow_service, "validate_features_structure"),
|
||||
@@ -547,9 +520,7 @@ class TestWorkflowService:
|
||||
# Mock existing draft workflow with different hash
|
||||
mock_workflow = TestWorkflowAssociatedDataFactory.create_workflow_mock(unique_hash="old-hash")
|
||||
|
||||
mock_query = MagicMock()
|
||||
mock_db_session.session.query.return_value = mock_query
|
||||
mock_query.where.return_value.first.return_value = mock_workflow
|
||||
mock_db_session.session.scalar.return_value = mock_workflow
|
||||
|
||||
with pytest.raises(WorkflowHashNotEqualError):
|
||||
workflow_service.sync_draft_workflow(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
import tasks.trigger_processing_tasks as trigger_processing_tasks_module
|
||||
from services.errors.app import QuotaExceededError
|
||||
from tasks.trigger_processing_tasks import dispatch_triggered_workflow
|
||||
|
||||
|
||||
class TestDispatchTriggeredWorkflow:
|
||||
"""Unit tests covering branch behaviours of ``dispatch_triggered_workflow``.
|
||||
|
||||
The covered branches are:
|
||||
- workflow missing for ``plugin_trigger.app_id`` → log + ``continue``
|
||||
- ``QuotaService.reserve`` raising ``QuotaExceededError`` →
|
||||
``mark_tenant_triggers_rate_limited`` + early ``return``
|
||||
- ``trigger_workflow_async`` succeeds →
|
||||
``quota_charge.commit()`` + ``dispatched_count`` increments
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def subscription(self):
|
||||
sub = MagicMock()
|
||||
sub.id = "subscription-123"
|
||||
sub.tenant_id = "tenant-123"
|
||||
sub.provider_id = "langgenius/test_plugin/test_plugin"
|
||||
sub.endpoint_id = "endpoint-123"
|
||||
sub.credentials = {}
|
||||
sub.credential_type = "api_key"
|
||||
return sub
|
||||
|
||||
@pytest.fixture
|
||||
def plugin_trigger(self):
|
||||
trigger = MagicMock()
|
||||
trigger.id = "plugin-trigger-123"
|
||||
trigger.app_id = "app-123"
|
||||
trigger.node_id = "node-123"
|
||||
return trigger
|
||||
|
||||
@pytest.fixture
|
||||
def provider_controller(self):
|
||||
controller = MagicMock()
|
||||
controller.plugin_unique_identifier = "langgenius/test_plugin:0.0.1"
|
||||
controller.entity.identity.name = "Test Plugin"
|
||||
controller.entity.identity.icon = "icon.svg"
|
||||
controller.entity.identity.icon_dark = "icon_dark.svg"
|
||||
return controller
|
||||
|
||||
@pytest.fixture
|
||||
def dispatch_mocks(self, subscription, plugin_trigger, provider_controller):
|
||||
"""Patch all external dependencies reached by ``dispatch_triggered_workflow``.
|
||||
|
||||
Defaults are configured so the code flow can reach the final async
|
||||
trigger block (line ~385); each test overrides specific handles
|
||||
(``get_workflows``, ``reserve``, ``create_end_user_batch``, ...) to
|
||||
drive the path it targets.
|
||||
"""
|
||||
session_cm = MagicMock()
|
||||
session_cm.__enter__.return_value = MagicMock()
|
||||
session_cm.__exit__.return_value = False
|
||||
|
||||
invoke_response = MagicMock()
|
||||
invoke_response.cancelled = False
|
||||
invoke_response.variables = {}
|
||||
|
||||
quota_charge = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.TriggerHttpRequestCachingService,
|
||||
"get_request",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.TriggerHttpRequestCachingService,
|
||||
"get_payload",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.TriggerSubscriptionOperatorService,
|
||||
"get_subscriber_triggers",
|
||||
return_value=[plugin_trigger],
|
||||
),
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.TriggerManager,
|
||||
"get_trigger_provider",
|
||||
return_value=provider_controller,
|
||||
),
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.TriggerManager,
|
||||
"invoke_trigger_event",
|
||||
return_value=invoke_response,
|
||||
) as invoke_trigger_event,
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.TriggerEventNodeData,
|
||||
"model_validate",
|
||||
return_value=MagicMock(),
|
||||
),
|
||||
patch.object(
|
||||
trigger_processing_tasks_module,
|
||||
"_get_latest_workflows_by_app_ids",
|
||||
) as get_workflows,
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.EndUserService,
|
||||
"create_end_user_batch",
|
||||
return_value={},
|
||||
) as create_end_user_batch,
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.session_factory,
|
||||
"create_session",
|
||||
return_value=session_cm,
|
||||
),
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.QuotaService,
|
||||
"reserve",
|
||||
return_value=quota_charge,
|
||||
) as reserve,
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.AppTriggerService,
|
||||
"mark_tenant_triggers_rate_limited",
|
||||
) as mark_rate_limited,
|
||||
patch.object(
|
||||
trigger_processing_tasks_module.AsyncWorkflowService,
|
||||
"trigger_workflow_async",
|
||||
) as trigger_workflow_async,
|
||||
):
|
||||
yield {
|
||||
"get_workflows": get_workflows,
|
||||
"reserve": reserve,
|
||||
"quota_charge": quota_charge,
|
||||
"mark_rate_limited": mark_rate_limited,
|
||||
"invoke_trigger_event": invoke_trigger_event,
|
||||
"invoke_response": invoke_response,
|
||||
"create_end_user_batch": create_end_user_batch,
|
||||
"trigger_workflow_async": trigger_workflow_async,
|
||||
}
|
||||
|
||||
def test_dispatch_skips_when_workflow_missing(self, subscription, dispatch_mocks):
|
||||
"""Covers missing workflow → log + ``continue``."""
|
||||
dispatch_mocks["get_workflows"].return_value = {}
|
||||
|
||||
dispatched = dispatch_triggered_workflow(
|
||||
user_id="user-123",
|
||||
subscription=subscription,
|
||||
event_name="test_event",
|
||||
request_id="request-123",
|
||||
)
|
||||
|
||||
assert dispatched == 0
|
||||
dispatch_mocks["reserve"].assert_not_called()
|
||||
dispatch_mocks["invoke_trigger_event"].assert_not_called()
|
||||
dispatch_mocks["mark_rate_limited"].assert_not_called()
|
||||
|
||||
def test_dispatch_marks_rate_limited_when_quota_exceeded(self, subscription, plugin_trigger, dispatch_mocks):
|
||||
"""Covers QuotaExceededError → mark rate-limited + early return."""
|
||||
workflow_mock = MagicMock()
|
||||
workflow_mock.walk_nodes.return_value = iter(
|
||||
[(plugin_trigger.node_id, {"type": trigger_processing_tasks_module.TRIGGER_PLUGIN_NODE_TYPE})]
|
||||
)
|
||||
dispatch_mocks["get_workflows"].return_value = {plugin_trigger.app_id: workflow_mock}
|
||||
dispatch_mocks["reserve"].side_effect = QuotaExceededError(
|
||||
feature="trigger", tenant_id=subscription.tenant_id, required=1
|
||||
)
|
||||
|
||||
dispatched = dispatch_triggered_workflow(
|
||||
user_id="user-123",
|
||||
subscription=subscription,
|
||||
event_name="test_event",
|
||||
request_id="request-123",
|
||||
)
|
||||
|
||||
assert dispatched == 0
|
||||
dispatch_mocks["reserve"].assert_called_once()
|
||||
dispatch_mocks["mark_rate_limited"].assert_called_once_with(subscription.tenant_id)
|
||||
dispatch_mocks["invoke_trigger_event"].assert_not_called()
|
||||
|
||||
def test_dispatch_commits_quota_and_counts_when_workflow_triggered(
|
||||
self, subscription, plugin_trigger, dispatch_mocks
|
||||
):
|
||||
"""Happy path: end user exists and async trigger succeeds."""
|
||||
workflow_mock = MagicMock()
|
||||
workflow_mock.id = "workflow-123"
|
||||
workflow_mock.walk_nodes.return_value = iter(
|
||||
[(plugin_trigger.node_id, {"type": trigger_processing_tasks_module.TRIGGER_PLUGIN_NODE_TYPE})]
|
||||
)
|
||||
dispatch_mocks["get_workflows"].return_value = {plugin_trigger.app_id: workflow_mock}
|
||||
|
||||
end_user_mock = MagicMock()
|
||||
dispatch_mocks["create_end_user_batch"].return_value = {plugin_trigger.app_id: end_user_mock}
|
||||
|
||||
dispatched = dispatch_triggered_workflow(
|
||||
user_id="user-123",
|
||||
subscription=subscription,
|
||||
event_name="test_event",
|
||||
request_id="request-123",
|
||||
)
|
||||
|
||||
assert dispatched == 1
|
||||
dispatch_mocks["trigger_workflow_async"].assert_called_once()
|
||||
_, kwargs = dispatch_mocks["trigger_workflow_async"].call_args
|
||||
assert kwargs["user"] is end_user_mock
|
||||
dispatch_mocks["quota_charge"].commit.assert_called_once()
|
||||
dispatch_mocks["quota_charge"].refund.assert_not_called()
|
||||
dispatch_mocks["mark_rate_limited"].assert_not_called()
|
||||
Generated
+1
-1
@@ -1457,7 +1457,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.2
|
||||
image: langgenius/dify-web:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@@ -245,7 +245,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.12
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
restart: always
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
@@ -269,7 +269,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@@ -97,7 +97,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.12
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
@@ -123,7 +123,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
env_file:
|
||||
- ./middleware.env
|
||||
|
||||
@@ -731,7 +731,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -773,7 +773,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -812,7 +812,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.2
|
||||
image: langgenius/dify-api:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@@ -842,7 +842,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.2
|
||||
image: langgenius/dify-web:1.13.3
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
@@ -955,7 +955,7 @@ services:
|
||||
|
||||
# The DifySandbox
|
||||
sandbox:
|
||||
image: langgenius/dify-sandbox:0.2.12
|
||||
image: langgenius/dify-sandbox:0.2.14
|
||||
restart: always
|
||||
environment:
|
||||
# The DifySandbox configurations
|
||||
@@ -979,7 +979,7 @@ services:
|
||||
|
||||
# plugin daemon
|
||||
plugin_daemon:
|
||||
image: langgenius/dify-plugin-daemon:0.5.4-local
|
||||
image: langgenius/dify-plugin-daemon:0.5.3-local
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
|
||||
@@ -5,7 +5,8 @@ app:
|
||||
max_workers: 4
|
||||
max_requests: 50
|
||||
worker_timeout: 5
|
||||
python_path: /usr/local/bin/python3
|
||||
python_path: /opt/python/bin/python3
|
||||
nodejs_path: /usr/local/bin/node
|
||||
enable_network: True # please make sure there is no network risk in your environment
|
||||
allowed_syscalls: # please leave it empty if you have no idea how seccomp works
|
||||
proxy:
|
||||
|
||||
@@ -5,7 +5,7 @@ app:
|
||||
max_workers: 4
|
||||
max_requests: 50
|
||||
worker_timeout: 5
|
||||
python_path: /usr/local/bin/python3
|
||||
python_path: /opt/python/bin/python3
|
||||
python_lib_path:
|
||||
- /usr/local/lib/python3.10
|
||||
- /usr/lib/python3.10
|
||||
|
||||
@@ -501,6 +501,16 @@ describe('Question component', () => {
|
||||
expect(onRegenerate).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render default question avatar icon when questionIcon is not provided', () => {
|
||||
const { container } = renderWithProvider(
|
||||
makeItem(),
|
||||
vi.fn() as unknown as OnRegenerate,
|
||||
)
|
||||
|
||||
const defaultIcon = container.querySelector('.question-default-user-icon')
|
||||
expect(defaultIcon).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render custom questionIcon when provided', () => {
|
||||
const { container } = renderWithProvider(
|
||||
makeItem(),
|
||||
@@ -509,7 +519,7 @@ describe('Question component', () => {
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('custom-question-icon')).toBeInTheDocument()
|
||||
const defaultIcon = container.querySelector('.i-custom-public-avatar-user')
|
||||
const defaultIcon = container.querySelector('.question-default-user-icon')
|
||||
expect(defaultIcon).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from 'react-textarea-autosize'
|
||||
import { FileList } from '@/app/components/base/file-uploader'
|
||||
import { User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import ActionButton from '../../action-button'
|
||||
@@ -243,7 +244,7 @@ const Question: FC<QuestionProps> = ({
|
||||
{
|
||||
questionIcon || (
|
||||
<div className="h-full w-full rounded-full border-[0.5px] border-black/5">
|
||||
<div className="i-custom-public-avatar-user h-full w-full" />
|
||||
<User className="question-default-user-icon h-full w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -142,7 +142,7 @@ const ApiKeyModal = ({
|
||||
onExtraButtonClick={onRemove}
|
||||
disabled={disabled || isLoading || doingAction}
|
||||
clickOutsideNotClose={true}
|
||||
wrapperClassName="!z-[101]"
|
||||
wrapperClassName="!z-[1002]"
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
<ReadmeEntrance pluginDetail={pluginPayload.detail} showType={ReadmeShowType.modal} />
|
||||
|
||||
@@ -157,7 +157,7 @@ const OAuthClientSettings = ({
|
||||
)
|
||||
}
|
||||
containerClassName="pt-0"
|
||||
wrapperClassName="!z-[101]"
|
||||
wrapperClassName="!z-[1002]"
|
||||
clickOutsideNotClose={true}
|
||||
>
|
||||
{pluginPayload.detail && (
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen, waitFor } from '@testing-library/react'
|
||||
import WorkflowApp from '../index'
|
||||
|
||||
const mockSetTriggerStatuses = vi.fn()
|
||||
const mockSetInputs = vi.fn()
|
||||
const mockSetShowInputsPanel = vi.fn()
|
||||
const mockSetShowDebugAndPreviewPanel = vi.fn()
|
||||
const mockWorkflowStoreSetState = vi.fn()
|
||||
const mockDebouncedCancel = vi.fn()
|
||||
const mockFetchRunDetail = vi.fn()
|
||||
const mockInitialNodes = vi.fn()
|
||||
const mockInitialEdges = vi.fn()
|
||||
const mockGetWorkflowRunAndTraceUrl = vi.fn()
|
||||
|
||||
let appStoreState: {
|
||||
appDetail?: {
|
||||
id: string
|
||||
mode: string
|
||||
}
|
||||
}
|
||||
|
||||
let workflowInitState: {
|
||||
data: {
|
||||
graph: {
|
||||
nodes: Array<Record<string, unknown>>
|
||||
edges: Array<Record<string, unknown>>
|
||||
viewport: { x: number, y: number, zoom: number }
|
||||
}
|
||||
features: Record<string, unknown>
|
||||
} | null
|
||||
isLoading: boolean
|
||||
fileUploadConfigResponse: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
let appContextState: {
|
||||
isLoadingCurrentWorkspace: boolean
|
||||
currentWorkspace: {
|
||||
id?: string
|
||||
}
|
||||
}
|
||||
|
||||
let appTriggersState: {
|
||||
data?: {
|
||||
data: Array<{
|
||||
node_id: string
|
||||
status: string
|
||||
}>
|
||||
}
|
||||
}
|
||||
|
||||
let searchParamsValue: string | null = null
|
||||
|
||||
const mockWorkflowStore = {
|
||||
setState: mockWorkflowStoreSetState,
|
||||
getState: () => ({
|
||||
setInputs: mockSetInputs,
|
||||
setShowInputsPanel: mockSetShowInputsPanel,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
debouncedSyncWorkflowDraft: {
|
||||
cancel: mockDebouncedCancel,
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: <T,>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store/trigger-status', () => ({
|
||||
useTriggerStatusStore: () => ({
|
||||
setTriggerStatuses: mockSetTriggerStatuses,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/app-context', () => ({
|
||||
useAppContext: () => appContextState,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
useSearchParams: () => ({
|
||||
get: (key: string) => (key === 'replayRunId' ? searchParamsValue : null),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/log', () => ({
|
||||
fetchRunDetail: (...args: unknown[]) => mockFetchRunDetail(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-tools', () => ({
|
||||
useAppTriggers: () => appTriggersState,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks/use-workflow-init', () => ({
|
||||
useWorkflowInit: () => workflowInitState,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks/use-get-run-and-trace-url', () => ({
|
||||
useGetRunAndTraceUrl: () => ({
|
||||
getWorkflowRunAndTraceUrl: mockGetWorkflowRunAndTraceUrl,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
return {
|
||||
...actual,
|
||||
initialNodes: (...args: unknown[]) => mockInitialNodes(...args),
|
||||
initialEdges: (...args: unknown[]) => mockInitialEdges(...args),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/loading', () => ({
|
||||
default: () => <div data-testid="loading">loading</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features', () => ({
|
||||
FeaturesProvider: ({
|
||||
features,
|
||||
children,
|
||||
}: {
|
||||
features: Record<string, unknown>
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div data-testid="features-provider" data-features={JSON.stringify(features)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
default: ({
|
||||
nodes,
|
||||
edges,
|
||||
children,
|
||||
}: {
|
||||
nodes: Array<Record<string, unknown>>
|
||||
edges: Array<Record<string, unknown>>
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div data-testid="workflow-default-context" data-nodes={JSON.stringify(nodes)} data-edges={JSON.stringify(edges)}>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/context', () => ({
|
||||
WorkflowContextProvider: ({
|
||||
children,
|
||||
}: {
|
||||
injectWorkflowStoreSliceFn: unknown
|
||||
children: ReactNode
|
||||
}) => (
|
||||
<div data-testid="workflow-context-provider">{children}</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/components/workflow-main', () => ({
|
||||
default: ({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
}: {
|
||||
nodes: Array<Record<string, unknown>>
|
||||
edges: Array<Record<string, unknown>>
|
||||
viewport: Record<string, unknown>
|
||||
}) => (
|
||||
<div
|
||||
data-testid="workflow-app-main"
|
||||
data-nodes={JSON.stringify(nodes)}
|
||||
data-edges={JSON.stringify(edges)}
|
||||
data-viewport={JSON.stringify(viewport)}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('WorkflowApp', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appStoreState = {
|
||||
appDetail: {
|
||||
id: 'app-1',
|
||||
mode: 'workflow',
|
||||
},
|
||||
}
|
||||
workflowInitState = {
|
||||
data: {
|
||||
graph: {
|
||||
nodes: [{ id: 'raw-node' }],
|
||||
edges: [{ id: 'raw-edge' }],
|
||||
viewport: { x: 1, y: 2, zoom: 3 },
|
||||
},
|
||||
features: {
|
||||
file_upload: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
fileUploadConfigResponse: { enabled: true },
|
||||
}
|
||||
appContextState = {
|
||||
isLoadingCurrentWorkspace: false,
|
||||
currentWorkspace: { id: 'workspace-1' },
|
||||
}
|
||||
appTriggersState = {}
|
||||
searchParamsValue = null
|
||||
mockFetchRunDetail.mockResolvedValue({ inputs: null })
|
||||
mockInitialNodes.mockReturnValue([{ id: 'node-1' }])
|
||||
mockInitialEdges.mockReturnValue([{ id: 'edge-1' }])
|
||||
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '/runs/run-1' })
|
||||
})
|
||||
|
||||
it('should render the loading shell while workflow data is still loading', () => {
|
||||
workflowInitState = {
|
||||
data: null,
|
||||
isLoading: true,
|
||||
fileUploadConfigResponse: null,
|
||||
}
|
||||
|
||||
render(<WorkflowApp />)
|
||||
|
||||
expect(screen.getByTestId('loading')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workflow-app-main')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render the workflow app shell and sync trigger statuses when data is ready', () => {
|
||||
appTriggersState = {
|
||||
data: {
|
||||
data: [
|
||||
{ node_id: 'trigger-enabled', status: 'enabled' },
|
||||
{ node_id: 'trigger-disabled', status: 'paused' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
render(<WorkflowApp />)
|
||||
|
||||
expect(screen.getByTestId('workflow-context-provider')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-nodes', JSON.stringify([{ id: 'node-1' }]))
|
||||
expect(screen.getByTestId('workflow-default-context')).toHaveAttribute('data-edges', JSON.stringify([{ id: 'edge-1' }]))
|
||||
expect(screen.getByTestId('workflow-app-main')).toHaveAttribute('data-viewport', JSON.stringify({ x: 1, y: 2, zoom: 3 }))
|
||||
expect(screen.getByTestId('features-provider')).toBeInTheDocument()
|
||||
expect(mockSetTriggerStatuses).toHaveBeenCalledWith({
|
||||
'trigger-enabled': 'enabled',
|
||||
'trigger-disabled': 'disabled',
|
||||
})
|
||||
})
|
||||
|
||||
it('should not sync trigger statuses when trigger data is unavailable', () => {
|
||||
render(<WorkflowApp />)
|
||||
|
||||
expect(screen.getByTestId('workflow-app-main')).toBeInTheDocument()
|
||||
expect(mockSetTriggerStatuses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should replay workflow inputs from replayRunId and clean up workflow state on unmount', async () => {
|
||||
searchParamsValue = 'run-1'
|
||||
mockFetchRunDetail.mockResolvedValue({
|
||||
inputs: '{"sys.query":"hidden","foo":"bar","count":2,"flag":true,"obj":{"nested":true},"nil":null}',
|
||||
})
|
||||
|
||||
const { unmount } = render(<WorkflowApp />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({
|
||||
foo: 'bar',
|
||||
count: 2,
|
||||
flag: true,
|
||||
obj: '{"nested":true}',
|
||||
nil: '',
|
||||
})
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
unmount()
|
||||
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ isWorkflowDataLoaded: false })
|
||||
expect(mockDebouncedCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip replay lookups when replayRunId is missing', () => {
|
||||
render(<WorkflowApp />)
|
||||
|
||||
expect(mockGetWorkflowRunAndTraceUrl).not.toHaveBeenCalled()
|
||||
expect(mockFetchRunDetail).not.toHaveBeenCalled()
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should skip replay fetches when the resolved run url is empty', async () => {
|
||||
searchParamsValue = 'run-1'
|
||||
mockGetWorkflowRunAndTraceUrl.mockReturnValue({ runUrl: '' })
|
||||
|
||||
render(<WorkflowApp />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockGetWorkflowRunAndTraceUrl).toHaveBeenCalledWith('run-1')
|
||||
})
|
||||
|
||||
expect(mockFetchRunDetail).not.toHaveBeenCalled()
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should stop replay recovery when workflow run inputs cannot be parsed', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
searchParamsValue = 'run-1'
|
||||
mockFetchRunDetail.mockResolvedValue({
|
||||
inputs: '{invalid-json}',
|
||||
})
|
||||
|
||||
render(<WorkflowApp />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
|
||||
})
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'Failed to parse workflow run inputs',
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
|
||||
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should ignore replay inputs when they only contain sys variables', async () => {
|
||||
searchParamsValue = 'run-1'
|
||||
mockFetchRunDetail.mockResolvedValue({
|
||||
inputs: '{"sys.query":"hidden","sys.user_id":"u-1"}',
|
||||
})
|
||||
|
||||
render(<WorkflowApp />)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchRunDetail).toHaveBeenCalledWith('/runs/run-1')
|
||||
})
|
||||
|
||||
expect(mockSetInputs).not.toHaveBeenCalled()
|
||||
expect(mockSetShowInputsPanel).not.toHaveBeenCalled()
|
||||
expect(mockSetShowDebugAndPreviewPanel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
import {
|
||||
buildInitialFeatures,
|
||||
buildTriggerStatusMap,
|
||||
coerceReplayUserInputs,
|
||||
} from '../utils'
|
||||
|
||||
describe('workflow-app utils', () => {
|
||||
it('should map trigger statuses to enabled and disabled states', () => {
|
||||
expect(buildTriggerStatusMap([
|
||||
{ node_id: 'node-1', status: 'enabled' },
|
||||
{ node_id: 'node-2', status: 'disabled' },
|
||||
{ node_id: 'node-3', status: 'paused' },
|
||||
])).toEqual({
|
||||
'node-1': 'enabled',
|
||||
'node-2': 'disabled',
|
||||
'node-3': 'disabled',
|
||||
})
|
||||
})
|
||||
|
||||
it('should coerce replay run inputs, omit sys keys, and stringify complex values', () => {
|
||||
expect(coerceReplayUserInputs({
|
||||
'sys.query': 'hidden',
|
||||
'query': 'hello',
|
||||
'count': 3,
|
||||
'enabled': true,
|
||||
'nullable': null,
|
||||
'metadata': { nested: true },
|
||||
})).toEqual({
|
||||
query: 'hello',
|
||||
count: 3,
|
||||
enabled: true,
|
||||
nullable: '',
|
||||
metadata: '{"nested":true}',
|
||||
})
|
||||
expect(coerceReplayUserInputs('invalid')).toBeNull()
|
||||
expect(coerceReplayUserInputs(null)).toBeNull()
|
||||
})
|
||||
|
||||
it('should build initial features with file-upload and feature fallbacks', () => {
|
||||
const result = buildInitialFeatures({
|
||||
file_upload: {
|
||||
enabled: true,
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 2,
|
||||
image: {
|
||||
enabled: true,
|
||||
number_limits: 5,
|
||||
transfer_methods: [TransferMethod.remote_url],
|
||||
},
|
||||
},
|
||||
opening_statement: 'hello',
|
||||
suggested_questions: ['Q1'],
|
||||
suggested_questions_after_answer: { enabled: true },
|
||||
speech_to_text: { enabled: true },
|
||||
text_to_speech: { enabled: true },
|
||||
retriever_resource: { enabled: true },
|
||||
sensitive_word_avoidance: { enabled: true },
|
||||
}, { enabled: true } as never)
|
||||
|
||||
expect(result).toMatchObject({
|
||||
file: {
|
||||
enabled: true,
|
||||
allowed_file_types: [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: ['.png'],
|
||||
allowed_file_upload_methods: [TransferMethod.local_file],
|
||||
number_limits: 2,
|
||||
fileUploadConfig: { enabled: true },
|
||||
image: {
|
||||
enabled: true,
|
||||
number_limits: 5,
|
||||
transfer_methods: [TransferMethod.remote_url],
|
||||
},
|
||||
},
|
||||
opening: {
|
||||
enabled: true,
|
||||
opening_statement: 'hello',
|
||||
suggested_questions: ['Q1'],
|
||||
},
|
||||
suggested: { enabled: true },
|
||||
speech2text: { enabled: true },
|
||||
text2speech: { enabled: true },
|
||||
citation: { enabled: true },
|
||||
moderation: { enabled: true },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,494 @@
|
||||
import { act, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import WorkflowChildren from '../workflow-children'
|
||||
|
||||
type WorkflowStoreState = {
|
||||
showFeaturesPanel: boolean
|
||||
showImportDSLModal: boolean
|
||||
setShowImportDSLModal: (show: boolean) => void
|
||||
showOnboarding: boolean
|
||||
setShowOnboarding: (show: boolean) => void
|
||||
setHasSelectedStartNode: (selected: boolean) => void
|
||||
setShouldAutoOpenStartNodeSelector: (open: boolean) => void
|
||||
}
|
||||
|
||||
type TriggerPluginConfig = {
|
||||
plugin_id: string
|
||||
provider_name: string
|
||||
provider_type: string
|
||||
event_name: string
|
||||
event_label: string
|
||||
event_description: string
|
||||
output_schema: Record<string, unknown>
|
||||
paramSchemas: Array<Record<string, unknown>>
|
||||
params: Record<string, unknown>
|
||||
subscription_id: string
|
||||
plugin_unique_identifier: string
|
||||
is_team_authorization: boolean
|
||||
meta?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const mockSetShowImportDSLModal = vi.fn()
|
||||
const mockSetShowOnboarding = vi.fn()
|
||||
const mockSetHasSelectedStartNode = vi.fn()
|
||||
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
|
||||
const mockSetNodes = vi.fn()
|
||||
const mockSetEdges = vi.fn()
|
||||
const mockHandleSyncWorkflowDraft = vi.fn()
|
||||
const mockHandleOnboardingClose = vi.fn()
|
||||
const mockHandlePaneContextmenuCancel = vi.fn()
|
||||
const mockHandleExportDSL = vi.fn()
|
||||
const mockExportCheck = vi.fn()
|
||||
const mockAutoGenerateWebhookUrl = vi.fn()
|
||||
|
||||
let workflowStoreState: WorkflowStoreState
|
||||
let eventSubscription: ((value: { type: string, payload: { data: Array<Record<string, unknown>> } }) => void) | null = null
|
||||
let lastGenerateNodeInput: Record<string, unknown> | null = null
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
setNodes: mockSetNodes,
|
||||
setEdges: mockSetEdges,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: <T,>(selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
useSubscription: (callback: typeof eventSubscription) => {
|
||||
eventSubscription = callback
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useAutoGenerateWebhookUrl: () => mockAutoGenerateWebhookUrl,
|
||||
useDSL: () => ({
|
||||
exportCheck: mockExportCheck,
|
||||
handleExportDSL: mockHandleExportDSL,
|
||||
}),
|
||||
usePanelInteractions: () => ({
|
||||
handlePaneContextmenuCancel: mockHandlePaneContextmenuCancel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
handleSyncWorkflowDraft: mockHandleSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
return {
|
||||
...actual,
|
||||
generateNewNode: (args: Record<string, unknown>) => {
|
||||
lastGenerateNodeInput = args
|
||||
return {
|
||||
newNode: {
|
||||
id: 'new-node-id',
|
||||
position: args.position,
|
||||
data: args.data,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks', () => ({
|
||||
useAvailableNodesMetaData: () => ({
|
||||
nodesMap: {
|
||||
[BlockEnum.Start]: {
|
||||
defaultValue: {
|
||||
title: 'Start Title',
|
||||
desc: 'Start description',
|
||||
config: {
|
||||
image: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
[BlockEnum.TriggerPlugin]: {
|
||||
defaultValue: {
|
||||
title: 'Plugin title',
|
||||
desc: 'Plugin description',
|
||||
config: {
|
||||
baseConfig: 'base',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks/use-auto-onboarding', () => ({
|
||||
useAutoOnboarding: () => ({
|
||||
handleOnboardingClose: mockHandleOnboardingClose,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/plugin-dependency', () => ({
|
||||
default: () => <div data-testid="plugin-dependency">plugin-dependency</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/components/workflow-header', () => ({
|
||||
default: () => <div data-testid="workflow-header">workflow-header</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/components/workflow-panel', () => ({
|
||||
default: () => <div data-testid="workflow-panel">workflow-panel</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', async () => {
|
||||
const ReactModule = await import('react')
|
||||
|
||||
return {
|
||||
default: (
|
||||
loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>,
|
||||
) => {
|
||||
const DynamicComponent = (props: Record<string, unknown>) => {
|
||||
const [Loaded, setLoaded] = ReactModule.useState<React.ComponentType<Record<string, unknown>> | null>(null)
|
||||
|
||||
ReactModule.useEffect(() => {
|
||||
let mounted = true
|
||||
loader().then((mod) => {
|
||||
if (mounted)
|
||||
setLoaded(() => mod.default)
|
||||
})
|
||||
return () => {
|
||||
mounted = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return Loaded ? <Loaded {...props} /> : null
|
||||
}
|
||||
|
||||
return DynamicComponent
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/workflow/features', () => ({
|
||||
default: () => <div data-testid="workflow-features">features</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/update-dsl-modal', () => ({
|
||||
default: ({
|
||||
onCancel,
|
||||
onBackup,
|
||||
onImport,
|
||||
}: {
|
||||
onCancel: () => void
|
||||
onBackup: () => void
|
||||
onImport: () => void
|
||||
}) => (
|
||||
<div data-testid="update-dsl-modal">
|
||||
<button type="button" onClick={onCancel}>cancel-import-dsl</button>
|
||||
<button type="button" onClick={onBackup}>backup-dsl</button>
|
||||
<button type="button" onClick={onImport}>import-dsl</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/dsl-export-confirm-modal', () => ({
|
||||
default: ({
|
||||
envList,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: {
|
||||
envList: Array<Record<string, unknown>>
|
||||
onConfirm: () => void
|
||||
onClose: () => void
|
||||
}) => (
|
||||
<div data-testid="dsl-export-confirm-modal" data-env-count={String(envList.length)}>
|
||||
<button type="button" onClick={onConfirm}>confirm-export-dsl</button>
|
||||
<button type="button" onClick={onClose}>close-export-dsl</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/components/workflow-onboarding-modal', () => ({
|
||||
default: ({
|
||||
onClose,
|
||||
onSelectStartNode,
|
||||
}: {
|
||||
isShow: boolean
|
||||
onClose: () => void
|
||||
onSelectStartNode: (nodeType: BlockEnum, config?: TriggerPluginConfig) => void
|
||||
}) => (
|
||||
<div data-testid="workflow-onboarding-modal">
|
||||
<button type="button" onClick={onClose}>close-onboarding</button>
|
||||
<button type="button" onClick={() => onSelectStartNode(BlockEnum.Start)}>select-start-node</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStartNode(BlockEnum.Start, {
|
||||
title: 'Configured Start Title',
|
||||
desc: 'Configured Start Description',
|
||||
config: { image: true, custom: 'config' },
|
||||
extra: 'field',
|
||||
} as never)}
|
||||
>
|
||||
select-start-node-with-config
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, {
|
||||
plugin_id: 'plugin-id',
|
||||
provider_name: 'provider-name',
|
||||
provider_type: 'tool',
|
||||
event_name: 'event-name',
|
||||
event_label: 'Event Label',
|
||||
event_description: 'Event Description',
|
||||
output_schema: { output: true },
|
||||
paramSchemas: [{ name: 'api_key' }],
|
||||
params: { token: 'abc' },
|
||||
subscription_id: 'subscription-id',
|
||||
plugin_unique_identifier: 'plugin-unique',
|
||||
is_team_authorization: true,
|
||||
meta: { source: 'plugin' },
|
||||
})}
|
||||
>
|
||||
select-trigger-plugin
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectStartNode(BlockEnum.TriggerPlugin, {
|
||||
plugin_id: 'plugin-id-2',
|
||||
provider_name: 'provider-name-2',
|
||||
provider_type: 'tool',
|
||||
event_name: 'event-name-2',
|
||||
event_label: '',
|
||||
event_description: '',
|
||||
output_schema: {},
|
||||
paramSchemas: undefined,
|
||||
params: {},
|
||||
subscription_id: 'subscription-id-2',
|
||||
plugin_unique_identifier: 'plugin-unique-2',
|
||||
is_team_authorization: false,
|
||||
} as never)}
|
||||
>
|
||||
select-trigger-plugin-fallback
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
describe('WorkflowChildren', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workflowStoreState = {
|
||||
showFeaturesPanel: false,
|
||||
showImportDSLModal: false,
|
||||
setShowImportDSLModal: mockSetShowImportDSLModal,
|
||||
showOnboarding: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
}
|
||||
eventSubscription = null
|
||||
lastGenerateNodeInput = null
|
||||
mockHandleSyncWorkflowDraft.mockImplementation((_force?: boolean, _notRefresh?: boolean, callback?: { onSuccess?: () => void }) => {
|
||||
callback?.onSuccess?.()
|
||||
})
|
||||
})
|
||||
|
||||
it('should render feature panel, import modal actions, and default workflow chrome', async () => {
|
||||
const user = userEvent.setup()
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
showFeaturesPanel: true,
|
||||
showImportDSLModal: true,
|
||||
}
|
||||
|
||||
render(<WorkflowChildren />)
|
||||
|
||||
expect(screen.getByTestId('plugin-dependency')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-header')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-panel')).toBeInTheDocument()
|
||||
expect(await screen.findByTestId('workflow-features')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('update-dsl-modal')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /cancel-import-dsl/i }))
|
||||
await user.click(screen.getByRole('button', { name: /backup-dsl/i }))
|
||||
await user.click(screen.getByRole('button', { name: /^import-dsl$/i }))
|
||||
|
||||
expect(mockSetShowImportDSLModal).toHaveBeenCalledWith(false)
|
||||
expect(mockExportCheck).toHaveBeenCalled()
|
||||
expect(mockHandlePaneContextmenuCancel).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should react to DSL export check events by showing the confirm modal and closing it', async () => {
|
||||
const user = userEvent.setup()
|
||||
|
||||
render(<WorkflowChildren />)
|
||||
|
||||
await act(async () => {
|
||||
eventSubscription?.({
|
||||
type: DSL_EXPORT_CHECK,
|
||||
payload: {
|
||||
data: [{ id: 'env-1' }, { id: 'env-2' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(await screen.findByTestId('dsl-export-confirm-modal')).toHaveAttribute('data-env-count', '2')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /confirm-export-dsl/i }))
|
||||
await user.click(screen.getByRole('button', { name: /close-export-dsl/i }))
|
||||
|
||||
expect(mockHandleExportDSL).toHaveBeenCalled()
|
||||
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should ignore unrelated workflow events when listening for DSL export checks', async () => {
|
||||
render(<WorkflowChildren />)
|
||||
|
||||
await act(async () => {
|
||||
eventSubscription?.({
|
||||
type: 'UNRELATED_EVENT',
|
||||
payload: {
|
||||
data: [{ id: 'env-1' }],
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
expect(screen.queryByTestId('dsl-export-confirm-modal')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should close onboarding through the onboarding hook callback', async () => {
|
||||
const user = userEvent.setup()
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
showOnboarding: true,
|
||||
}
|
||||
|
||||
render(<WorkflowChildren />)
|
||||
|
||||
expect(await screen.findByTestId('workflow-onboarding-modal')).toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /close-onboarding/i }))
|
||||
|
||||
expect(mockHandleOnboardingClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should create a start node, sync draft, and auto-generate webhook url after selecting a start node', async () => {
|
||||
const user = userEvent.setup()
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
showOnboarding: true,
|
||||
}
|
||||
|
||||
render(<WorkflowChildren />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /^select-start-node$/i }))
|
||||
|
||||
expect(lastGenerateNodeInput).toMatchObject({
|
||||
data: {
|
||||
title: 'Start Title',
|
||||
desc: 'Start description',
|
||||
config: {
|
||||
image: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(mockSetNodes).toHaveBeenCalledWith([expect.objectContaining({ id: 'new-node-id' })])
|
||||
expect(mockSetEdges).toHaveBeenCalledWith([])
|
||||
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
|
||||
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true)
|
||||
expect(mockHandleSyncWorkflowDraft).toHaveBeenCalledWith(true, false, expect.any(Object))
|
||||
expect(mockAutoGenerateWebhookUrl).toHaveBeenCalledWith('new-node-id')
|
||||
})
|
||||
|
||||
it('should merge non-trigger start node config directly into the default node data', async () => {
|
||||
const user = userEvent.setup()
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
showOnboarding: true,
|
||||
}
|
||||
|
||||
render(<WorkflowChildren />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /select-start-node-with-config/i }))
|
||||
|
||||
expect(lastGenerateNodeInput).toMatchObject({
|
||||
data: {
|
||||
title: 'Configured Start Title',
|
||||
desc: 'Configured Start Description',
|
||||
config: {
|
||||
image: true,
|
||||
custom: 'config',
|
||||
},
|
||||
extra: 'field',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should merge trigger plugin defaults and config before creating the node', async () => {
|
||||
const user = userEvent.setup()
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
showOnboarding: true,
|
||||
}
|
||||
|
||||
render(<WorkflowChildren />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /^select-trigger-plugin$/i }))
|
||||
|
||||
expect(lastGenerateNodeInput).toMatchObject({
|
||||
data: {
|
||||
plugin_id: 'plugin-id',
|
||||
provider_id: 'provider-name',
|
||||
provider_name: 'provider-name',
|
||||
provider_type: 'tool',
|
||||
event_name: 'event-name',
|
||||
event_label: 'Event Label',
|
||||
event_description: 'Event Description',
|
||||
title: 'Event Label',
|
||||
desc: 'Event Description',
|
||||
output_schema: { output: true },
|
||||
parameters_schema: [{ name: 'api_key' }],
|
||||
config: {
|
||||
baseConfig: 'base',
|
||||
token: 'abc',
|
||||
},
|
||||
subscription_id: 'subscription-id',
|
||||
plugin_unique_identifier: 'plugin-unique',
|
||||
is_team_authorization: true,
|
||||
meta: { source: 'plugin' },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('should fall back to plugin default title and description when trigger labels are missing', async () => {
|
||||
const user = userEvent.setup()
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
showOnboarding: true,
|
||||
}
|
||||
|
||||
render(<WorkflowChildren />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /select-trigger-plugin-fallback/i }))
|
||||
|
||||
expect(lastGenerateNodeInput).toMatchObject({
|
||||
data: {
|
||||
title: 'Plugin title',
|
||||
desc: 'Plugin description',
|
||||
parameters_schema: [],
|
||||
config: {
|
||||
baseConfig: 'base',
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,277 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { WorkflowProps } from '@/app/components/workflow'
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import WorkflowMain from '../workflow-main'
|
||||
|
||||
const mockSetFeatures = vi.fn()
|
||||
const mockSetConversationVariables = vi.fn()
|
||||
const mockSetEnvironmentVariables = vi.fn()
|
||||
|
||||
const hookFns = {
|
||||
doSyncWorkflowDraft: vi.fn(),
|
||||
syncWorkflowDraftWhenPageClose: vi.fn(),
|
||||
handleRefreshWorkflowDraft: vi.fn(),
|
||||
handleBackupDraft: vi.fn(),
|
||||
handleLoadBackupDraft: vi.fn(),
|
||||
handleRestoreFromPublishedWorkflow: vi.fn(),
|
||||
handleRun: vi.fn(),
|
||||
handleStopRun: vi.fn(),
|
||||
handleStartWorkflowRun: vi.fn(),
|
||||
handleWorkflowStartRunInChatflow: vi.fn(),
|
||||
handleWorkflowStartRunInWorkflow: vi.fn(),
|
||||
handleWorkflowTriggerScheduleRunInWorkflow: vi.fn(),
|
||||
handleWorkflowTriggerWebhookRunInWorkflow: vi.fn(),
|
||||
handleWorkflowTriggerPluginRunInWorkflow: vi.fn(),
|
||||
handleWorkflowRunAllTriggersInWorkflow: vi.fn(),
|
||||
getWorkflowRunAndTraceUrl: vi.fn(),
|
||||
exportCheck: vi.fn(),
|
||||
handleExportDSL: vi.fn(),
|
||||
fetchInspectVars: vi.fn(),
|
||||
hasNodeInspectVars: vi.fn(),
|
||||
hasSetInspectVar: vi.fn(),
|
||||
fetchInspectVarValue: vi.fn(),
|
||||
editInspectVarValue: vi.fn(),
|
||||
renameInspectVarName: vi.fn(),
|
||||
appendNodeInspectVars: vi.fn(),
|
||||
deleteInspectVar: vi.fn(),
|
||||
deleteNodeInspectorVars: vi.fn(),
|
||||
deleteAllInspectorVars: vi.fn(),
|
||||
isInspectVarEdited: vi.fn(),
|
||||
resetToLastRunVar: vi.fn(),
|
||||
invalidateSysVarValues: vi.fn(),
|
||||
resetConversationVar: vi.fn(),
|
||||
invalidateConversationVarValues: vi.fn(),
|
||||
}
|
||||
|
||||
let capturedContextProps: Record<string, unknown> | null = null
|
||||
|
||||
type MockWorkflowWithInnerContextProps = Pick<WorkflowProps, 'nodes' | 'edges' | 'viewport' | 'onWorkflowDataUpdate'> & {
|
||||
hooksStore?: Record<string, unknown>
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => ({
|
||||
setFeatures: mockSetFeatures,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
setConversationVariables: mockSetConversationVariables,
|
||||
setEnvironmentVariables: mockSetEnvironmentVariables,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow', () => ({
|
||||
WorkflowWithInnerContext: ({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
onWorkflowDataUpdate,
|
||||
hooksStore,
|
||||
children,
|
||||
}: MockWorkflowWithInnerContextProps) => {
|
||||
capturedContextProps = {
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
hooksStore,
|
||||
}
|
||||
return (
|
||||
<div data-testid="workflow-inner-context">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWorkflowDataUpdate?.({
|
||||
features: { file: { enabled: true } },
|
||||
conversation_variables: [{ id: 'conversation-1' }],
|
||||
environment_variables: [{ id: 'env-1' }],
|
||||
})}
|
||||
>
|
||||
update-workflow-data
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWorkflowDataUpdate?.({
|
||||
conversation_variables: [{ id: 'conversation-only' }],
|
||||
})}
|
||||
>
|
||||
update-conversation-only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onWorkflowDataUpdate?.({})}
|
||||
>
|
||||
update-empty-payload
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks', () => ({
|
||||
useAvailableNodesMetaData: () => ({ nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } }),
|
||||
useConfigsMap: () => ({ flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } }),
|
||||
useDSL: () => ({ exportCheck: hookFns.exportCheck, handleExportDSL: hookFns.handleExportDSL }),
|
||||
useGetRunAndTraceUrl: () => ({ getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl }),
|
||||
useInspectVarsCrud: () => ({
|
||||
hasNodeInspectVars: hookFns.hasNodeInspectVars,
|
||||
hasSetInspectVar: hookFns.hasSetInspectVar,
|
||||
fetchInspectVarValue: hookFns.fetchInspectVarValue,
|
||||
editInspectVarValue: hookFns.editInspectVarValue,
|
||||
renameInspectVarName: hookFns.renameInspectVarName,
|
||||
appendNodeInspectVars: hookFns.appendNodeInspectVars,
|
||||
deleteInspectVar: hookFns.deleteInspectVar,
|
||||
deleteNodeInspectorVars: hookFns.deleteNodeInspectorVars,
|
||||
deleteAllInspectorVars: hookFns.deleteAllInspectorVars,
|
||||
isInspectVarEdited: hookFns.isInspectVarEdited,
|
||||
resetToLastRunVar: hookFns.resetToLastRunVar,
|
||||
invalidateSysVarValues: hookFns.invalidateSysVarValues,
|
||||
resetConversationVar: hookFns.resetConversationVar,
|
||||
invalidateConversationVarValues: hookFns.invalidateConversationVarValues,
|
||||
}),
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft,
|
||||
syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose,
|
||||
}),
|
||||
useSetWorkflowVarsWithValue: () => ({
|
||||
fetchInspectVars: hookFns.fetchInspectVars,
|
||||
}),
|
||||
useWorkflowRefreshDraft: () => ({ handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft }),
|
||||
useWorkflowRun: () => ({
|
||||
handleBackupDraft: hookFns.handleBackupDraft,
|
||||
handleLoadBackupDraft: hookFns.handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow,
|
||||
handleRun: hookFns.handleRun,
|
||||
handleStopRun: hookFns.handleStopRun,
|
||||
}),
|
||||
useWorkflowStartRun: () => ({
|
||||
handleStartWorkflowRun: hookFns.handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow,
|
||||
handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow,
|
||||
handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow,
|
||||
handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../workflow-children', () => ({
|
||||
default: () => <div data-testid="workflow-children">workflow-children</div>,
|
||||
}))
|
||||
|
||||
describe('WorkflowMain', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
capturedContextProps = null
|
||||
})
|
||||
|
||||
it('should render the inner workflow context with children and forwarded graph props', () => {
|
||||
const nodes = [{ id: 'node-1' }]
|
||||
const edges = [{ id: 'edge-1' }]
|
||||
const viewport = { x: 1, y: 2, zoom: 1.5 }
|
||||
|
||||
render(
|
||||
<WorkflowMain
|
||||
nodes={nodes as never}
|
||||
edges={edges as never}
|
||||
viewport={viewport}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByTestId('workflow-inner-context')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-children')).toBeInTheDocument()
|
||||
expect(capturedContextProps).toMatchObject({
|
||||
nodes,
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
})
|
||||
|
||||
it('should update features and workflow variables when workflow data changes', () => {
|
||||
render(
|
||||
<WorkflowMain
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /update-workflow-data/i }))
|
||||
|
||||
expect(mockSetFeatures).toHaveBeenCalledWith({ file: { enabled: true } })
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-1' }])
|
||||
})
|
||||
|
||||
it('should only update the workflow store slices present in the payload', () => {
|
||||
render(
|
||||
<WorkflowMain
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /update-conversation-only/i }))
|
||||
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-only' }])
|
||||
expect(mockSetFeatures).not.toHaveBeenCalled()
|
||||
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore empty workflow data updates', () => {
|
||||
render(
|
||||
<WorkflowMain
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /update-empty-payload/i }))
|
||||
|
||||
expect(mockSetFeatures).not.toHaveBeenCalled()
|
||||
expect(mockSetConversationVariables).not.toHaveBeenCalled()
|
||||
expect(mockSetEnvironmentVariables).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose the composed workflow action hooks through hooksStore', () => {
|
||||
render(
|
||||
<WorkflowMain
|
||||
nodes={[]}
|
||||
edges={[]}
|
||||
viewport={{ x: 0, y: 0, zoom: 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(capturedContextProps?.hooksStore).toMatchObject({
|
||||
syncWorkflowDraftWhenPageClose: hookFns.syncWorkflowDraftWhenPageClose,
|
||||
doSyncWorkflowDraft: hookFns.doSyncWorkflowDraft,
|
||||
handleRefreshWorkflowDraft: hookFns.handleRefreshWorkflowDraft,
|
||||
handleBackupDraft: hookFns.handleBackupDraft,
|
||||
handleLoadBackupDraft: hookFns.handleLoadBackupDraft,
|
||||
handleRestoreFromPublishedWorkflow: hookFns.handleRestoreFromPublishedWorkflow,
|
||||
handleRun: hookFns.handleRun,
|
||||
handleStopRun: hookFns.handleStopRun,
|
||||
handleStartWorkflowRun: hookFns.handleStartWorkflowRun,
|
||||
handleWorkflowStartRunInChatflow: hookFns.handleWorkflowStartRunInChatflow,
|
||||
handleWorkflowStartRunInWorkflow: hookFns.handleWorkflowStartRunInWorkflow,
|
||||
handleWorkflowTriggerScheduleRunInWorkflow: hookFns.handleWorkflowTriggerScheduleRunInWorkflow,
|
||||
handleWorkflowTriggerWebhookRunInWorkflow: hookFns.handleWorkflowTriggerWebhookRunInWorkflow,
|
||||
handleWorkflowTriggerPluginRunInWorkflow: hookFns.handleWorkflowTriggerPluginRunInWorkflow,
|
||||
handleWorkflowRunAllTriggersInWorkflow: hookFns.handleWorkflowRunAllTriggersInWorkflow,
|
||||
availableNodesMetaData: { nodes: [{ id: 'start' }], nodesMap: { start: { id: 'start' } } },
|
||||
getWorkflowRunAndTraceUrl: hookFns.getWorkflowRunAndTraceUrl,
|
||||
exportCheck: hookFns.exportCheck,
|
||||
handleExportDSL: hookFns.handleExportDSL,
|
||||
fetchInspectVars: hookFns.fetchInspectVars,
|
||||
configsMap: { flowId: 'app-1', flowType: 'app-flow', fileSettings: { enabled: true } },
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import * as React from 'react'
|
||||
import WorkflowPanel from '../workflow-panel'
|
||||
|
||||
type AppStoreState = {
|
||||
appDetail?: {
|
||||
id?: string
|
||||
workflow?: {
|
||||
id?: string
|
||||
}
|
||||
}
|
||||
currentLogItem?: { id: string }
|
||||
setCurrentLogItem: (item?: { id: string }) => void
|
||||
showMessageLogModal: boolean
|
||||
setShowMessageLogModal: (show: boolean) => void
|
||||
currentLogModalActiveTab?: string
|
||||
}
|
||||
|
||||
type WorkflowStoreState = {
|
||||
historyWorkflowData?: Record<string, unknown>
|
||||
showDebugAndPreviewPanel: boolean
|
||||
showChatVariablePanel: boolean
|
||||
showGlobalVariablePanel: boolean
|
||||
}
|
||||
|
||||
const mockUseIsChatMode = vi.fn()
|
||||
const mockSetCurrentLogItem = vi.fn()
|
||||
const mockSetShowMessageLogModal = vi.fn()
|
||||
|
||||
let appStoreState: AppStoreState
|
||||
let workflowStoreState: WorkflowStoreState
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: <T,>(selector: (state: AppStoreState) => T) => selector(appStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: <T,>(selector: (state: WorkflowStoreState) => T) => selector(workflowStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel', () => ({
|
||||
default: ({
|
||||
components,
|
||||
versionHistoryPanelProps,
|
||||
}: {
|
||||
components?: {
|
||||
left?: ReactNode
|
||||
right?: ReactNode
|
||||
}
|
||||
versionHistoryPanelProps?: {
|
||||
getVersionListUrl: string
|
||||
deleteVersionUrl: (versionId: string) => string
|
||||
restoreVersionUrl: (versionId: string) => string
|
||||
updateVersionUrl: (versionId: string) => string
|
||||
latestVersionId?: string
|
||||
}
|
||||
}) => (
|
||||
<div
|
||||
data-testid="panel"
|
||||
data-version-list-url={versionHistoryPanelProps?.getVersionListUrl ?? ''}
|
||||
data-delete-version-url={versionHistoryPanelProps?.deleteVersionUrl('version-1') ?? ''}
|
||||
data-restore-version-url={versionHistoryPanelProps?.restoreVersionUrl('version-1') ?? ''}
|
||||
data-update-version-url={versionHistoryPanelProps?.updateVersionUrl('version-1') ?? ''}
|
||||
data-latest-version-id={versionHistoryPanelProps?.latestVersionId ?? ''}
|
||||
>
|
||||
<div data-testid="panel-left">{components?.left}</div>
|
||||
<div data-testid="panel-right">{components?.right}</div>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/dynamic', () => ({
|
||||
default: (loader: () => Promise<{ default: React.ComponentType<Record<string, unknown>> }>) => {
|
||||
const LazyComp = React.lazy(loader)
|
||||
return function DynamicWrapper(props: Record<string, unknown>) {
|
||||
return React.createElement(
|
||||
React.Suspense,
|
||||
{ fallback: null },
|
||||
React.createElement(LazyComp, props),
|
||||
)
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/message-log-modal', () => ({
|
||||
default: ({
|
||||
currentLogItem,
|
||||
defaultTab,
|
||||
onCancel,
|
||||
}: {
|
||||
currentLogItem?: { id: string }
|
||||
defaultTab?: string
|
||||
onCancel: () => void
|
||||
}) => (
|
||||
<div data-testid="message-log-modal" data-current-log-id={currentLogItem?.id ?? ''} data-default-tab={defaultTab ?? ''}>
|
||||
<button type="button" onClick={onCancel}>close-message-log</button>
|
||||
</div>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/record', () => ({
|
||||
default: () => <div data-testid="record-panel">record</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/chat-record', () => ({
|
||||
default: () => <div data-testid="chat-record-panel">chat-record</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/debug-and-preview', () => ({
|
||||
default: () => <div data-testid="debug-and-preview-panel">debug</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/workflow-preview', () => ({
|
||||
default: () => <div data-testid="workflow-preview-panel">preview</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/chat-variable-panel', () => ({
|
||||
default: () => <div data-testid="chat-variable-panel">chat-variable</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/panel/global-variable-panel', () => ({
|
||||
default: () => <div data-testid="global-variable-panel">global-variable</div>,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks', () => ({
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
}))
|
||||
|
||||
describe('WorkflowPanel', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appStoreState = {
|
||||
appDetail: {
|
||||
id: 'app-123',
|
||||
workflow: {
|
||||
id: 'workflow-version-id',
|
||||
},
|
||||
},
|
||||
currentLogItem: { id: 'log-1' },
|
||||
setCurrentLogItem: mockSetCurrentLogItem,
|
||||
showMessageLogModal: false,
|
||||
setShowMessageLogModal: mockSetShowMessageLogModal,
|
||||
currentLogModalActiveTab: 'detail',
|
||||
}
|
||||
workflowStoreState = {
|
||||
historyWorkflowData: undefined,
|
||||
showDebugAndPreviewPanel: false,
|
||||
showChatVariablePanel: false,
|
||||
showGlobalVariablePanel: false,
|
||||
}
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should configure workflow version history urls and latest version id for the panel shell', async () => {
|
||||
render(<WorkflowPanel />)
|
||||
|
||||
const panel = await screen.findByTestId('panel')
|
||||
expect(panel).toHaveAttribute('data-version-list-url', '/apps/app-123/workflows')
|
||||
expect(panel).toHaveAttribute('data-delete-version-url', '/apps/app-123/workflows/version-1')
|
||||
expect(panel).toHaveAttribute('data-restore-version-url', '/apps/app-123/workflows/version-1/restore')
|
||||
expect(panel).toHaveAttribute('data-update-version-url', '/apps/app-123/workflows/version-1')
|
||||
expect(panel).toHaveAttribute('data-latest-version-id', 'workflow-version-id')
|
||||
})
|
||||
|
||||
it('should render and close the message log modal from the left panel slot', async () => {
|
||||
const user = userEvent.setup()
|
||||
appStoreState = {
|
||||
...appStoreState,
|
||||
showMessageLogModal: true,
|
||||
}
|
||||
|
||||
render(<WorkflowPanel />)
|
||||
|
||||
expect(await screen.findByTestId('message-log-modal')).toHaveAttribute('data-current-log-id', 'log-1')
|
||||
expect(screen.getByTestId('message-log-modal')).toHaveAttribute('data-default-tab', 'detail')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /close-message-log/i }))
|
||||
|
||||
expect(mockSetCurrentLogItem).toHaveBeenCalledWith()
|
||||
expect(mockSetShowMessageLogModal).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should switch right-side workflow panels based on chat mode and workflow state', async () => {
|
||||
workflowStoreState = {
|
||||
historyWorkflowData: { id: 'history-1' },
|
||||
showDebugAndPreviewPanel: true,
|
||||
showChatVariablePanel: true,
|
||||
showGlobalVariablePanel: true,
|
||||
}
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
const { unmount } = render(<WorkflowPanel />)
|
||||
|
||||
expect(await screen.findByTestId('chat-record-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('debug-and-preview-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('chat-variable-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('record-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('workflow-preview-panel')).not.toBeInTheDocument()
|
||||
|
||||
unmount()
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
render(<WorkflowPanel />)
|
||||
|
||||
expect(await screen.findByTestId('record-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('workflow-preview-panel')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('global-variable-panel')).toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat-record-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('debug-and-preview-panel')).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('chat-variable-panel')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
+22
@@ -149,6 +149,7 @@ const createProviderContext = ({
|
||||
|
||||
const renderWithToast = (ui: ReactElement) => {
|
||||
return render(
|
||||
// eslint-disable-next-line react/no-context-provider
|
||||
<ToastContext.Provider value={{ notify: mockNotify, close: vi.fn() }}>
|
||||
{ui}
|
||||
</ToastContext.Provider>,
|
||||
@@ -445,6 +446,27 @@ describe('FeaturesTrigger', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should skip success side effects when publish mutation returns no workflow version', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
mockPublishWorkflow.mockResolvedValue(null)
|
||||
renderWithToast(<FeaturesTrigger />)
|
||||
|
||||
// Act
|
||||
await user.click(screen.getByRole('button', { name: 'publisher-publish' }))
|
||||
|
||||
// Assert
|
||||
await waitFor(() => {
|
||||
expect(mockPublishWorkflow).toHaveBeenCalled()
|
||||
})
|
||||
expect(mockNotify).not.toHaveBeenCalledWith({ type: 'success', message: 'common.api.actionSuccess' })
|
||||
expect(mockUpdatePublishedWorkflow).not.toHaveBeenCalled()
|
||||
expect(mockInvalidateAppTriggers).not.toHaveBeenCalled()
|
||||
expect(mockSetPublishedAt).not.toHaveBeenCalled()
|
||||
expect(mockSetLastPublishedHasUserInput).not.toHaveBeenCalled()
|
||||
expect(mockResetWorkflowVersionHistory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should log error when app detail refresh fails after publish', async () => {
|
||||
// Arrange
|
||||
const user = userEvent.setup()
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as hooks from '../index'
|
||||
|
||||
describe('workflow-app hooks index', () => {
|
||||
it('should re-export workflow-app hooks', () => {
|
||||
expect(hooks.useAvailableNodesMetaData).toBeTypeOf('function')
|
||||
expect(hooks.useConfigsMap).toBeTypeOf('function')
|
||||
expect(hooks.useDSL).toBeTypeOf('function')
|
||||
expect(hooks.useGetRunAndTraceUrl).toBeTypeOf('function')
|
||||
expect(hooks.useInspectVarsCrud).toBeTypeOf('function')
|
||||
expect(hooks.useIsChatMode).toBeTypeOf('function')
|
||||
expect(hooks.useNodesSyncDraft).toBeTypeOf('function')
|
||||
expect(hooks.useWorkflowInit).toBeTypeOf('function')
|
||||
expect(hooks.useWorkflowRefreshDraft).toBeTypeOf('function')
|
||||
expect(hooks.useWorkflowRun).toBeTypeOf('function')
|
||||
expect(hooks.useWorkflowStartRun).toBeTypeOf('function')
|
||||
expect(hooks.useWorkflowTemplate).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,206 @@
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { DSL_EXPORT_CHECK } from '@/app/components/workflow/constants'
|
||||
import { useDSL } from '../use-DSL'
|
||||
|
||||
const mockNotify = vi.fn()
|
||||
const mockEmit = vi.fn()
|
||||
const mockDoSyncWorkflowDraft = vi.fn()
|
||||
const mockExportAppConfig = vi.fn()
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
const mockDownloadBlob = vi.fn()
|
||||
|
||||
let appStoreState: {
|
||||
appDetail?: {
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/base/toast/context', () => ({
|
||||
useToastContext: () => ({ notify: mockNotify }),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/event-emitter', () => ({
|
||||
useEventEmitterContextContext: () => ({
|
||||
eventEmitter: {
|
||||
emit: mockEmit,
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: <T>(selector: (state: typeof appStoreState) => T) => selector(appStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/apps', () => ({
|
||||
exportAppConfig: (...args: unknown[]) => mockExportAppConfig(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/download', () => ({
|
||||
downloadBlob: (...args: unknown[]) => mockDownloadBlob(...args),
|
||||
}))
|
||||
|
||||
const createDeferred = <T>() => {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((res) => {
|
||||
resolve = res
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
describe('useDSL', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appStoreState = {
|
||||
appDetail: {
|
||||
id: 'app-1',
|
||||
name: 'Workflow App',
|
||||
},
|
||||
}
|
||||
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
|
||||
mockExportAppConfig.mockResolvedValue({ data: 'yaml-content' })
|
||||
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: [] })
|
||||
})
|
||||
|
||||
it('should export workflow dsl and download the yaml blob when no secret env is present', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockFetchWorkflowDraft).toHaveBeenCalledWith('/apps/app-1/workflows/draft')
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockExportAppConfig).toHaveBeenCalledWith({
|
||||
appID: 'app-1',
|
||||
include: false,
|
||||
workflowID: undefined,
|
||||
})
|
||||
expect(mockDownloadBlob).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.any(Blob),
|
||||
fileName: 'Workflow App.yml',
|
||||
}))
|
||||
})
|
||||
|
||||
it('should forward include and workflow id arguments when exporting dsl directly', async () => {
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL(true, 'workflow-1')
|
||||
})
|
||||
|
||||
expect(mockExportAppConfig).toHaveBeenCalledWith({
|
||||
appID: 'app-1',
|
||||
include: true,
|
||||
workflowID: 'workflow-1',
|
||||
})
|
||||
})
|
||||
|
||||
it('should emit DSL_EXPORT_CHECK when secret environment variables exist', async () => {
|
||||
const secretVars = [{ id: 'env-1', value_type: 'secret', value: 'secret-token' }]
|
||||
mockFetchWorkflowDraft.mockResolvedValue({ environment_variables: secretVars })
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
expect(mockEmit).toHaveBeenCalledWith({
|
||||
type: DSL_EXPORT_CHECK,
|
||||
payload: {
|
||||
data: secretVars,
|
||||
},
|
||||
})
|
||||
expect(mockExportAppConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early when app detail is unavailable', async () => {
|
||||
appStoreState = {}
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockFetchWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockExportAppConfig).not.toHaveBeenCalled()
|
||||
expect(mockEmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should notify when export fails', async () => {
|
||||
mockExportAppConfig.mockRejectedValue(new Error('export failed'))
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'app.exportFailed',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
it('should notify when exportCheck cannot load the workflow draft', async () => {
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('draft fetch failed'))
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.exportCheck()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockNotify).toHaveBeenCalledWith({
|
||||
type: 'error',
|
||||
message: 'app.exportFailed',
|
||||
})
|
||||
})
|
||||
expect(mockExportAppConfig).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should ignore repeated export attempts while an export is already in progress', async () => {
|
||||
const deferred = createDeferred<{ data: string }>()
|
||||
mockExportAppConfig.mockReturnValue(deferred.promise)
|
||||
|
||||
const { result } = renderHook(() => useDSL())
|
||||
let firstExportPromise!: Promise<void>
|
||||
|
||||
act(() => {
|
||||
firstExportPromise = result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mockExportAppConfig).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
act(() => {
|
||||
void result.current.handleExportDSL()
|
||||
})
|
||||
|
||||
expect(mockExportAppConfig).toHaveBeenCalledTimes(1)
|
||||
|
||||
await act(async () => {
|
||||
deferred.resolve({ data: 'yaml-content' })
|
||||
await firstExportPromise
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,118 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { useAutoOnboarding } from '../use-auto-onboarding'
|
||||
|
||||
const mockGetNodes = vi.fn()
|
||||
const mockWorkflowStore = {
|
||||
getState: vi.fn(),
|
||||
}
|
||||
|
||||
const mockSetShowOnboarding = vi.fn()
|
||||
const mockSetHasShownOnboarding = vi.fn()
|
||||
const mockSetShouldAutoOpenStartNodeSelector = vi.fn()
|
||||
const mockSetHasSelectedStartNode = vi.fn()
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore,
|
||||
}))
|
||||
|
||||
describe('useAutoOnboarding', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
mockGetNodes.mockReturnValue([])
|
||||
mockWorkflowStore.getState.mockReturnValue({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should open onboarding after the delayed empty-canvas check on mount', () => {
|
||||
renderHook(() => useAutoOnboarding())
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(mockSetShowOnboarding).toHaveBeenCalledWith(true)
|
||||
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should skip auto onboarding when it is already visible or the workflow is not initial', () => {
|
||||
mockWorkflowStore.getState.mockReturnValue({
|
||||
showOnboarding: true,
|
||||
hasShownOnboarding: false,
|
||||
notInitialWorkflow: true,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
hasSelectedStartNode: false,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
})
|
||||
|
||||
renderHook(() => useAutoOnboarding())
|
||||
|
||||
act(() => {
|
||||
vi.advanceTimersByTime(500)
|
||||
})
|
||||
|
||||
expect(mockSetShowOnboarding).not.toHaveBeenCalled()
|
||||
expect(mockSetHasShownOnboarding).not.toHaveBeenCalled()
|
||||
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close onboarding and reset selected start node state when one was chosen', () => {
|
||||
mockWorkflowStore.getState.mockReturnValue({
|
||||
showOnboarding: false,
|
||||
hasShownOnboarding: true,
|
||||
notInitialWorkflow: false,
|
||||
setShowOnboarding: mockSetShowOnboarding,
|
||||
setHasShownOnboarding: mockSetHasShownOnboarding,
|
||||
setShouldAutoOpenStartNodeSelector: mockSetShouldAutoOpenStartNodeSelector,
|
||||
hasSelectedStartNode: true,
|
||||
setHasSelectedStartNode: mockSetHasSelectedStartNode,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useAutoOnboarding())
|
||||
|
||||
act(() => {
|
||||
result.current.handleOnboardingClose()
|
||||
})
|
||||
|
||||
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
|
||||
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
|
||||
expect(mockSetHasSelectedStartNode).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShouldAutoOpenStartNodeSelector).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should close onboarding and disable auto-open when no start node was selected', () => {
|
||||
const { result } = renderHook(() => useAutoOnboarding())
|
||||
|
||||
act(() => {
|
||||
result.current.handleOnboardingClose()
|
||||
})
|
||||
|
||||
expect(mockSetShowOnboarding).toHaveBeenCalledWith(false)
|
||||
expect(mockSetHasShownOnboarding).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShouldAutoOpenStartNodeSelector).toHaveBeenCalledWith(false)
|
||||
expect(mockSetHasSelectedStartNode).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,49 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
import { useAvailableNodesMetaData } from '../use-available-nodes-meta-data'
|
||||
|
||||
const mockUseIsChatMode = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
}))
|
||||
|
||||
vi.mock('@/context/i18n', () => ({
|
||||
useDocLink: () => (path: string) => `/docs${path}`,
|
||||
}))
|
||||
|
||||
describe('useAvailableNodesMetaData', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should include chat-specific nodes and make the start node undeletable in chat mode', () => {
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(true)
|
||||
expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeDefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.End]).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.VariableAssigner]).toBe(result.current.nodesMap?.[BlockEnum.VariableAggregator])
|
||||
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.helpLinkUri).toContain('/docs/use-dify/nodes/')
|
||||
})
|
||||
|
||||
it('should include workflow-specific trigger and end nodes outside chat mode', () => {
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useAvailableNodesMetaData())
|
||||
|
||||
expect(result.current.nodesMap?.[BlockEnum.Start]?.metaData.isUndeletable).toBe(false)
|
||||
expect(result.current.nodesMap?.[BlockEnum.End]).toBeDefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.TriggerWebhook]).toBeDefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.TriggerSchedule]).toBeDefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.TriggerPlugin]).toBeDefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.Answer]).toBeUndefined()
|
||||
expect(result.current.nodesMap?.[BlockEnum.Start]?.defaultValue).toMatchObject({
|
||||
type: BlockEnum.Start,
|
||||
title: 'workflow.blocks.start',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { useConfigsMap } from '../use-configs-map'
|
||||
|
||||
const mockUseFeatures = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeatures: (selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => mockUseFeatures(selector),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: <T>(selector: (state: { appId: string }) => T) => selector({ appId: 'app-1' }),
|
||||
}))
|
||||
|
||||
describe('useConfigsMap', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseFeatures.mockImplementation((selector: (state: { features: { file: Record<string, unknown> } }) => unknown) => selector({
|
||||
features: {
|
||||
file: {
|
||||
enabled: true,
|
||||
number_limits: 3,
|
||||
},
|
||||
},
|
||||
}))
|
||||
})
|
||||
|
||||
it('should map workflow app id and feature file settings into inspect-var configs', () => {
|
||||
const { result } = renderHook(() => useConfigsMap())
|
||||
|
||||
expect(result.current).toEqual({
|
||||
flowId: 'app-1',
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {
|
||||
enabled: true,
|
||||
number_limits: 3,
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,28 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useGetRunAndTraceUrl } from '../use-get-run-and-trace-url'
|
||||
|
||||
const mockWorkflowStore = {
|
||||
getState: vi.fn(),
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => mockWorkflowStore,
|
||||
}))
|
||||
|
||||
describe('useGetRunAndTraceUrl', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockWorkflowStore.getState.mockReturnValue({
|
||||
appId: 'app-123',
|
||||
})
|
||||
})
|
||||
|
||||
it('should build workflow run and trace urls from the current app id', () => {
|
||||
const { result } = renderHook(() => useGetRunAndTraceUrl())
|
||||
|
||||
expect(result.current.getWorkflowRunAndTraceUrl('run-1')).toEqual({
|
||||
runUrl: '/apps/app-123/workflow-runs/run-1',
|
||||
traceUrl: '/apps/app-123/workflow-runs/run-1/node-executions',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useInspectVarsCrud } from '../use-inspect-vars-crud'
|
||||
|
||||
const mockUseInspectVarsCrudCommon = vi.fn()
|
||||
const mockUseConfigsMap = vi.fn()
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-inspect-vars-crud-common', () => ({
|
||||
useInspectVarsCrudCommon: (...args: unknown[]) => mockUseInspectVarsCrudCommon(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks/use-configs-map', () => ({
|
||||
useConfigsMap: () => mockUseConfigsMap(),
|
||||
}))
|
||||
|
||||
describe('useInspectVarsCrud', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockUseConfigsMap.mockReturnValue({
|
||||
flowId: 'app-1',
|
||||
flowType: 'app-flow',
|
||||
fileSettings: { enabled: true },
|
||||
})
|
||||
mockUseInspectVarsCrudCommon.mockReturnValue({
|
||||
fetchInspectVarValue: vi.fn(),
|
||||
editInspectVarValue: vi.fn(),
|
||||
deleteInspectVar: vi.fn(),
|
||||
})
|
||||
})
|
||||
|
||||
it('should call the shared inspect vars hook with workflow-app configs and return its api', () => {
|
||||
const { result } = renderHook(() => useInspectVarsCrud())
|
||||
|
||||
expect(mockUseInspectVarsCrudCommon).toHaveBeenCalledWith({
|
||||
flowId: 'app-1',
|
||||
flowType: 'app-flow',
|
||||
fileSettings: { enabled: true },
|
||||
})
|
||||
expect(result.current).toEqual({
|
||||
fetchInspectVarValue: expect.any(Function),
|
||||
editInspectVarValue: expect.any(Function),
|
||||
deleteInspectVar: expect.any(Function),
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -4,42 +4,57 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useNodesSyncDraft } from '../use-nodes-sync-draft'
|
||||
|
||||
const mockGetNodes = vi.fn()
|
||||
const mockPostWithKeepalive = vi.fn()
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockSetDraftUpdatedAt = vi.fn()
|
||||
const mockGetNodesReadOnly = vi.fn()
|
||||
|
||||
let reactFlowState: {
|
||||
getNodes: typeof mockGetNodes
|
||||
edges: Array<Record<string, unknown>>
|
||||
transform: [number, number, number]
|
||||
}
|
||||
|
||||
let workflowStoreState: {
|
||||
appId: string
|
||||
isWorkflowDataLoaded: boolean
|
||||
syncWorkflowDraftHash: string | null
|
||||
environmentVariables: Array<Record<string, unknown>>
|
||||
conversationVariables: Array<Record<string, unknown>>
|
||||
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
|
||||
setDraftUpdatedAt: typeof mockSetDraftUpdatedAt
|
||||
}
|
||||
|
||||
let featuresState: {
|
||||
features: {
|
||||
opening: { enabled: boolean, opening_statement: string, suggested_questions: string[] }
|
||||
suggested: Record<string, unknown>
|
||||
text2speech: Record<string, unknown>
|
||||
speech2text: Record<string, unknown>
|
||||
citation: Record<string, unknown>
|
||||
moderation: Record<string, unknown>
|
||||
file: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({ getState: () => ({ getNodes: mockGetNodes, edges: [], transform: [0, 0, 1] }) }),
|
||||
useStoreApi: () => ({ getState: () => reactFlowState }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
appId: 'app-1',
|
||||
isWorkflowDataLoaded: true,
|
||||
syncWorkflowDraftHash: 'hash-123',
|
||||
environmentVariables: [],
|
||||
conversationVariables: [],
|
||||
setSyncWorkflowDraftHash: vi.fn(),
|
||||
setDraftUpdatedAt: vi.fn(),
|
||||
}),
|
||||
getState: () => workflowStoreState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => ({
|
||||
features: {
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
suggested: {},
|
||||
text2speech: {},
|
||||
speech2text: {},
|
||||
citation: {},
|
||||
moderation: {},
|
||||
file: {},
|
||||
},
|
||||
}),
|
||||
getState: () => featuresState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow', () => ({
|
||||
useNodesReadOnly: () => ({ getNodesReadOnly: () => false }),
|
||||
useNodesReadOnly: () => ({ getNodesReadOnly: mockGetNodesReadOnly }),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-serial-async-callback', () => ({
|
||||
@@ -55,7 +70,7 @@ vi.mock('@/service/workflow', () => ({
|
||||
syncWorkflowDraft: (p: unknown) => mockSyncWorkflowDraft(p),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/fetch', () => ({ postWithKeepalive: vi.fn() }))
|
||||
vi.mock('@/service/fetch', () => ({ postWithKeepalive: (...args: unknown[]) => mockPostWithKeepalive(...args) }))
|
||||
vi.mock('@/config', () => ({ API_PREFIX: '/api' }))
|
||||
|
||||
const mockHandleRefreshWorkflowDraft = vi.fn()
|
||||
@@ -66,6 +81,32 @@ vi.mock('@/app/components/workflow-app/hooks', () => ({
|
||||
describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
reactFlowState = {
|
||||
getNodes: mockGetNodes,
|
||||
edges: [],
|
||||
transform: [0, 0, 1],
|
||||
}
|
||||
workflowStoreState = {
|
||||
appId: 'app-1',
|
||||
isWorkflowDataLoaded: true,
|
||||
syncWorkflowDraftHash: 'hash-123',
|
||||
environmentVariables: [],
|
||||
conversationVariables: [],
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setDraftUpdatedAt: mockSetDraftUpdatedAt,
|
||||
}
|
||||
featuresState = {
|
||||
features: {
|
||||
opening: { enabled: false, opening_statement: '', suggested_questions: [] },
|
||||
suggested: {},
|
||||
text2speech: {},
|
||||
speech2text: {},
|
||||
citation: {},
|
||||
moderation: {},
|
||||
file: {},
|
||||
},
|
||||
}
|
||||
mockGetNodesReadOnly.mockReturnValue(false)
|
||||
mockGetNodes.mockReturnValue([{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start' } }])
|
||||
mockSyncWorkflowDraft.mockResolvedValue({ hash: 'new', updated_at: 1 })
|
||||
})
|
||||
@@ -122,4 +163,102 @@ describe('useNodesSyncDraft — handleRefreshWorkflowDraft(true) on 409', () =>
|
||||
}),
|
||||
}))
|
||||
})
|
||||
|
||||
it('should strip temp entities and private data, use the latest hash, and invoke success callbacks', async () => {
|
||||
reactFlowState = {
|
||||
...reactFlowState,
|
||||
edges: [
|
||||
{ id: 'edge-1', source: 'n1', target: 'n2', data: { _isTemp: false, _private: 'drop', stable: 'keep' } },
|
||||
{ id: 'temp-edge', source: 'n2', target: 'n3', data: { _isTemp: true } },
|
||||
],
|
||||
transform: [10, 20, 1.5],
|
||||
}
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', _tempField: 'drop', label: 'Start' } },
|
||||
{ id: 'temp-node', position: { x: 1, y: 1 }, data: { type: 'answer', _isTempNode: true } },
|
||||
])
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
syncWorkflowDraftHash: 'latest-hash',
|
||||
environmentVariables: [{ id: 'env-1', value: 'env' }],
|
||||
conversationVariables: [{ id: 'conversation-1', value: 'conversation' }],
|
||||
}
|
||||
featuresState = {
|
||||
features: {
|
||||
opening: { enabled: true, opening_statement: 'Hello', suggested_questions: ['Q1'] },
|
||||
suggested: { enabled: true },
|
||||
text2speech: { enabled: true },
|
||||
speech2text: { enabled: true },
|
||||
citation: { enabled: true },
|
||||
moderation: { enabled: false },
|
||||
file: { enabled: true },
|
||||
},
|
||||
}
|
||||
|
||||
const callbacks = {
|
||||
onSuccess: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onSettled: vi.fn(),
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.doSyncWorkflowDraft(false, callbacks)
|
||||
})
|
||||
|
||||
expect(mockSyncWorkflowDraft).toHaveBeenCalledWith({
|
||||
url: '/apps/app-1/workflows/draft',
|
||||
params: {
|
||||
graph: {
|
||||
nodes: [{ id: 'n1', position: { x: 0, y: 0 }, data: { type: 'start', label: 'Start' } }],
|
||||
edges: [{ id: 'edge-1', source: 'n1', target: 'n2', data: { stable: 'keep' } }],
|
||||
viewport: { x: 10, y: 20, zoom: 1.5 },
|
||||
},
|
||||
features: {
|
||||
opening_statement: 'Hello',
|
||||
suggested_questions: ['Q1'],
|
||||
suggested_questions_after_answer: { enabled: true },
|
||||
text_to_speech: { enabled: true },
|
||||
speech_to_text: { enabled: true },
|
||||
retriever_resource: { enabled: true },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
file_upload: { enabled: true },
|
||||
},
|
||||
environment_variables: [{ id: 'env-1', value: 'env' }],
|
||||
conversation_variables: [{ id: 'conversation-1', value: 'conversation' }],
|
||||
hash: 'latest-hash',
|
||||
},
|
||||
})
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('new')
|
||||
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(1)
|
||||
expect(callbacks.onSuccess).toHaveBeenCalled()
|
||||
expect(callbacks.onError).not.toHaveBeenCalled()
|
||||
expect(callbacks.onSettled).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should post workflow draft with keepalive when the page closes', () => {
|
||||
reactFlowState = {
|
||||
...reactFlowState,
|
||||
transform: [1, 2, 3],
|
||||
}
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
environmentVariables: [{ id: 'env-1' }],
|
||||
conversationVariables: [{ id: 'conversation-1' }],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useNodesSyncDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.syncWorkflowDraftWhenPageClose()
|
||||
})
|
||||
|
||||
expect(mockPostWithKeepalive).toHaveBeenCalledWith('/api/apps/app-1/workflows/draft', expect.objectContaining({
|
||||
graph: expect.objectContaining({
|
||||
viewport: { x: 1, y: 2, zoom: 3 },
|
||||
}),
|
||||
hash: 'hash-123',
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { BlockEnum } from '@/app/components/workflow/types'
|
||||
|
||||
import { useWorkflowInit } from '../use-workflow-init'
|
||||
|
||||
@@ -11,6 +12,21 @@ const mockSetLastPublishedHasUserInput = vi.fn()
|
||||
const mockSetFileUploadConfig = vi.fn()
|
||||
const mockWorkflowStoreSetState = vi.fn()
|
||||
const mockWorkflowStoreGetState = vi.fn()
|
||||
const mockFetchNodesDefaultConfigs = vi.fn()
|
||||
const mockFetchPublishedWorkflow = vi.fn()
|
||||
|
||||
let appStoreState: {
|
||||
appDetail: {
|
||||
id: string
|
||||
name: string
|
||||
mode: string
|
||||
}
|
||||
}
|
||||
|
||||
let workflowConfigState: {
|
||||
data: Record<string, unknown> | null
|
||||
isLoading: boolean
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useStore: <T>(selector: (state: { setSyncWorkflowDraftHash: ReturnType<typeof vi.fn> }) => T): T =>
|
||||
@@ -22,8 +38,8 @@ vi.mock('@/app/components/workflow/store', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => ({
|
||||
useStore: <T>(selector: (state: { appDetail: { id: string, name: string, mode: string } }) => T): T =>
|
||||
selector({ appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' } }),
|
||||
useStore: <T>(selector: (state: typeof appStoreState) => T): T =>
|
||||
selector(appStoreState),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-template', () => ({
|
||||
@@ -31,7 +47,11 @@ vi.mock('../use-workflow-template', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useWorkflowConfig: () => ({ data: null, isLoading: false }),
|
||||
useWorkflowConfig: (_url: string, onSuccess: (config: Record<string, unknown>) => void) => {
|
||||
if (workflowConfigState.data)
|
||||
onSuccess(workflowConfigState.data)
|
||||
return workflowConfigState
|
||||
},
|
||||
}))
|
||||
|
||||
const mockFetchWorkflowDraft = vi.fn()
|
||||
@@ -40,8 +60,8 @@ const mockSyncWorkflowDraft = vi.fn()
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
fetchWorkflowDraft: (...args: unknown[]) => mockFetchWorkflowDraft(...args),
|
||||
syncWorkflowDraft: (...args: unknown[]) => mockSyncWorkflowDraft(...args),
|
||||
fetchNodesDefaultConfigs: () => Promise.resolve([]),
|
||||
fetchPublishedWorkflow: () => Promise.resolve({ created_at: 0, graph: { nodes: [], edges: [] } }),
|
||||
fetchNodesDefaultConfigs: (...args: unknown[]) => mockFetchNodesDefaultConfigs(...args),
|
||||
fetchPublishedWorkflow: (...args: unknown[]) => mockFetchPublishedWorkflow(...args),
|
||||
}))
|
||||
|
||||
const notExistError = () => ({
|
||||
@@ -68,6 +88,10 @@ const draftResponse = {
|
||||
describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
appStoreState = {
|
||||
appDetail: { id: 'app-1', name: 'Test', mode: 'workflow' },
|
||||
}
|
||||
workflowConfigState = { data: null, isLoading: false }
|
||||
mockWorkflowStoreGetState.mockReturnValue({
|
||||
setDraftUpdatedAt: mockSetDraftUpdatedAt,
|
||||
setToolPublished: mockSetToolPublished,
|
||||
@@ -75,6 +99,8 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
|
||||
setLastPublishedHasUserInput: mockSetLastPublishedHasUserInput,
|
||||
setFileUploadConfig: mockSetFileUploadConfig,
|
||||
})
|
||||
mockFetchNodesDefaultConfigs.mockResolvedValue([])
|
||||
mockFetchPublishedWorkflow.mockResolvedValue({ created_at: 0, graph: { nodes: [], edges: [] } })
|
||||
mockFetchWorkflowDraft
|
||||
.mockRejectedValueOnce(notExistError())
|
||||
.mockResolvedValueOnce(draftResponse)
|
||||
@@ -104,4 +130,77 @@ describe('useWorkflowInit — hash fix (draft_workflow_not_exist)', () => {
|
||||
expect(order).toContain('hash:new-hash')
|
||||
expect(order.indexOf('hash:new-hash')).toBeLessThan(order.indexOf('fetch:2'))
|
||||
})
|
||||
|
||||
it('should hydrate draft state, preload defaults, and derive published workflow metadata on success', async () => {
|
||||
workflowConfigState = {
|
||||
data: { enabled: true, sizeLimit: 20 },
|
||||
isLoading: false,
|
||||
}
|
||||
mockFetchWorkflowDraft.mockReset().mockResolvedValue({
|
||||
...draftResponse,
|
||||
updated_at: 9,
|
||||
tool_published: true,
|
||||
environment_variables: [
|
||||
{ id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' },
|
||||
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
|
||||
],
|
||||
conversation_variables: [{ id: 'conversation-1' }],
|
||||
})
|
||||
mockFetchNodesDefaultConfigs.mockResolvedValue([
|
||||
{ type: 'start', config: { title: 'Start Config' } },
|
||||
{ type: 'start', config: { title: 'Ignored Duplicate' } },
|
||||
])
|
||||
mockFetchPublishedWorkflow.mockResolvedValue({
|
||||
created_at: 99,
|
||||
graph: {
|
||||
nodes: [{ id: 'start', data: { type: BlockEnum.Start } }],
|
||||
edges: [{ source: 'start', target: 'end' }],
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowInit())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.data?.hash).toBe('server-hash')
|
||||
})
|
||||
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({ appId: 'app-1', appName: 'Test' })
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith(expect.objectContaining({
|
||||
envSecrets: { 'env-secret': 'top-secret' },
|
||||
environmentVariables: [
|
||||
{ id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' },
|
||||
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
|
||||
],
|
||||
conversationVariables: [{ id: 'conversation-1' }],
|
||||
isWorkflowDataLoaded: true,
|
||||
}))
|
||||
expect(mockWorkflowStoreSetState).toHaveBeenCalledWith({
|
||||
nodesDefaultConfigs: {
|
||||
start: { title: 'Start Config' },
|
||||
},
|
||||
})
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
|
||||
expect(mockSetDraftUpdatedAt).toHaveBeenCalledWith(9)
|
||||
expect(mockSetToolPublished).toHaveBeenCalledWith(true)
|
||||
expect(mockSetPublishedAt).toHaveBeenCalledWith(99)
|
||||
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(true)
|
||||
expect(mockSetFileUploadConfig).toHaveBeenCalledWith({ enabled: true, sizeLimit: 20 })
|
||||
expect(result.current.fileUploadConfigResponse).toEqual({ enabled: true, sizeLimit: 20 })
|
||||
expect(result.current.isLoading).toBe(false)
|
||||
})
|
||||
|
||||
it('should fall back to no published user input when preload requests fail', async () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
mockFetchWorkflowDraft.mockReset().mockResolvedValue(draftResponse)
|
||||
mockFetchNodesDefaultConfigs.mockRejectedValue(new Error('preload failed'))
|
||||
|
||||
renderHook(() => useWorkflowInit())
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetLastPublishedHasUserInput).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalled()
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
+93
-13
@@ -1,24 +1,32 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { act, renderHook, waitFor } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useWorkflowRefreshDraft } from '../use-workflow-refresh-draft'
|
||||
|
||||
const mockHandleUpdateWorkflowCanvas = vi.fn()
|
||||
const mockSetSyncWorkflowDraftHash = vi.fn()
|
||||
const mockSetIsSyncingWorkflowDraft = vi.fn()
|
||||
const mockSetEnvironmentVariables = vi.fn()
|
||||
const mockSetEnvSecrets = vi.fn()
|
||||
const mockSetConversationVariables = vi.fn()
|
||||
const mockSetIsWorkflowDataLoaded = vi.fn()
|
||||
const mockCancel = vi.fn()
|
||||
|
||||
let workflowStoreState: {
|
||||
appId: string
|
||||
isWorkflowDataLoaded: boolean
|
||||
debouncedSyncWorkflowDraft?: { cancel: () => void }
|
||||
setSyncWorkflowDraftHash: typeof mockSetSyncWorkflowDraftHash
|
||||
setIsSyncingWorkflowDraft: typeof mockSetIsSyncingWorkflowDraft
|
||||
setEnvironmentVariables: typeof mockSetEnvironmentVariables
|
||||
setEnvSecrets: typeof mockSetEnvSecrets
|
||||
setConversationVariables: typeof mockSetConversationVariables
|
||||
setIsWorkflowDataLoaded: typeof mockSetIsWorkflowDataLoaded
|
||||
}
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => ({
|
||||
appId: 'app-1',
|
||||
isWorkflowDataLoaded: true,
|
||||
debouncedSyncWorkflowDraft: undefined,
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setIsSyncingWorkflowDraft: vi.fn(),
|
||||
setEnvironmentVariables: vi.fn(),
|
||||
setEnvSecrets: vi.fn(),
|
||||
setConversationVariables: vi.fn(),
|
||||
setIsWorkflowDataLoaded: vi.fn(),
|
||||
}),
|
||||
getState: () => workflowStoreState,
|
||||
}),
|
||||
}))
|
||||
|
||||
@@ -41,6 +49,17 @@ const draftResponse = {
|
||||
describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workflowStoreState = {
|
||||
appId: 'app-1',
|
||||
isWorkflowDataLoaded: true,
|
||||
debouncedSyncWorkflowDraft: undefined,
|
||||
setSyncWorkflowDraftHash: mockSetSyncWorkflowDraftHash,
|
||||
setIsSyncingWorkflowDraft: mockSetIsSyncingWorkflowDraft,
|
||||
setEnvironmentVariables: mockSetEnvironmentVariables,
|
||||
setEnvSecrets: mockSetEnvSecrets,
|
||||
setConversationVariables: mockSetConversationVariables,
|
||||
setIsWorkflowDataLoaded: mockSetIsWorkflowDataLoaded,
|
||||
}
|
||||
mockFetchWorkflowDraft.mockResolvedValue(draftResponse)
|
||||
})
|
||||
|
||||
@@ -75,6 +94,67 @@ describe('useWorkflowRefreshDraft — notUpdateCanvas parameter', () => {
|
||||
await act(async () => {
|
||||
result.current.handleRefreshWorkflowDraft(true)
|
||||
})
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
|
||||
await waitFor(() => {
|
||||
expect(mockSetSyncWorkflowDraftHash).toHaveBeenCalledWith('server-hash')
|
||||
})
|
||||
})
|
||||
|
||||
it('should cancel pending draft sync, use fallback viewport, and persist masked secrets', async () => {
|
||||
workflowStoreState = {
|
||||
...workflowStoreState,
|
||||
debouncedSyncWorkflowDraft: { cancel: mockCancel },
|
||||
}
|
||||
mockFetchWorkflowDraft.mockResolvedValue({
|
||||
hash: 'server-hash',
|
||||
graph: {
|
||||
nodes: [{ id: 'n1' }],
|
||||
edges: [],
|
||||
},
|
||||
environment_variables: [
|
||||
{ id: 'env-secret', value_type: 'secret', value: 'top-secret', name: 'SECRET' },
|
||||
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
|
||||
],
|
||||
conversation_variables: [{ id: 'conversation-1' }],
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCancel).toHaveBeenCalled()
|
||||
expect(mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [{ id: 'n1' }],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
})
|
||||
expect(mockSetEnvSecrets).toHaveBeenCalledWith({
|
||||
'env-secret': 'top-secret',
|
||||
})
|
||||
expect(mockSetEnvironmentVariables).toHaveBeenCalledWith([
|
||||
{ id: 'env-secret', value_type: 'secret', value: '[__HIDDEN__]', name: 'SECRET' },
|
||||
{ id: 'env-plain', value_type: 'text', value: 'visible', name: 'PLAIN' },
|
||||
])
|
||||
expect(mockSetConversationVariables).toHaveBeenCalledWith([{ id: 'conversation-1' }])
|
||||
})
|
||||
})
|
||||
|
||||
it('should restore loaded state when refresh fails after workflow data was already loaded', async () => {
|
||||
mockFetchWorkflowDraft.mockRejectedValue(new Error('refresh failed'))
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRefreshDraft())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRefreshWorkflowDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(1, false)
|
||||
expect(mockSetIsWorkflowDataLoaded).toHaveBeenNthCalledWith(2, true)
|
||||
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenCalledWith(true)
|
||||
expect(mockSetIsSyncingWorkflowDraft).toHaveBeenLastCalledWith(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,451 @@
|
||||
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
|
||||
import { createBaseWorkflowRunCallbacks, createFinalWorkflowRunCallbacks } from '../use-workflow-run-callbacks'
|
||||
|
||||
const {
|
||||
mockSseGet,
|
||||
mockResetMsgId,
|
||||
} = vi.hoisted(() => ({
|
||||
mockSseGet: vi.fn(),
|
||||
mockResetMsgId: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
sseGet: mockSseGet,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
|
||||
AudioPlayerManager: {
|
||||
getInstance: () => ({
|
||||
resetMsgId: mockResetMsgId,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
const createHandlers = () => ({
|
||||
handleWorkflowStarted: vi.fn(),
|
||||
handleWorkflowFinished: vi.fn(),
|
||||
handleWorkflowFailed: vi.fn(),
|
||||
handleWorkflowNodeStarted: vi.fn(),
|
||||
handleWorkflowNodeFinished: vi.fn(),
|
||||
handleWorkflowNodeHumanInputRequired: vi.fn(),
|
||||
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
|
||||
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
|
||||
handleWorkflowNodeIterationStarted: vi.fn(),
|
||||
handleWorkflowNodeIterationNext: vi.fn(),
|
||||
handleWorkflowNodeIterationFinished: vi.fn(),
|
||||
handleWorkflowNodeLoopStarted: vi.fn(),
|
||||
handleWorkflowNodeLoopNext: vi.fn(),
|
||||
handleWorkflowNodeLoopFinished: vi.fn(),
|
||||
handleWorkflowNodeRetry: vi.fn(),
|
||||
handleWorkflowAgentLog: vi.fn(),
|
||||
handleWorkflowTextChunk: vi.fn(),
|
||||
handleWorkflowTextReplace: vi.fn(),
|
||||
handleWorkflowPaused: vi.fn(),
|
||||
})
|
||||
|
||||
const createUserCallbacks = () => ({
|
||||
onWorkflowStarted: vi.fn(),
|
||||
onWorkflowFinished: vi.fn(),
|
||||
onNodeStarted: vi.fn(),
|
||||
onNodeFinished: vi.fn(),
|
||||
onIterationStart: vi.fn(),
|
||||
onIterationNext: vi.fn(),
|
||||
onIterationFinish: vi.fn(),
|
||||
onLoopStart: vi.fn(),
|
||||
onLoopNext: vi.fn(),
|
||||
onLoopFinish: vi.fn(),
|
||||
onNodeRetry: vi.fn(),
|
||||
onAgentLog: vi.fn(),
|
||||
onError: vi.fn(),
|
||||
onWorkflowPaused: vi.fn(),
|
||||
onHumanInputRequired: vi.fn(),
|
||||
onHumanInputFormFilled: vi.fn(),
|
||||
onHumanInputFormTimeout: vi.fn(),
|
||||
onCompleted: vi.fn(),
|
||||
})
|
||||
|
||||
describe('useWorkflowRun callbacks helpers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should create base callbacks that wrap workflow events, errors, pause continuation, and lazy tts playback', () => {
|
||||
const handlers = createHandlers()
|
||||
const clearAbortController = vi.fn()
|
||||
const clearListeningState = vi.fn()
|
||||
const invalidateRunHistory = vi.fn()
|
||||
const fetchInspectVars = vi.fn()
|
||||
const invalidAllLastRun = vi.fn()
|
||||
const trackWorkflowRunFailed = vi.fn()
|
||||
const userOnWorkflowFinished = vi.fn()
|
||||
const userOnError = vi.fn()
|
||||
const userOnWorkflowPaused = vi.fn()
|
||||
const player = {
|
||||
playAudioWithAudio: vi.fn(),
|
||||
} as unknown as AudioPlayer
|
||||
const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => player)
|
||||
|
||||
const callbacks = createBaseWorkflowRunCallbacks({
|
||||
clientWidth: 320,
|
||||
clientHeight: 240,
|
||||
runHistoryUrl: '/apps/app-1/workflow-runs',
|
||||
isInWorkflowDebug: true,
|
||||
fetchInspectVars,
|
||||
invalidAllLastRun,
|
||||
invalidateRunHistory,
|
||||
clearAbortController,
|
||||
clearListeningState,
|
||||
trackWorkflowRunFailed,
|
||||
handlers,
|
||||
callbacks: {
|
||||
onWorkflowFinished: userOnWorkflowFinished,
|
||||
onError: userOnError,
|
||||
onWorkflowPaused: userOnWorkflowPaused,
|
||||
},
|
||||
restCallback: {},
|
||||
getOrCreatePlayer,
|
||||
})
|
||||
|
||||
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
|
||||
expect(clearListeningState).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
|
||||
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
|
||||
expect(userOnWorkflowFinished).toHaveBeenCalled()
|
||||
expect(fetchInspectVars).toHaveBeenCalledWith({})
|
||||
expect(invalidAllLastRun).toHaveBeenCalled()
|
||||
|
||||
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never)
|
||||
expect(clearAbortController).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
|
||||
expect(userOnError).toHaveBeenCalled()
|
||||
expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' })
|
||||
|
||||
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
|
||||
expect(getOrCreatePlayer).toHaveBeenCalled()
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
|
||||
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
|
||||
|
||||
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
|
||||
expect(userOnWorkflowPaused).toHaveBeenCalled()
|
||||
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
|
||||
})
|
||||
|
||||
it('should create final callbacks that preserve rest callback override order and eager abort-controller wiring', () => {
|
||||
const handlers = createHandlers()
|
||||
const restOnNodeStarted = vi.fn()
|
||||
const setAbortController = vi.fn()
|
||||
const player = {
|
||||
playAudioWithAudio: vi.fn(),
|
||||
} as unknown as AudioPlayer
|
||||
|
||||
const baseSseOptions = createBaseWorkflowRunCallbacks({
|
||||
clientWidth: 320,
|
||||
clientHeight: 240,
|
||||
runHistoryUrl: '/apps/app-1/workflow-runs',
|
||||
isInWorkflowDebug: false,
|
||||
fetchInspectVars: vi.fn(),
|
||||
invalidAllLastRun: vi.fn(),
|
||||
invalidateRunHistory: vi.fn(),
|
||||
clearAbortController: vi.fn(),
|
||||
clearListeningState: vi.fn(),
|
||||
trackWorkflowRunFailed: vi.fn(),
|
||||
handlers,
|
||||
callbacks: {},
|
||||
restCallback: {},
|
||||
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
|
||||
})
|
||||
|
||||
const finalCallbacks = createFinalWorkflowRunCallbacks({
|
||||
clientWidth: 320,
|
||||
clientHeight: 240,
|
||||
runHistoryUrl: '/apps/app-1/workflow-runs',
|
||||
isInWorkflowDebug: false,
|
||||
fetchInspectVars: vi.fn(),
|
||||
invalidAllLastRun: vi.fn(),
|
||||
invalidateRunHistory: vi.fn(),
|
||||
clearAbortController: vi.fn(),
|
||||
clearListeningState: vi.fn(),
|
||||
trackWorkflowRunFailed: vi.fn(),
|
||||
handlers,
|
||||
callbacks: {},
|
||||
restCallback: {
|
||||
onNodeStarted: restOnNodeStarted,
|
||||
},
|
||||
baseSseOptions,
|
||||
player,
|
||||
setAbortController,
|
||||
})
|
||||
|
||||
const controller = new AbortController()
|
||||
finalCallbacks.getAbortController?.(controller)
|
||||
expect(setAbortController).toHaveBeenCalledWith(controller)
|
||||
|
||||
finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
|
||||
expect(restOnNodeStarted).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeStarted).not.toHaveBeenCalled()
|
||||
|
||||
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
|
||||
expect(mockResetMsgId).toHaveBeenCalledWith('message-2')
|
||||
})
|
||||
|
||||
it('should route base workflow events through handlers, user callbacks, and pause continuation with the same callback object', async () => {
|
||||
const handlers = createHandlers()
|
||||
const userCallbacks = createUserCallbacks()
|
||||
const clearAbortController = vi.fn()
|
||||
const clearListeningState = vi.fn()
|
||||
const invalidateRunHistory = vi.fn()
|
||||
const fetchInspectVars = vi.fn()
|
||||
const invalidAllLastRun = vi.fn()
|
||||
const trackWorkflowRunFailed = vi.fn()
|
||||
const player = {
|
||||
playAudioWithAudio: vi.fn(),
|
||||
} as unknown as AudioPlayer
|
||||
|
||||
const callbacks = createBaseWorkflowRunCallbacks({
|
||||
clientWidth: 640,
|
||||
clientHeight: 360,
|
||||
runHistoryUrl: '/apps/app-1/workflow-runs',
|
||||
isInWorkflowDebug: true,
|
||||
fetchInspectVars,
|
||||
invalidAllLastRun,
|
||||
invalidateRunHistory,
|
||||
clearAbortController,
|
||||
clearListeningState,
|
||||
trackWorkflowRunFailed,
|
||||
handlers,
|
||||
callbacks: userCallbacks,
|
||||
restCallback: {},
|
||||
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
|
||||
})
|
||||
|
||||
callbacks.onWorkflowStarted?.({ workflow_run_id: 'run-1' } as never)
|
||||
callbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onNodeFinished?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onIterationStart?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onIterationNext?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onIterationFinish?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onLoopStart?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onLoopNext?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onLoopFinish?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onNodeRetry?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onAgentLog?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onTextChunk?.({ data: 'chunk' } as never)
|
||||
callbacks.onTextReplace?.({ text: 'replacement' } as never)
|
||||
callbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
|
||||
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
|
||||
await callbacks.onCompleted?.(false, '')
|
||||
callbacks.onTTSChunk?.('message-1', 'audio-chunk')
|
||||
callbacks.onTTSEnd?.('message-1', 'audio-finished')
|
||||
callbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
callbacks.onError?.({ error: 'failed', node_type: 'llm' } as never, '500')
|
||||
|
||||
expect(handlers.handleWorkflowStarted).toHaveBeenCalled()
|
||||
expect(userCallbacks.onWorkflowStarted).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith(
|
||||
{ node_id: 'node-1' },
|
||||
{ clientWidth: 640, clientHeight: 360 },
|
||||
)
|
||||
expect(userCallbacks.onNodeStarted).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeFinished).toHaveBeenCalled()
|
||||
expect(userCallbacks.onNodeFinished).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith(
|
||||
{ node_id: 'node-1' },
|
||||
{ clientWidth: 640, clientHeight: 360 },
|
||||
)
|
||||
expect(userCallbacks.onIterationStart).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeIterationNext).toHaveBeenCalled()
|
||||
expect(userCallbacks.onIterationNext).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeIterationFinished).toHaveBeenCalled()
|
||||
expect(userCallbacks.onIterationFinish).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith(
|
||||
{ node_id: 'node-1' },
|
||||
{ clientWidth: 640, clientHeight: 360 },
|
||||
)
|
||||
expect(userCallbacks.onLoopStart).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeLoopNext).toHaveBeenCalled()
|
||||
expect(userCallbacks.onLoopNext).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeLoopFinished).toHaveBeenCalled()
|
||||
expect(userCallbacks.onLoopFinish).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeRetry).toHaveBeenCalled()
|
||||
expect(userCallbacks.onNodeRetry).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowAgentLog).toHaveBeenCalled()
|
||||
expect(userCallbacks.onAgentLog).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled()
|
||||
expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled()
|
||||
expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled()
|
||||
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
|
||||
expect(clearListeningState).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
|
||||
expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled()
|
||||
expect(fetchInspectVars).toHaveBeenCalledWith({})
|
||||
expect(invalidAllLastRun).toHaveBeenCalled()
|
||||
expect(userCallbacks.onCompleted).toHaveBeenCalledWith(false, '')
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
|
||||
expect(mockResetMsgId).toHaveBeenCalledWith('message-1')
|
||||
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
|
||||
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
|
||||
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, callbacks)
|
||||
expect(clearAbortController).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
|
||||
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' }, '500')
|
||||
expect(trackWorkflowRunFailed).toHaveBeenCalledWith({ error: 'failed', node_type: 'llm' })
|
||||
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
|
||||
})
|
||||
|
||||
it('should skip base debug-only side effects and audio playback when debug mode is off or audio is empty', () => {
|
||||
const handlers = createHandlers()
|
||||
const fetchInspectVars = vi.fn()
|
||||
const invalidAllLastRun = vi.fn()
|
||||
const getOrCreatePlayer = vi.fn<() => AudioPlayer | null>(() => null)
|
||||
|
||||
const callbacks = createBaseWorkflowRunCallbacks({
|
||||
clientWidth: 320,
|
||||
clientHeight: 240,
|
||||
runHistoryUrl: '/apps/app-1/workflow-runs',
|
||||
isInWorkflowDebug: false,
|
||||
fetchInspectVars,
|
||||
invalidAllLastRun,
|
||||
invalidateRunHistory: vi.fn(),
|
||||
clearAbortController: vi.fn(),
|
||||
clearListeningState: vi.fn(),
|
||||
trackWorkflowRunFailed: vi.fn(),
|
||||
handlers,
|
||||
callbacks: {},
|
||||
restCallback: {},
|
||||
getOrCreatePlayer,
|
||||
})
|
||||
|
||||
callbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
|
||||
callbacks.onTTSChunk?.('message-1', '')
|
||||
callbacks.onTTSEnd?.('message-1', 'audio-finished')
|
||||
|
||||
expect(fetchInspectVars).not.toHaveBeenCalled()
|
||||
expect(invalidAllLastRun).not.toHaveBeenCalled()
|
||||
expect(getOrCreatePlayer).toHaveBeenCalledTimes(1)
|
||||
expect(mockResetMsgId).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should route final workflow events through handlers and continue paused runs with final callbacks', async () => {
|
||||
const handlers = createHandlers()
|
||||
const userCallbacks = createUserCallbacks()
|
||||
const fetchInspectVars = vi.fn()
|
||||
const invalidAllLastRun = vi.fn()
|
||||
const invalidateRunHistory = vi.fn()
|
||||
const setAbortController = vi.fn()
|
||||
const player = {
|
||||
playAudioWithAudio: vi.fn(),
|
||||
} as unknown as AudioPlayer
|
||||
|
||||
const baseSseOptions = createBaseWorkflowRunCallbacks({
|
||||
clientWidth: 480,
|
||||
clientHeight: 320,
|
||||
runHistoryUrl: '/apps/app-1/workflow-runs',
|
||||
isInWorkflowDebug: false,
|
||||
fetchInspectVars: vi.fn(),
|
||||
invalidAllLastRun: vi.fn(),
|
||||
invalidateRunHistory: vi.fn(),
|
||||
clearAbortController: vi.fn(),
|
||||
clearListeningState: vi.fn(),
|
||||
trackWorkflowRunFailed: vi.fn(),
|
||||
handlers,
|
||||
callbacks: {},
|
||||
restCallback: {},
|
||||
getOrCreatePlayer: vi.fn<() => AudioPlayer | null>(() => player),
|
||||
})
|
||||
|
||||
const finalCallbacks = createFinalWorkflowRunCallbacks({
|
||||
clientWidth: 480,
|
||||
clientHeight: 320,
|
||||
runHistoryUrl: '/apps/app-1/workflow-runs',
|
||||
isInWorkflowDebug: true,
|
||||
fetchInspectVars,
|
||||
invalidAllLastRun,
|
||||
invalidateRunHistory,
|
||||
clearAbortController: vi.fn(),
|
||||
clearListeningState: vi.fn(),
|
||||
trackWorkflowRunFailed: vi.fn(),
|
||||
handlers,
|
||||
callbacks: userCallbacks,
|
||||
restCallback: {},
|
||||
baseSseOptions,
|
||||
player,
|
||||
setAbortController,
|
||||
})
|
||||
|
||||
finalCallbacks.getAbortController?.(new AbortController())
|
||||
finalCallbacks.onWorkflowFinished?.({ workflow_run_id: 'run-1' } as never)
|
||||
finalCallbacks.onNodeStarted?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onNodeFinished?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onIterationStart?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onIterationNext?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onIterationFinish?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onLoopStart?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onLoopNext?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onLoopFinish?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onNodeRetry?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onAgentLog?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onTextChunk?.({ data: 'chunk' } as never)
|
||||
finalCallbacks.onTextReplace?.({ text: 'replacement' } as never)
|
||||
finalCallbacks.onHumanInputRequired?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onHumanInputFormFilled?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onHumanInputFormTimeout?.({ node_id: 'node-1' } as never)
|
||||
finalCallbacks.onWorkflowPaused?.({ workflow_run_id: 'run-2' } as never)
|
||||
finalCallbacks.onTTSChunk?.('message-2', 'audio-chunk')
|
||||
finalCallbacks.onTTSEnd?.('message-2', 'audio-finished')
|
||||
await finalCallbacks.onCompleted?.(true, 'done')
|
||||
finalCallbacks.onError?.({ error: 'failed' } as never, '500')
|
||||
|
||||
expect(setAbortController).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowFinished).toHaveBeenCalled()
|
||||
expect(userCallbacks.onWorkflowFinished).toHaveBeenCalled()
|
||||
expect(fetchInspectVars).toHaveBeenCalledWith({})
|
||||
expect(invalidAllLastRun).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeStarted).toHaveBeenCalledWith(
|
||||
{ node_id: 'node-1' },
|
||||
{ clientWidth: 480, clientHeight: 320 },
|
||||
)
|
||||
expect(handlers.handleWorkflowNodeIterationStarted).toHaveBeenCalledWith(
|
||||
{ node_id: 'node-1' },
|
||||
{ clientWidth: 480, clientHeight: 320 },
|
||||
)
|
||||
expect(handlers.handleWorkflowNodeLoopStarted).toHaveBeenCalledWith(
|
||||
{ node_id: 'node-1' },
|
||||
{ clientWidth: 480, clientHeight: 320 },
|
||||
)
|
||||
expect(userCallbacks.onNodeStarted).toHaveBeenCalled()
|
||||
expect(userCallbacks.onNodeFinished).toHaveBeenCalled()
|
||||
expect(userCallbacks.onIterationStart).toHaveBeenCalled()
|
||||
expect(userCallbacks.onIterationNext).toHaveBeenCalled()
|
||||
expect(userCallbacks.onIterationFinish).toHaveBeenCalled()
|
||||
expect(userCallbacks.onLoopStart).toHaveBeenCalled()
|
||||
expect(userCallbacks.onLoopNext).toHaveBeenCalled()
|
||||
expect(userCallbacks.onLoopFinish).toHaveBeenCalled()
|
||||
expect(userCallbacks.onNodeRetry).toHaveBeenCalled()
|
||||
expect(userCallbacks.onAgentLog).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowTextChunk).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowTextReplace).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeHumanInputRequired).toHaveBeenCalled()
|
||||
expect(userCallbacks.onHumanInputRequired).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeHumanInputFormFilled).toHaveBeenCalled()
|
||||
expect(userCallbacks.onHumanInputFormFilled).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowNodeHumanInputFormTimeout).toHaveBeenCalled()
|
||||
expect(userCallbacks.onHumanInputFormTimeout).toHaveBeenCalled()
|
||||
expect(handlers.handleWorkflowPaused).toHaveBeenCalled()
|
||||
expect(userCallbacks.onWorkflowPaused).toHaveBeenCalled()
|
||||
expect(mockSseGet).toHaveBeenCalledWith('/workflow/run-2/events', {}, finalCallbacks)
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-chunk', true)
|
||||
expect(player.playAudioWithAudio).toHaveBeenCalledWith('audio-finished', false)
|
||||
expect(handlers.handleWorkflowFailed).toHaveBeenCalled()
|
||||
expect(userCallbacks.onError).toHaveBeenCalledWith({ error: 'failed' }, '500')
|
||||
expect(invalidateRunHistory).toHaveBeenCalledWith('/apps/app-1/workflow-runs')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,431 @@
|
||||
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import {
|
||||
applyRunningStateForMode,
|
||||
applyStoppedState,
|
||||
buildListeningTriggerNodeIds,
|
||||
buildRunHistoryUrl,
|
||||
buildTTSConfig,
|
||||
buildWorkflowRunRequestBody,
|
||||
clearListeningState,
|
||||
clearWindowDebugControllers,
|
||||
createFailedWorkflowState,
|
||||
createRunningWorkflowState,
|
||||
createStoppedWorkflowState,
|
||||
mapPublishedWorkflowFeatures,
|
||||
normalizePublishedWorkflowNodes,
|
||||
resolveWorkflowRunUrl,
|
||||
runTriggerDebug,
|
||||
validateWorkflowRunRequest,
|
||||
} from '../use-workflow-run-utils'
|
||||
|
||||
const {
|
||||
mockPost,
|
||||
mockHandleStream,
|
||||
mockToastError,
|
||||
} = vi.hoisted(() => ({
|
||||
mockPost: vi.fn(),
|
||||
mockHandleStream: vi.fn(),
|
||||
mockToastError: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
post: mockPost,
|
||||
handleStream: mockHandleStream,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/ui/toast', () => ({
|
||||
toast: {
|
||||
error: mockToastError,
|
||||
},
|
||||
}))
|
||||
|
||||
const createListeningActions = () => ({
|
||||
setWorkflowRunningData: vi.fn(),
|
||||
setIsListening: vi.fn(),
|
||||
setShowVariableInspectPanel: vi.fn(),
|
||||
setListeningTriggerType: vi.fn(),
|
||||
setListeningTriggerNodeIds: vi.fn(),
|
||||
setListeningTriggerIsAll: vi.fn(),
|
||||
setListeningTriggerNodeId: vi.fn(),
|
||||
})
|
||||
|
||||
describe('useWorkflowRun utils', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('should resolve run history urls and run endpoints for workflow modes', () => {
|
||||
expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW })).toBe('/apps/app-1/workflow-runs')
|
||||
expect(buildRunHistoryUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT })).toBe('/apps/app-1/advanced-chat/workflow-runs')
|
||||
|
||||
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, true)).toBe('/apps/app-1/workflows/draft/run')
|
||||
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.ADVANCED_CHAT }, TriggerType.UserInput, false)).toBe('/apps/app-1/advanced-chat/workflows/draft/run')
|
||||
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.Schedule, true)).toBe('/apps/app-1/workflows/draft/trigger/run')
|
||||
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.All, true)).toBe('/apps/app-1/workflows/draft/trigger/run-all')
|
||||
})
|
||||
|
||||
it('should build request bodies and validation errors for trigger runs', () => {
|
||||
expect(buildWorkflowRunRequestBody(TriggerType.Schedule, {}, { scheduleNodeId: 'schedule-1' })).toEqual({ node_id: 'schedule-1' })
|
||||
expect(buildWorkflowRunRequestBody(TriggerType.Webhook, {}, { webhookNodeId: 'webhook-1' })).toEqual({ node_id: 'webhook-1' })
|
||||
expect(buildWorkflowRunRequestBody(TriggerType.Plugin, {}, { pluginNodeId: 'plugin-1' })).toEqual({ node_id: 'plugin-1' })
|
||||
expect(buildWorkflowRunRequestBody(TriggerType.All, {}, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual({ node_ids: ['trigger-1', 'trigger-2'] })
|
||||
expect(buildWorkflowRunRequestBody(TriggerType.UserInput, { inputs: { query: 'hello' } })).toEqual({ inputs: { query: 'hello' } })
|
||||
|
||||
expect(validateWorkflowRunRequest(TriggerType.Schedule)).toBe('handleRun: schedule trigger run requires node id')
|
||||
expect(validateWorkflowRunRequest(TriggerType.Webhook)).toBe('handleRun: webhook trigger run requires node id')
|
||||
expect(validateWorkflowRunRequest(TriggerType.Plugin)).toBe('handleRun: plugin trigger run requires node id')
|
||||
expect(validateWorkflowRunRequest(TriggerType.All)).toBe('')
|
||||
expect(validateWorkflowRunRequest(TriggerType.All, { allNodeIds: [] })).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty trigger urls when app id is missing and keep user-input urls empty outside workflow debug', () => {
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
expect(resolveWorkflowRunUrl(undefined, TriggerType.Plugin, true)).toBe('')
|
||||
expect(resolveWorkflowRunUrl(undefined, TriggerType.All, true)).toBe('')
|
||||
expect(resolveWorkflowRunUrl({ id: 'app-1', mode: AppModeEnum.WORKFLOW }, TriggerType.UserInput, false)).toBe('')
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger plugin run')
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith('handleRun: missing app id for trigger run all')
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should configure listening state for trigger and non-trigger modes', () => {
|
||||
const triggerActions = createListeningActions()
|
||||
|
||||
applyRunningStateForMode(triggerActions, TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })
|
||||
|
||||
expect(triggerActions.setIsListening).toHaveBeenCalledWith(true)
|
||||
expect(triggerActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true)
|
||||
expect(triggerActions.setListeningTriggerIsAll).toHaveBeenCalledWith(true)
|
||||
expect(triggerActions.setListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2'])
|
||||
expect(triggerActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState())
|
||||
|
||||
const normalActions = createListeningActions()
|
||||
applyRunningStateForMode(normalActions, TriggerType.UserInput)
|
||||
|
||||
expect(normalActions.setIsListening).toHaveBeenCalledWith(false)
|
||||
expect(normalActions.setListeningTriggerType).toHaveBeenCalledWith(null)
|
||||
expect(normalActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
|
||||
expect(normalActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
|
||||
expect(normalActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
|
||||
expect(normalActions.setWorkflowRunningData).toHaveBeenCalledWith(createRunningWorkflowState())
|
||||
})
|
||||
|
||||
it('should clear listening state, stop state, and remove debug controllers', () => {
|
||||
const listeningActions = createListeningActions()
|
||||
clearListeningState(listeningActions)
|
||||
|
||||
expect(listeningActions.setIsListening).toHaveBeenCalledWith(false)
|
||||
expect(listeningActions.setListeningTriggerType).toHaveBeenCalledWith(null)
|
||||
expect(listeningActions.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
|
||||
expect(listeningActions.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
|
||||
expect(listeningActions.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
|
||||
|
||||
const stoppedActions = createListeningActions()
|
||||
applyStoppedState(stoppedActions)
|
||||
|
||||
expect(stoppedActions.setWorkflowRunningData).toHaveBeenCalledWith(createStoppedWorkflowState())
|
||||
expect(stoppedActions.setShowVariableInspectPanel).toHaveBeenCalledWith(true)
|
||||
|
||||
const controllerTarget = {
|
||||
__webhookDebugAbortController: { abort: vi.fn() },
|
||||
__pluginDebugAbortController: { abort: vi.fn() },
|
||||
__scheduleDebugAbortController: { abort: vi.fn() },
|
||||
__allTriggersDebugAbortController: { abort: vi.fn() },
|
||||
}
|
||||
clearWindowDebugControllers(controllerTarget)
|
||||
expect(controllerTarget).toEqual({})
|
||||
})
|
||||
|
||||
it('should derive listening node ids, tts config, and published workflow mappings', () => {
|
||||
expect(buildListeningTriggerNodeIds(TriggerType.Webhook, { webhookNodeId: 'webhook-1' })).toEqual(['webhook-1'])
|
||||
expect(buildListeningTriggerNodeIds(TriggerType.Schedule, { scheduleNodeId: 'schedule-1' })).toEqual(['schedule-1'])
|
||||
expect(buildListeningTriggerNodeIds(TriggerType.Plugin, { pluginNodeId: 'plugin-1' })).toEqual(['plugin-1'])
|
||||
expect(buildListeningTriggerNodeIds(TriggerType.All, { allNodeIds: ['trigger-1', 'trigger-2'] })).toEqual(['trigger-1', 'trigger-2'])
|
||||
|
||||
expect(buildTTSConfig({ token: 'public-token' }, '/apps/app-1')).toEqual({
|
||||
ttsUrl: '/text-to-audio',
|
||||
ttsIsPublic: true,
|
||||
})
|
||||
expect(buildTTSConfig({ appId: 'app-1' }, '/explore/installed/app-1')).toEqual({
|
||||
ttsUrl: '/installed-apps/app-1/text-to-audio',
|
||||
ttsIsPublic: false,
|
||||
})
|
||||
expect(buildTTSConfig({ appId: 'app-1' }, '/apps/app-1/workflow')).toEqual({
|
||||
ttsUrl: '/apps/app-1/text-to-audio',
|
||||
ttsIsPublic: false,
|
||||
})
|
||||
|
||||
const publishedWorkflow = {
|
||||
graph: {
|
||||
nodes: [{ id: 'node-1', selected: true, data: { selected: true, title: 'Start' } }],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
features: {
|
||||
opening_statement: 'hello',
|
||||
suggested_questions: ['Q1'],
|
||||
suggested_questions_after_answer: { enabled: true },
|
||||
text_to_speech: { enabled: true },
|
||||
speech_to_text: { enabled: true },
|
||||
retriever_resource: { enabled: true },
|
||||
sensitive_word_avoidance: { enabled: true },
|
||||
file_upload: { enabled: true },
|
||||
},
|
||||
} as never
|
||||
|
||||
expect(normalizePublishedWorkflowNodes(publishedWorkflow)).toEqual([
|
||||
{ id: 'node-1', selected: false, data: { selected: false, title: 'Start' } },
|
||||
])
|
||||
expect(mapPublishedWorkflowFeatures(publishedWorkflow)).toMatchObject({
|
||||
opening: {
|
||||
enabled: true,
|
||||
opening_statement: 'hello',
|
||||
suggested_questions: ['Q1'],
|
||||
},
|
||||
suggested: { enabled: true },
|
||||
text2speech: { enabled: true },
|
||||
speech2text: { enabled: true },
|
||||
citation: { enabled: true },
|
||||
moderation: { enabled: true },
|
||||
file: { enabled: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle trigger debug null and invalid json responses as request failures', async () => {
|
||||
const clearAbortController = vi.fn()
|
||||
const clearListeningStateSpy = vi.fn()
|
||||
const setAbortController = vi.fn()
|
||||
const setWorkflowRunningData = vi.fn()
|
||||
const controllerTarget: Record<string, unknown> = {}
|
||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
mockPost.mockResolvedValueOnce(null)
|
||||
|
||||
await runTriggerDebug({
|
||||
debugType: TriggerType.Webhook,
|
||||
url: '/apps/app-1/workflows/draft/trigger/run',
|
||||
requestBody: { node_id: 'webhook-1' },
|
||||
baseSseOptions: {},
|
||||
controllerTarget,
|
||||
setAbortController,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateSpy,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('Webhook debug request failed')
|
||||
expect(clearAbortController).toHaveBeenCalledTimes(1)
|
||||
expect(clearListeningStateSpy).not.toHaveBeenCalled()
|
||||
|
||||
mockPost.mockResolvedValueOnce(new Response('{invalid-json}', {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
|
||||
await runTriggerDebug({
|
||||
debugType: TriggerType.Schedule,
|
||||
url: '/apps/app-1/workflows/draft/trigger/run',
|
||||
requestBody: { node_id: 'schedule-1' },
|
||||
baseSseOptions: {},
|
||||
controllerTarget,
|
||||
setAbortController,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateSpy,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
|
||||
expect(consoleErrorSpy).toHaveBeenCalledWith(
|
||||
'handleRun: schedule debug response parse error',
|
||||
expect.any(Error),
|
||||
)
|
||||
expect(mockToastError).toHaveBeenCalledWith('Schedule debug request failed')
|
||||
expect(clearAbortController).toHaveBeenCalledTimes(2)
|
||||
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
|
||||
expect(setWorkflowRunningData).not.toHaveBeenCalled()
|
||||
|
||||
consoleErrorSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('should handle trigger debug json failures and stream responses', async () => {
|
||||
const clearAbortController = vi.fn()
|
||||
const clearListeningStateSpy = vi.fn()
|
||||
const setAbortController = vi.fn()
|
||||
const setWorkflowRunningData = vi.fn()
|
||||
const controllerTarget: Record<string, unknown> = {}
|
||||
const baseSseOptions = {
|
||||
onData: vi.fn(),
|
||||
onCompleted: vi.fn(),
|
||||
}
|
||||
|
||||
mockPost.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Webhook failed' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
|
||||
await runTriggerDebug({
|
||||
debugType: TriggerType.Webhook,
|
||||
url: '/apps/app-1/workflows/draft/trigger/run',
|
||||
requestBody: { node_id: 'webhook-1' },
|
||||
baseSseOptions,
|
||||
controllerTarget,
|
||||
setAbortController,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateSpy,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
|
||||
expect(setAbortController).toHaveBeenCalledTimes(1)
|
||||
expect(mockToastError).toHaveBeenCalledWith('Webhook failed')
|
||||
expect(clearAbortController).toHaveBeenCalled()
|
||||
expect(clearListeningStateSpy).toHaveBeenCalled()
|
||||
expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Webhook failed'))
|
||||
|
||||
mockPost.mockResolvedValueOnce(new Response('data: ok', {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}))
|
||||
|
||||
await runTriggerDebug({
|
||||
debugType: TriggerType.Plugin,
|
||||
url: '/apps/app-1/workflows/draft/trigger/run',
|
||||
requestBody: { node_id: 'plugin-1' },
|
||||
baseSseOptions,
|
||||
controllerTarget,
|
||||
setAbortController,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateSpy,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
|
||||
expect(clearListeningStateSpy).toHaveBeenCalledTimes(2)
|
||||
expect(mockHandleStream).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should retry waiting trigger debug responses until a stream is returned', async () => {
|
||||
vi.useFakeTimers()
|
||||
const clearAbortController = vi.fn()
|
||||
const clearListeningStateSpy = vi.fn()
|
||||
const setAbortController = vi.fn()
|
||||
const setWorkflowRunningData = vi.fn()
|
||||
const controllerTarget: Record<string, unknown> = {}
|
||||
const baseSseOptions = {
|
||||
onData: vi.fn(),
|
||||
onCompleted: vi.fn(),
|
||||
}
|
||||
|
||||
mockPost
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({ status: 'waiting', retry_in: 1 }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
.mockResolvedValueOnce(new Response('data: ok', {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}))
|
||||
|
||||
const runPromise = runTriggerDebug({
|
||||
debugType: TriggerType.All,
|
||||
url: '/apps/app-1/workflows/draft/trigger/run-all',
|
||||
requestBody: { node_ids: ['trigger-1'] },
|
||||
baseSseOptions,
|
||||
controllerTarget,
|
||||
setAbortController,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateSpy,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await runPromise
|
||||
|
||||
expect(mockPost).toHaveBeenCalledTimes(2)
|
||||
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
|
||||
expect(mockHandleStream).toHaveBeenCalledTimes(1)
|
||||
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('should stop trigger debug processing when the controller aborts before handling the response', async () => {
|
||||
const clearAbortController = vi.fn()
|
||||
const clearListeningStateSpy = vi.fn()
|
||||
const setWorkflowRunningData = vi.fn()
|
||||
const controllerTarget: Record<string, unknown> = {}
|
||||
|
||||
mockPost.mockResolvedValueOnce(new Response('data: ok', {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}))
|
||||
|
||||
await runTriggerDebug({
|
||||
debugType: TriggerType.Plugin,
|
||||
url: '/apps/app-1/workflows/draft/trigger/run',
|
||||
requestBody: { node_id: 'plugin-1' },
|
||||
baseSseOptions: {},
|
||||
controllerTarget,
|
||||
setAbortController: (controller) => {
|
||||
controller?.abort()
|
||||
},
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateSpy,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
|
||||
expect(mockHandleStream).not.toHaveBeenCalled()
|
||||
expect(mockToastError).not.toHaveBeenCalled()
|
||||
expect(clearAbortController).not.toHaveBeenCalled()
|
||||
expect(clearListeningStateSpy).not.toHaveBeenCalled()
|
||||
expect(setWorkflowRunningData).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle Response and non-Response trigger debug exceptions correctly', async () => {
|
||||
const clearAbortController = vi.fn()
|
||||
const clearListeningStateSpy = vi.fn()
|
||||
const setAbortController = vi.fn()
|
||||
const setWorkflowRunningData = vi.fn()
|
||||
const controllerTarget: Record<string, unknown> = {}
|
||||
|
||||
mockPost.mockRejectedValueOnce(new Response(JSON.stringify({ error: 'Plugin failed' }), {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}))
|
||||
|
||||
await runTriggerDebug({
|
||||
debugType: TriggerType.Plugin,
|
||||
url: '/apps/app-1/workflows/draft/trigger/run',
|
||||
requestBody: { node_id: 'plugin-1' },
|
||||
baseSseOptions: {},
|
||||
controllerTarget,
|
||||
setAbortController,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateSpy,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
|
||||
expect(mockToastError).toHaveBeenCalledWith('Plugin failed')
|
||||
expect(clearAbortController).toHaveBeenCalledTimes(1)
|
||||
expect(setWorkflowRunningData).toHaveBeenCalledWith(createFailedWorkflowState('Plugin failed'))
|
||||
expect(clearListeningStateSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockPost.mockRejectedValueOnce(new Error('network failed'))
|
||||
|
||||
await runTriggerDebug({
|
||||
debugType: TriggerType.Plugin,
|
||||
url: '/apps/app-1/workflows/draft/trigger/run',
|
||||
requestBody: { node_id: 'plugin-1' },
|
||||
baseSseOptions: {},
|
||||
controllerTarget,
|
||||
setAbortController,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateSpy,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
|
||||
expect(clearAbortController).toHaveBeenCalledTimes(1)
|
||||
expect(setWorkflowRunningData).toHaveBeenCalledTimes(1)
|
||||
expect(clearListeningStateSpy).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should expose the canonical workflow state factories', () => {
|
||||
expect(createRunningWorkflowState().result.status).toBe(WorkflowRunningStatus.Running)
|
||||
expect(createStoppedWorkflowState().result.status).toBe(WorkflowRunningStatus.Stopped)
|
||||
expect(createFailedWorkflowState('failed').result.status).toBe(WorkflowRunningStatus.Failed)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,592 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { useWorkflowRun } from '../use-workflow-run'
|
||||
|
||||
type DebugAbortControllerRef = {
|
||||
abort: () => void
|
||||
}
|
||||
|
||||
type DebugControllerWindow = Window & {
|
||||
__webhookDebugAbortController?: DebugAbortControllerRef
|
||||
__pluginDebugAbortController?: DebugAbortControllerRef
|
||||
__scheduleDebugAbortController?: DebugAbortControllerRef
|
||||
__allTriggersDebugAbortController?: DebugAbortControllerRef
|
||||
}
|
||||
|
||||
type WorkflowStoreState = {
|
||||
backupDraft?: unknown
|
||||
environmentVariables?: unknown
|
||||
setBackupDraft?: (value: unknown) => void
|
||||
setEnvironmentVariables?: (value: unknown) => void
|
||||
setWorkflowRunningData?: (value: unknown) => void
|
||||
setIsListening?: (value: boolean) => void
|
||||
setShowVariableInspectPanel?: (value: boolean) => void
|
||||
setListeningTriggerType?: (value: unknown) => void
|
||||
setListeningTriggerNodeIds?: (value: string[]) => void
|
||||
setListeningTriggerIsAll?: (value: boolean) => void
|
||||
setListeningTriggerNodeId?: (value: string | null) => void
|
||||
}
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const appStoreState = {
|
||||
appDetail: {
|
||||
id: 'app-1',
|
||||
mode: 'workflow',
|
||||
name: 'Workflow App',
|
||||
},
|
||||
}
|
||||
const reactFlowStoreState = {
|
||||
edges: [{ id: 'edge-1' }],
|
||||
getNodes: vi.fn(),
|
||||
setNodes: vi.fn(),
|
||||
}
|
||||
const workflowStoreState: WorkflowStoreState = {}
|
||||
const workflowStoreSetState = vi.fn((partial: Record<string, unknown>) => {
|
||||
Object.assign(workflowStoreState, partial)
|
||||
})
|
||||
const featuresStoreState = {
|
||||
features: {
|
||||
file: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
const featuresStoreSetState = vi.fn((partial: Record<string, unknown>) => {
|
||||
Object.assign(featuresStoreState, partial)
|
||||
})
|
||||
|
||||
return {
|
||||
appStoreState,
|
||||
reactFlowStoreState,
|
||||
workflowStoreState,
|
||||
workflowStoreSetState,
|
||||
featuresStoreState,
|
||||
featuresStoreSetState,
|
||||
mockGetViewport: vi.fn(),
|
||||
mockDoSyncWorkflowDraft: vi.fn(),
|
||||
mockHandleUpdateWorkflowCanvas: vi.fn(),
|
||||
mockFetchInspectVars: vi.fn(),
|
||||
mockInvalidateAllLastRun: vi.fn(),
|
||||
mockInvalidateRunHistory: vi.fn(),
|
||||
mockSsePost: vi.fn(),
|
||||
mockSseGet: vi.fn(),
|
||||
mockHandleStream: vi.fn(),
|
||||
mockPost: vi.fn(),
|
||||
mockStopWorkflowRun: vi.fn(),
|
||||
mockTrackEvent: vi.fn(),
|
||||
mockGetAudioPlayer: vi.fn(),
|
||||
mockResetMsgId: vi.fn(),
|
||||
mockCreateBaseWorkflowRunCallbacks: vi.fn(),
|
||||
mockCreateFinalWorkflowRunCallbacks: vi.fn(),
|
||||
runEventHandlers: {
|
||||
handleWorkflowStarted: vi.fn(),
|
||||
handleWorkflowFinished: vi.fn(),
|
||||
handleWorkflowFailed: vi.fn(),
|
||||
handleWorkflowNodeStarted: vi.fn(),
|
||||
handleWorkflowNodeFinished: vi.fn(),
|
||||
handleWorkflowNodeHumanInputRequired: vi.fn(),
|
||||
handleWorkflowNodeHumanInputFormFilled: vi.fn(),
|
||||
handleWorkflowNodeHumanInputFormTimeout: vi.fn(),
|
||||
handleWorkflowNodeIterationStarted: vi.fn(),
|
||||
handleWorkflowNodeIterationNext: vi.fn(),
|
||||
handleWorkflowNodeIterationFinished: vi.fn(),
|
||||
handleWorkflowNodeLoopStarted: vi.fn(),
|
||||
handleWorkflowNodeLoopNext: vi.fn(),
|
||||
handleWorkflowNodeLoopFinished: vi.fn(),
|
||||
handleWorkflowNodeRetry: vi.fn(),
|
||||
handleWorkflowAgentLog: vi.fn(),
|
||||
handleWorkflowTextChunk: vi.fn(),
|
||||
handleWorkflowTextReplace: vi.fn(),
|
||||
handleWorkflowPaused: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => mocks.reactFlowStoreState,
|
||||
}),
|
||||
useReactFlow: () => ({
|
||||
getViewport: mocks.mockGetViewport,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/app/store', () => {
|
||||
const useStore = Object.assign(vi.fn(), {
|
||||
getState: () => mocks.appStoreState,
|
||||
})
|
||||
|
||||
return {
|
||||
useStore,
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/amplitude', () => ({
|
||||
trackEvent: mocks.mockTrackEvent,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/audio-btn/audio.player.manager', () => ({
|
||||
AudioPlayerManager: {
|
||||
getInstance: () => ({
|
||||
getAudioPlayer: mocks.mockGetAudioPlayer,
|
||||
resetMsgId: mocks.mockResetMsgId,
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeaturesStore: () => ({
|
||||
getState: () => mocks.featuresStoreState,
|
||||
setState: mocks.featuresStoreSetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow-interactions', () => ({
|
||||
useWorkflowUpdate: () => ({
|
||||
handleUpdateWorkflowCanvas: mocks.mockHandleUpdateWorkflowCanvas,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event', () => ({
|
||||
useWorkflowRunEvent: () => mocks.runEventHandlers,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => mocks.workflowStoreState,
|
||||
setState: mocks.workflowStoreSetState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/next/navigation', () => ({
|
||||
usePathname: () => '/apps/app-1/workflow',
|
||||
}))
|
||||
|
||||
vi.mock('@/service/base', () => ({
|
||||
ssePost: mocks.mockSsePost,
|
||||
sseGet: mocks.mockSseGet,
|
||||
post: mocks.mockPost,
|
||||
handleStream: mocks.mockHandleStream,
|
||||
}))
|
||||
|
||||
vi.mock('@/service/use-workflow', () => ({
|
||||
useInvalidAllLastRun: () => mocks.mockInvalidateAllLastRun,
|
||||
useInvalidateWorkflowRunHistory: () => mocks.mockInvalidateRunHistory,
|
||||
useInvalidateConversationVarValues: () => vi.fn(),
|
||||
useInvalidateSysVarValues: () => vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/service/workflow', () => ({
|
||||
stopWorkflowRun: mocks.mockStopWorkflowRun,
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks/use-fetch-workflow-inspect-vars', () => ({
|
||||
useSetWorkflowVarsWithValue: () => ({
|
||||
fetchInspectVars: mocks.mockFetchInspectVars,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-configs-map', () => ({
|
||||
useConfigsMap: () => ({
|
||||
flowId: 'flow-1',
|
||||
flowType: 'workflow',
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-nodes-sync-draft', () => ({
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mocks.mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('../use-workflow-run-callbacks', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('../use-workflow-run-callbacks')>()
|
||||
|
||||
return {
|
||||
...actual,
|
||||
createBaseWorkflowRunCallbacks: vi.fn((params) => {
|
||||
mocks.mockCreateBaseWorkflowRunCallbacks(params)
|
||||
return actual.createBaseWorkflowRunCallbacks(params)
|
||||
}),
|
||||
createFinalWorkflowRunCallbacks: vi.fn((params) => {
|
||||
mocks.mockCreateFinalWorkflowRunCallbacks(params)
|
||||
return actual.createFinalWorkflowRunCallbacks(params)
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const createWorkflowStoreState = () => ({
|
||||
backupDraft: undefined,
|
||||
environmentVariables: [{ id: 'env-current', value: 'secret' }],
|
||||
setBackupDraft: vi.fn((value: unknown) => {
|
||||
mocks.workflowStoreState.backupDraft = value
|
||||
}),
|
||||
setEnvironmentVariables: vi.fn((value: unknown) => {
|
||||
mocks.workflowStoreState.environmentVariables = value
|
||||
}),
|
||||
setWorkflowRunningData: vi.fn(),
|
||||
setIsListening: vi.fn(),
|
||||
setShowVariableInspectPanel: vi.fn(),
|
||||
setListeningTriggerType: vi.fn(),
|
||||
setListeningTriggerNodeIds: vi.fn(),
|
||||
setListeningTriggerIsAll: vi.fn(),
|
||||
setListeningTriggerNodeId: vi.fn(),
|
||||
})
|
||||
|
||||
describe('useWorkflowRun', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
document.body.innerHTML = '<div id="workflow-container"></div>'
|
||||
const workflowContainer = document.getElementById('workflow-container')!
|
||||
Object.defineProperty(workflowContainer, 'clientWidth', { value: 960, configurable: true })
|
||||
Object.defineProperty(workflowContainer, 'clientHeight', { value: 540, configurable: true })
|
||||
|
||||
mocks.reactFlowStoreState.getNodes.mockReturnValue([
|
||||
{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } },
|
||||
])
|
||||
mocks.mockGetViewport.mockReturnValue({ x: 1, y: 2, zoom: 1.5 })
|
||||
mocks.mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
|
||||
mocks.mockPost.mockResolvedValue(new Response('data: ok', {
|
||||
headers: { 'content-type': 'text/event-stream' },
|
||||
}))
|
||||
mocks.mockGetAudioPlayer.mockReturnValue({
|
||||
playAudioWithAudio: vi.fn(),
|
||||
})
|
||||
mocks.workflowStoreState.backupDraft = undefined
|
||||
Object.assign(mocks.workflowStoreState, createWorkflowStoreState())
|
||||
mocks.workflowStoreSetState.mockImplementation((partial: Record<string, unknown>) => {
|
||||
Object.assign(mocks.workflowStoreState, partial)
|
||||
})
|
||||
mocks.featuresStoreState.features = {
|
||||
file: {
|
||||
enabled: true,
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
it('should backup the current draft once and skip subsequent backups until it is cleared', () => {
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
act(() => {
|
||||
result.current.handleBackupDraft()
|
||||
result.current.handleBackupDraft()
|
||||
})
|
||||
|
||||
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledTimes(1)
|
||||
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith({
|
||||
nodes: [{ id: 'node-1', data: { selected: true, _runningStatus: 'running' } }],
|
||||
edges: [{ id: 'edge-1' }],
|
||||
viewport: { x: 1, y: 2, zoom: 1.5 },
|
||||
features: { file: { enabled: true } },
|
||||
environmentVariables: [{ id: 'env-current', value: 'secret' }],
|
||||
})
|
||||
expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should load a backup draft into canvas, environment variables, and features state', () => {
|
||||
mocks.workflowStoreState.backupDraft = {
|
||||
nodes: [{ id: 'backup-node' }],
|
||||
edges: [{ id: 'backup-edge' }],
|
||||
viewport: { x: 0, y: 0, zoom: 2 },
|
||||
features: { opening: { enabled: true } },
|
||||
environmentVariables: [{ id: 'env-backup', value: 'value' }],
|
||||
}
|
||||
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
act(() => {
|
||||
result.current.handleLoadBackupDraft()
|
||||
})
|
||||
|
||||
expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [{ id: 'backup-node' }],
|
||||
edges: [{ id: 'backup-edge' }],
|
||||
viewport: { x: 0, y: 0, zoom: 2 },
|
||||
})
|
||||
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-backup', value: 'value' }])
|
||||
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
|
||||
features: { opening: { enabled: true } },
|
||||
})
|
||||
expect(mocks.workflowStoreState.setBackupDraft).toHaveBeenCalledWith(undefined)
|
||||
})
|
||||
|
||||
it('should prepare the graph and dispatch a workflow run through ssePost for user-input mode', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRun({ inputs: { query: 'hello' } })
|
||||
})
|
||||
|
||||
expect(mocks.reactFlowStoreState.setNodes).toHaveBeenCalledWith([
|
||||
{ id: 'node-1', data: { selected: false, _runningStatus: undefined } },
|
||||
])
|
||||
expect(mocks.mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mocks.workflowStoreSetState).toHaveBeenCalledWith({ historyWorkflowData: undefined })
|
||||
expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(false)
|
||||
expect(mocks.workflowStoreState.setListeningTriggerType).toHaveBeenCalledWith(null)
|
||||
expect(mocks.workflowStoreState.setListeningTriggerNodeId).toHaveBeenCalledWith(null)
|
||||
expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith([])
|
||||
expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(false)
|
||||
expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({
|
||||
result: expect.objectContaining({
|
||||
status: WorkflowRunningStatus.Running,
|
||||
}),
|
||||
}))
|
||||
expect(mocks.mockSsePost).toHaveBeenCalledWith(
|
||||
'/apps/app-1/workflows/draft/run',
|
||||
{ body: { inputs: { query: 'hello' } } },
|
||||
expect.objectContaining({
|
||||
getAbortController: expect.any(Function),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
title: 'schedule',
|
||||
params: {},
|
||||
options: { mode: TriggerType.Schedule, scheduleNodeId: 'schedule-1' },
|
||||
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
|
||||
expectedBody: { node_id: 'schedule-1' },
|
||||
expectedNodeIds: ['schedule-1'],
|
||||
expectedIsAll: false,
|
||||
},
|
||||
{
|
||||
title: 'webhook',
|
||||
params: { node_id: 'webhook-1' },
|
||||
options: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' },
|
||||
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
|
||||
expectedBody: { node_id: 'webhook-1' },
|
||||
expectedNodeIds: ['webhook-1'],
|
||||
expectedIsAll: false,
|
||||
},
|
||||
{
|
||||
title: 'plugin',
|
||||
params: { node_id: 'plugin-1' },
|
||||
options: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' },
|
||||
expectedUrl: '/apps/app-1/workflows/draft/trigger/run',
|
||||
expectedBody: { node_id: 'plugin-1' },
|
||||
expectedNodeIds: ['plugin-1'],
|
||||
expectedIsAll: false,
|
||||
},
|
||||
{
|
||||
title: 'all',
|
||||
params: { node_ids: ['trigger-1', 'trigger-2'] },
|
||||
options: { mode: TriggerType.All, allNodeIds: ['trigger-1', 'trigger-2'] },
|
||||
expectedUrl: '/apps/app-1/workflows/draft/trigger/run-all',
|
||||
expectedBody: { node_ids: ['trigger-1', 'trigger-2'] },
|
||||
expectedNodeIds: ['trigger-1', 'trigger-2'],
|
||||
expectedIsAll: true,
|
||||
},
|
||||
])('should dispatch $title trigger runs through the debug runner integration', async ({
|
||||
params,
|
||||
options,
|
||||
expectedUrl,
|
||||
expectedBody,
|
||||
expectedNodeIds,
|
||||
expectedIsAll,
|
||||
}) => {
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRun(params, undefined, options)
|
||||
})
|
||||
|
||||
expect(mocks.mockPost).toHaveBeenCalledWith(
|
||||
expectedUrl,
|
||||
expect.objectContaining({
|
||||
body: expectedBody,
|
||||
signal: expect.any(AbortSignal),
|
||||
}),
|
||||
{ needAllResponseContent: true },
|
||||
)
|
||||
expect(mocks.workflowStoreState.setIsListening).toHaveBeenCalledWith(true)
|
||||
expect(mocks.workflowStoreState.setListeningTriggerNodeIds).toHaveBeenCalledWith(expectedNodeIds)
|
||||
expect(mocks.workflowStoreState.setListeningTriggerIsAll).toHaveBeenCalledWith(expectedIsAll)
|
||||
expect(mocks.mockSsePost).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should expose the workflow-failed tracker through the callback factory context', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRun({ inputs: { query: 'hello' } })
|
||||
})
|
||||
|
||||
const baseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
|
||||
trackWorkflowRunFailed: (params: { error?: string, node_type?: string }) => void
|
||||
}
|
||||
|
||||
baseCallbackFactoryContext.trackWorkflowRunFailed({ error: 'failed', node_type: 'llm' })
|
||||
|
||||
expect(mocks.mockTrackEvent).toHaveBeenCalledWith('workflow_run_failed', {
|
||||
workflow_id: 'flow-1',
|
||||
reason: 'failed',
|
||||
node_type: 'llm',
|
||||
})
|
||||
})
|
||||
|
||||
it('should lazily create audio players with the correct public and private tts urls', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRun({ token: 'public-token' })
|
||||
})
|
||||
|
||||
const publicBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
|
||||
getOrCreatePlayer: () => unknown
|
||||
}
|
||||
|
||||
publicBaseCallbackFactoryContext.getOrCreatePlayer()
|
||||
|
||||
expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith(
|
||||
'/text-to-audio',
|
||||
true,
|
||||
expect.any(String),
|
||||
'none',
|
||||
'none',
|
||||
expect.any(Function),
|
||||
)
|
||||
|
||||
mocks.mockSsePost.mockClear()
|
||||
mocks.mockGetAudioPlayer.mockClear()
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRun({ appId: 'app-2' })
|
||||
})
|
||||
|
||||
const privateBaseCallbackFactoryContext = mocks.mockCreateBaseWorkflowRunCallbacks.mock.calls.at(-1)?.[0] as {
|
||||
getOrCreatePlayer: () => unknown
|
||||
}
|
||||
|
||||
privateBaseCallbackFactoryContext.getOrCreatePlayer()
|
||||
|
||||
expect(mocks.mockGetAudioPlayer).toHaveBeenCalledWith(
|
||||
'/apps/app-2/text-to-audio',
|
||||
false,
|
||||
expect.any(String),
|
||||
'none',
|
||||
'none',
|
||||
expect.any(Function),
|
||||
)
|
||||
})
|
||||
|
||||
it('should stop workflow runs by task id or by aborting active debug controllers', async () => {
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleRun({ inputs: { query: 'hello' } })
|
||||
})
|
||||
|
||||
act(() => {
|
||||
result.current.handleStopRun('task-1')
|
||||
})
|
||||
|
||||
expect(mocks.mockStopWorkflowRun).toHaveBeenCalledWith('/apps/app-1/workflow-runs/tasks/task-1/stop')
|
||||
expect(mocks.workflowStoreState.setWorkflowRunningData).toHaveBeenCalledWith(expect.objectContaining({
|
||||
result: expect.objectContaining({
|
||||
status: WorkflowRunningStatus.Stopped,
|
||||
}),
|
||||
}))
|
||||
|
||||
const webhookAbort = vi.fn()
|
||||
const pluginAbort = vi.fn()
|
||||
const scheduleAbort = vi.fn()
|
||||
const allTriggersAbort = vi.fn()
|
||||
const windowWithDebugControllers = window as DebugControllerWindow
|
||||
windowWithDebugControllers.__webhookDebugAbortController = { abort: webhookAbort }
|
||||
windowWithDebugControllers.__pluginDebugAbortController = { abort: pluginAbort }
|
||||
windowWithDebugControllers.__scheduleDebugAbortController = { abort: scheduleAbort }
|
||||
windowWithDebugControllers.__allTriggersDebugAbortController = { abort: allTriggersAbort }
|
||||
const refController = new AbortController()
|
||||
const refAbortSpy = vi.spyOn(refController, 'abort')
|
||||
const { getAbortController } = mocks.mockSsePost.mock.calls.at(-1)?.[2] as {
|
||||
getAbortController?: (controller: AbortController) => void
|
||||
}
|
||||
getAbortController?.(refController)
|
||||
|
||||
act(() => {
|
||||
result.current.handleStopRun('')
|
||||
})
|
||||
|
||||
expect(webhookAbort).toHaveBeenCalled()
|
||||
expect(pluginAbort).toHaveBeenCalled()
|
||||
expect(scheduleAbort).toHaveBeenCalled()
|
||||
expect(allTriggersAbort).toHaveBeenCalled()
|
||||
expect(refAbortSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should restore published workflow graph, features, and environment variables', () => {
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRestoreFromPublishedWorkflow({
|
||||
graph: {
|
||||
nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }],
|
||||
edges: [{ id: 'published-edge' }],
|
||||
viewport: { x: 10, y: 20, zoom: 0.8 },
|
||||
},
|
||||
features: {
|
||||
opening_statement: 'hello',
|
||||
suggested_questions: ['Q1'],
|
||||
suggested_questions_after_answer: { enabled: true },
|
||||
text_to_speech: { enabled: true },
|
||||
speech_to_text: { enabled: true },
|
||||
retriever_resource: { enabled: true },
|
||||
sensitive_word_avoidance: { enabled: true },
|
||||
file_upload: { enabled: true },
|
||||
},
|
||||
environment_variables: [{ id: 'env-published', value: 'value' }],
|
||||
} as never)
|
||||
})
|
||||
|
||||
expect(mocks.mockHandleUpdateWorkflowCanvas).toHaveBeenCalledWith({
|
||||
nodes: [{ id: 'published-node', selected: false, data: { selected: false, label: 'Published' } }],
|
||||
edges: [{ id: 'published-edge' }],
|
||||
viewport: { x: 10, y: 20, zoom: 0.8 },
|
||||
})
|
||||
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
|
||||
features: expect.objectContaining({
|
||||
opening: expect.objectContaining({
|
||||
enabled: true,
|
||||
opening_statement: 'hello',
|
||||
}),
|
||||
file: { enabled: true },
|
||||
}),
|
||||
})
|
||||
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([{ id: 'env-published', value: 'value' }])
|
||||
})
|
||||
|
||||
it('should restore published workflows with empty environment variables as an empty list', () => {
|
||||
const { result } = renderHook(() => useWorkflowRun())
|
||||
|
||||
act(() => {
|
||||
result.current.handleRestoreFromPublishedWorkflow({
|
||||
graph: {
|
||||
nodes: [{ id: 'published-node', selected: true, data: { selected: true, label: 'Published' } }],
|
||||
edges: [],
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
},
|
||||
features: {
|
||||
opening_statement: '',
|
||||
suggested_questions: [],
|
||||
suggested_questions_after_answer: { enabled: false },
|
||||
text_to_speech: { enabled: false },
|
||||
speech_to_text: { enabled: false },
|
||||
retriever_resource: { enabled: false },
|
||||
sensitive_word_avoidance: { enabled: false },
|
||||
file_upload: { enabled: false },
|
||||
},
|
||||
} as never)
|
||||
})
|
||||
|
||||
expect(mocks.featuresStoreSetState).toHaveBeenCalledWith({
|
||||
features: expect.objectContaining({
|
||||
opening: expect.objectContaining({ enabled: false }),
|
||||
file: { enabled: false },
|
||||
}),
|
||||
})
|
||||
expect(mocks.workflowStoreState.setEnvironmentVariables).toHaveBeenCalledWith([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,391 @@
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
|
||||
import {
|
||||
BlockEnum,
|
||||
WorkflowRunningStatus,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { useWorkflowStartRun } from '../use-workflow-start-run'
|
||||
|
||||
const mockGetNodes = vi.fn()
|
||||
const mockGetFeaturesState = vi.fn()
|
||||
const mockHandleCancelDebugAndPreviewPanel = vi.fn()
|
||||
const mockHandleRun = vi.fn()
|
||||
const mockDoSyncWorkflowDraft = vi.fn()
|
||||
const mockUseIsChatMode = vi.fn()
|
||||
|
||||
const mockSetShowDebugAndPreviewPanel = vi.fn()
|
||||
const mockSetShowInputsPanel = vi.fn()
|
||||
const mockSetShowEnvPanel = vi.fn()
|
||||
const mockSetShowGlobalVariablePanel = vi.fn()
|
||||
const mockSetShowChatVariablePanel = vi.fn()
|
||||
const mockSetListeningTriggerType = vi.fn()
|
||||
const mockSetListeningTriggerNodeId = vi.fn()
|
||||
const mockSetListeningTriggerNodeIds = vi.fn()
|
||||
const mockSetListeningTriggerIsAll = vi.fn()
|
||||
const mockSetHistoryWorkflowData = vi.fn()
|
||||
|
||||
let workflowStoreState: Record<string, unknown>
|
||||
|
||||
vi.mock('reactflow', () => ({
|
||||
useStoreApi: () => ({
|
||||
getState: () => ({
|
||||
getNodes: mockGetNodes,
|
||||
}),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/features/hooks', () => ({
|
||||
useFeaturesStore: () => ({
|
||||
getState: mockGetFeaturesState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/hooks', () => ({
|
||||
useWorkflowInteractions: () => ({
|
||||
handleCancelDebugAndPreviewPanel: mockHandleCancelDebugAndPreviewPanel,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/store', () => ({
|
||||
useWorkflowStore: () => ({
|
||||
getState: () => workflowStoreState,
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks', () => ({
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
useNodesSyncDraft: () => ({
|
||||
doSyncWorkflowDraft: mockDoSyncWorkflowDraft,
|
||||
}),
|
||||
useWorkflowRun: () => ({
|
||||
handleRun: mockHandleRun,
|
||||
}),
|
||||
}))
|
||||
|
||||
const createWorkflowStoreState = (overrides: Record<string, unknown> = {}) => ({
|
||||
workflowRunningData: undefined,
|
||||
showDebugAndPreviewPanel: false,
|
||||
setShowDebugAndPreviewPanel: mockSetShowDebugAndPreviewPanel,
|
||||
setShowInputsPanel: mockSetShowInputsPanel,
|
||||
setShowEnvPanel: mockSetShowEnvPanel,
|
||||
setShowGlobalVariablePanel: mockSetShowGlobalVariablePanel,
|
||||
setShowChatVariablePanel: mockSetShowChatVariablePanel,
|
||||
setListeningTriggerType: mockSetListeningTriggerType,
|
||||
setListeningTriggerNodeId: mockSetListeningTriggerNodeId,
|
||||
setListeningTriggerNodeIds: mockSetListeningTriggerNodeIds,
|
||||
setListeningTriggerIsAll: mockSetListeningTriggerIsAll,
|
||||
setHistoryWorkflowData: mockSetHistoryWorkflowData,
|
||||
...overrides,
|
||||
})
|
||||
|
||||
describe('useWorkflowStartRun', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
workflowStoreState = createWorkflowStoreState()
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'start-1', data: { type: BlockEnum.Start, variables: [] } },
|
||||
])
|
||||
mockGetFeaturesState.mockReturnValue({
|
||||
features: {
|
||||
file: {
|
||||
image: {
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
mockDoSyncWorkflowDraft.mockResolvedValue(undefined)
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
})
|
||||
|
||||
it('should run the workflow immediately when there are no start variables and no image upload input', async () => {
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockHandleRun).toHaveBeenCalledWith({ inputs: {}, files: [] })
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should open the input panel instead of running immediately when start inputs are required', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'start-1', data: { type: BlockEnum.Start, variables: [{ name: 'query' }] } },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should open the input panel when image upload is enabled even without start variables', async () => {
|
||||
mockGetFeaturesState.mockReturnValue({
|
||||
features: {
|
||||
file: {
|
||||
image: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('should cancel the current debug panel instead of starting another workflow when one is already open', async () => {
|
||||
workflowStoreState = createWorkflowStoreState({
|
||||
showDebugAndPreviewPanel: true,
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should short-circuit workflow start when a run is already in progress', async () => {
|
||||
workflowStoreState = createWorkflowStoreState({
|
||||
workflowRunningData: {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowStartRunInWorkflow()
|
||||
})
|
||||
|
||||
expect(mockSetShowEnvPanel).not.toHaveBeenCalled()
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should configure schedule trigger runs and execute the workflow with schedule options', async () => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1')
|
||||
})
|
||||
|
||||
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetListeningTriggerType).toHaveBeenCalledWith(BlockEnum.TriggerSchedule)
|
||||
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith('schedule-1')
|
||||
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['schedule-1'])
|
||||
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockHandleRun).toHaveBeenCalledWith(
|
||||
{},
|
||||
undefined,
|
||||
{
|
||||
mode: TriggerType.Schedule,
|
||||
scheduleNodeId: 'schedule-1',
|
||||
},
|
||||
)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('should cancel schedule trigger execution when the debug panel is already open', async () => {
|
||||
workflowStoreState = createWorkflowStoreState({
|
||||
showDebugAndPreviewPanel: true,
|
||||
})
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: 'schedule-1', data: { type: BlockEnum.TriggerSchedule } },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowTriggerScheduleRunInWorkflow('schedule-1')
|
||||
})
|
||||
|
||||
expect(mockHandleCancelDebugAndPreviewPanel).toHaveBeenCalled()
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
title: 'schedule',
|
||||
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerScheduleRunInWorkflow(undefined),
|
||||
},
|
||||
{
|
||||
title: 'webhook',
|
||||
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: '' }),
|
||||
},
|
||||
{
|
||||
title: 'plugin',
|
||||
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow(''),
|
||||
},
|
||||
])('should ignore $title trigger execution when the node id is empty', async ({ invoke }) => {
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await invoke(result.current)
|
||||
})
|
||||
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
title: 'schedule',
|
||||
warnMessage: 'handleWorkflowTriggerScheduleRunInWorkflow: schedule node not found',
|
||||
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerScheduleRunInWorkflow('schedule-missing'),
|
||||
},
|
||||
{
|
||||
title: 'webhook',
|
||||
warnMessage: 'handleWorkflowTriggerWebhookRunInWorkflow: webhook node not found',
|
||||
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-missing' }),
|
||||
},
|
||||
{
|
||||
title: 'plugin',
|
||||
warnMessage: 'handleWorkflowTriggerPluginRunInWorkflow: plugin node not found',
|
||||
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-missing'),
|
||||
},
|
||||
])('should warn when the $title trigger node cannot be found', async ({ warnMessage, invoke }) => {
|
||||
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
mockGetNodes.mockReturnValue([{ id: 'other-node', data: { type: BlockEnum.Start } }])
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await invoke(result.current)
|
||||
})
|
||||
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(warnMessage, expect.stringContaining('missing'))
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
|
||||
consoleWarnSpy.mockRestore()
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
title: 'webhook',
|
||||
nodeId: 'webhook-1',
|
||||
nodeType: BlockEnum.TriggerWebhook,
|
||||
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerWebhookRunInWorkflow({ nodeId: 'webhook-1' }),
|
||||
expectedParams: { node_id: 'webhook-1' },
|
||||
expectedOptions: { mode: TriggerType.Webhook, webhookNodeId: 'webhook-1' },
|
||||
},
|
||||
{
|
||||
title: 'plugin',
|
||||
nodeId: 'plugin-1',
|
||||
nodeType: BlockEnum.TriggerPlugin,
|
||||
invoke: (hook: ReturnType<typeof useWorkflowStartRun>) => hook.handleWorkflowTriggerPluginRunInWorkflow('plugin-1'),
|
||||
expectedParams: { node_id: 'plugin-1' },
|
||||
expectedOptions: { mode: TriggerType.Plugin, pluginNodeId: 'plugin-1' },
|
||||
},
|
||||
])('should configure $title trigger runs with node-specific options', async ({ nodeId, nodeType, invoke, expectedParams, expectedOptions }) => {
|
||||
mockGetNodes.mockReturnValue([
|
||||
{ id: nodeId, data: { type: nodeType } },
|
||||
])
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await invoke(result.current)
|
||||
})
|
||||
|
||||
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetListeningTriggerType).toHaveBeenCalledWith(nodeType)
|
||||
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(nodeId)
|
||||
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith([nodeId])
|
||||
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(false)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockHandleRun).toHaveBeenCalledWith(expectedParams, undefined, expectedOptions)
|
||||
})
|
||||
|
||||
it('should run all triggers and mark the listener state as global', async () => {
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowRunAllTriggersInWorkflow(['trigger-1', 'trigger-2'])
|
||||
})
|
||||
|
||||
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowInputsPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetListeningTriggerIsAll).toHaveBeenCalledWith(true)
|
||||
expect(mockSetListeningTriggerNodeIds).toHaveBeenCalledWith(['trigger-1', 'trigger-2'])
|
||||
expect(mockSetListeningTriggerNodeId).toHaveBeenCalledWith(null)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockDoSyncWorkflowDraft).toHaveBeenCalled()
|
||||
expect(mockHandleRun).toHaveBeenCalledWith(
|
||||
{ node_ids: ['trigger-1', 'trigger-2'] },
|
||||
undefined,
|
||||
{
|
||||
mode: TriggerType.All,
|
||||
allNodeIds: ['trigger-1', 'trigger-2'],
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('should ignore run-all requests when there are no trigger nodes', async () => {
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleWorkflowRunAllTriggersInWorkflow([])
|
||||
})
|
||||
|
||||
expect(mockSetListeningTriggerIsAll).not.toHaveBeenCalled()
|
||||
expect(mockDoSyncWorkflowDraft).not.toHaveBeenCalled()
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should route handleStartWorkflowRun to the chatflow path when chat mode is enabled', async () => {
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowStartRun())
|
||||
|
||||
await act(async () => {
|
||||
result.current.handleStartWorkflowRun()
|
||||
})
|
||||
|
||||
expect(mockSetShowEnvPanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowChatVariablePanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowGlobalVariablePanel).toHaveBeenCalledWith(false)
|
||||
expect(mockSetShowDebugAndPreviewPanel).toHaveBeenCalledWith(true)
|
||||
expect(mockSetHistoryWorkflowData).toHaveBeenCalledWith(undefined)
|
||||
expect(mockHandleRun).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { renderHook } from '@testing-library/react'
|
||||
import { useWorkflowTemplate } from '../use-workflow-template'
|
||||
|
||||
const mockUseIsChatMode = vi.fn()
|
||||
let generateNewNodeCalls: Array<Record<string, unknown>> = []
|
||||
|
||||
vi.mock('@/app/components/workflow-app/hooks/use-is-chat-mode', () => ({
|
||||
useIsChatMode: () => mockUseIsChatMode(),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/utils', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/app/components/workflow/utils')>()
|
||||
return {
|
||||
...actual,
|
||||
generateNewNode: (args: { id?: string, data: Record<string, unknown>, position: Record<string, unknown> }) => {
|
||||
generateNewNodeCalls.push(args)
|
||||
return {
|
||||
newNode: {
|
||||
id: args.id ?? `generated-${generateNewNodeCalls.length}`,
|
||||
data: args.data,
|
||||
position: args.position,
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
describe('useWorkflowTemplate', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
generateNewNodeCalls = []
|
||||
})
|
||||
|
||||
it('should return only the start node template in workflow mode', () => {
|
||||
mockUseIsChatMode.mockReturnValue(false)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowTemplate())
|
||||
|
||||
expect(result.current.nodes).toHaveLength(1)
|
||||
expect(result.current.edges).toEqual([])
|
||||
expect(generateNewNodeCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('should build start, llm, and answer templates with linked edges in chat mode', () => {
|
||||
mockUseIsChatMode.mockReturnValue(true)
|
||||
|
||||
const { result } = renderHook(() => useWorkflowTemplate())
|
||||
|
||||
expect(result.current.nodes).toHaveLength(3)
|
||||
expect(result.current.nodes.map(node => node.id)).toEqual(['generated-1', 'llm', 'answer'])
|
||||
expect(result.current.edges).toEqual([
|
||||
{
|
||||
id: 'generated-1-llm',
|
||||
source: 'generated-1',
|
||||
sourceHandle: 'source',
|
||||
target: 'llm',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
{
|
||||
id: 'llm-answer',
|
||||
source: 'llm',
|
||||
sourceHandle: 'source',
|
||||
target: 'answer',
|
||||
targetHandle: 'target',
|
||||
},
|
||||
])
|
||||
expect(generateNewNodeCalls).toHaveLength(3)
|
||||
expect(generateNewNodeCalls[0].data).toMatchObject({
|
||||
type: 'start',
|
||||
title: 'workflow.blocks.start',
|
||||
})
|
||||
expect(generateNewNodeCalls[1].data).toMatchObject({
|
||||
type: 'llm',
|
||||
title: 'workflow.blocks.llm',
|
||||
})
|
||||
expect(generateNewNodeCalls[2].data).toMatchObject({
|
||||
type: 'answer',
|
||||
title: 'workflow.blocks.answer',
|
||||
answer: '{{#llm.text#}}',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,470 @@
|
||||
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import { sseGet } from '@/service/base'
|
||||
|
||||
type ContainerSize = {
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
}
|
||||
|
||||
type WorkflowRunEventHandlers = {
|
||||
handleWorkflowStarted: NonNullable<IOtherOptions['onWorkflowStarted']>
|
||||
handleWorkflowFinished: NonNullable<IOtherOptions['onWorkflowFinished']>
|
||||
handleWorkflowFailed: () => void
|
||||
handleWorkflowNodeStarted: (params: Parameters<NonNullable<IOtherOptions['onNodeStarted']>>[0], containerParams: ContainerSize) => void
|
||||
handleWorkflowNodeFinished: NonNullable<IOtherOptions['onNodeFinished']>
|
||||
handleWorkflowNodeHumanInputRequired: NonNullable<IOtherOptions['onHumanInputRequired']>
|
||||
handleWorkflowNodeHumanInputFormFilled: NonNullable<IOtherOptions['onHumanInputFormFilled']>
|
||||
handleWorkflowNodeHumanInputFormTimeout: NonNullable<IOtherOptions['onHumanInputFormTimeout']>
|
||||
handleWorkflowNodeIterationStarted: (params: Parameters<NonNullable<IOtherOptions['onIterationStart']>>[0], containerParams: ContainerSize) => void
|
||||
handleWorkflowNodeIterationNext: NonNullable<IOtherOptions['onIterationNext']>
|
||||
handleWorkflowNodeIterationFinished: NonNullable<IOtherOptions['onIterationFinish']>
|
||||
handleWorkflowNodeLoopStarted: (params: Parameters<NonNullable<IOtherOptions['onLoopStart']>>[0], containerParams: ContainerSize) => void
|
||||
handleWorkflowNodeLoopNext: NonNullable<IOtherOptions['onLoopNext']>
|
||||
handleWorkflowNodeLoopFinished: NonNullable<IOtherOptions['onLoopFinish']>
|
||||
handleWorkflowNodeRetry: NonNullable<IOtherOptions['onNodeRetry']>
|
||||
handleWorkflowAgentLog: NonNullable<IOtherOptions['onAgentLog']>
|
||||
handleWorkflowTextChunk: NonNullable<IOtherOptions['onTextChunk']>
|
||||
handleWorkflowTextReplace: NonNullable<IOtherOptions['onTextReplace']>
|
||||
handleWorkflowPaused: () => void
|
||||
}
|
||||
|
||||
type UserCallbackHandlers = {
|
||||
onWorkflowStarted?: IOtherOptions['onWorkflowStarted']
|
||||
onWorkflowFinished?: IOtherOptions['onWorkflowFinished']
|
||||
onNodeStarted?: IOtherOptions['onNodeStarted']
|
||||
onNodeFinished?: IOtherOptions['onNodeFinished']
|
||||
onIterationStart?: IOtherOptions['onIterationStart']
|
||||
onIterationNext?: IOtherOptions['onIterationNext']
|
||||
onIterationFinish?: IOtherOptions['onIterationFinish']
|
||||
onLoopStart?: IOtherOptions['onLoopStart']
|
||||
onLoopNext?: IOtherOptions['onLoopNext']
|
||||
onLoopFinish?: IOtherOptions['onLoopFinish']
|
||||
onNodeRetry?: IOtherOptions['onNodeRetry']
|
||||
onAgentLog?: IOtherOptions['onAgentLog']
|
||||
onError?: IOtherOptions['onError']
|
||||
onWorkflowPaused?: IOtherOptions['onWorkflowPaused']
|
||||
onHumanInputRequired?: IOtherOptions['onHumanInputRequired']
|
||||
onHumanInputFormFilled?: IOtherOptions['onHumanInputFormFilled']
|
||||
onHumanInputFormTimeout?: IOtherOptions['onHumanInputFormTimeout']
|
||||
onCompleted?: IOtherOptions['onCompleted']
|
||||
}
|
||||
|
||||
type CallbackContext = {
|
||||
clientWidth: number
|
||||
clientHeight: number
|
||||
runHistoryUrl: string
|
||||
isInWorkflowDebug: boolean
|
||||
fetchInspectVars: (params: Record<string, never>) => void
|
||||
invalidAllLastRun: () => void
|
||||
invalidateRunHistory: (url: string) => void
|
||||
clearAbortController: () => void
|
||||
clearListeningState: () => void
|
||||
trackWorkflowRunFailed: (params: unknown) => void
|
||||
handlers: WorkflowRunEventHandlers
|
||||
callbacks: UserCallbackHandlers
|
||||
restCallback: IOtherOptions
|
||||
}
|
||||
|
||||
type BaseCallbacksContext = CallbackContext & {
|
||||
getOrCreatePlayer: () => AudioPlayer | null
|
||||
}
|
||||
|
||||
type FinalCallbacksContext = CallbackContext & {
|
||||
baseSseOptions: IOtherOptions
|
||||
player: AudioPlayer | null
|
||||
setAbortController: (controller: AbortController) => void
|
||||
}
|
||||
|
||||
export const createBaseWorkflowRunCallbacks = ({
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
runHistoryUrl,
|
||||
isInWorkflowDebug,
|
||||
fetchInspectVars,
|
||||
invalidAllLastRun,
|
||||
invalidateRunHistory,
|
||||
clearAbortController,
|
||||
clearListeningState,
|
||||
trackWorkflowRunFailed,
|
||||
handlers,
|
||||
callbacks,
|
||||
restCallback,
|
||||
getOrCreatePlayer,
|
||||
}: BaseCallbacksContext): IOtherOptions => {
|
||||
const {
|
||||
handleWorkflowStarted,
|
||||
handleWorkflowFinished,
|
||||
handleWorkflowFailed,
|
||||
handleWorkflowNodeStarted,
|
||||
handleWorkflowNodeFinished,
|
||||
handleWorkflowNodeHumanInputRequired,
|
||||
handleWorkflowNodeHumanInputFormFilled,
|
||||
handleWorkflowNodeHumanInputFormTimeout,
|
||||
handleWorkflowNodeIterationStarted,
|
||||
handleWorkflowNodeIterationNext,
|
||||
handleWorkflowNodeIterationFinished,
|
||||
handleWorkflowNodeLoopStarted,
|
||||
handleWorkflowNodeLoopNext,
|
||||
handleWorkflowNodeLoopFinished,
|
||||
handleWorkflowNodeRetry,
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowPaused,
|
||||
} = handlers
|
||||
const {
|
||||
onWorkflowStarted,
|
||||
onWorkflowFinished,
|
||||
onNodeStarted,
|
||||
onNodeFinished,
|
||||
onIterationStart,
|
||||
onIterationNext,
|
||||
onIterationFinish,
|
||||
onLoopStart,
|
||||
onLoopNext,
|
||||
onLoopFinish,
|
||||
onNodeRetry,
|
||||
onAgentLog,
|
||||
onError,
|
||||
onWorkflowPaused,
|
||||
onHumanInputRequired,
|
||||
onHumanInputFormFilled,
|
||||
onHumanInputFormTimeout,
|
||||
onCompleted,
|
||||
} = callbacks
|
||||
|
||||
const wrappedOnError: IOtherOptions['onError'] = (params, code) => {
|
||||
clearAbortController()
|
||||
handleWorkflowFailed()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
clearListeningState()
|
||||
|
||||
if (onError)
|
||||
onError(params, code)
|
||||
|
||||
trackWorkflowRunFailed(params)
|
||||
}
|
||||
|
||||
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError, errorMessage) => {
|
||||
clearAbortController()
|
||||
clearListeningState()
|
||||
if (onCompleted)
|
||||
onCompleted(hasError, errorMessage)
|
||||
}
|
||||
|
||||
const baseSseOptions: IOtherOptions = {
|
||||
...restCallback,
|
||||
onWorkflowStarted: (params) => {
|
||||
handleWorkflowStarted(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
if (onWorkflowStarted)
|
||||
onWorkflowStarted(params)
|
||||
},
|
||||
onWorkflowFinished: (params) => {
|
||||
clearListeningState()
|
||||
handleWorkflowFinished(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
if (onWorkflowFinished)
|
||||
onWorkflowFinished(params)
|
||||
if (isInWorkflowDebug) {
|
||||
fetchInspectVars({})
|
||||
invalidAllLastRun()
|
||||
}
|
||||
},
|
||||
onNodeStarted: (params) => {
|
||||
handleWorkflowNodeStarted(params, { clientWidth, clientHeight })
|
||||
|
||||
if (onNodeStarted)
|
||||
onNodeStarted(params)
|
||||
},
|
||||
onNodeFinished: (params) => {
|
||||
handleWorkflowNodeFinished(params)
|
||||
|
||||
if (onNodeFinished)
|
||||
onNodeFinished(params)
|
||||
},
|
||||
onIterationStart: (params) => {
|
||||
handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight })
|
||||
|
||||
if (onIterationStart)
|
||||
onIterationStart(params)
|
||||
},
|
||||
onIterationNext: (params) => {
|
||||
handleWorkflowNodeIterationNext(params)
|
||||
|
||||
if (onIterationNext)
|
||||
onIterationNext(params)
|
||||
},
|
||||
onIterationFinish: (params) => {
|
||||
handleWorkflowNodeIterationFinished(params)
|
||||
|
||||
if (onIterationFinish)
|
||||
onIterationFinish(params)
|
||||
},
|
||||
onLoopStart: (params) => {
|
||||
handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight })
|
||||
|
||||
if (onLoopStart)
|
||||
onLoopStart(params)
|
||||
},
|
||||
onLoopNext: (params) => {
|
||||
handleWorkflowNodeLoopNext(params)
|
||||
|
||||
if (onLoopNext)
|
||||
onLoopNext(params)
|
||||
},
|
||||
onLoopFinish: (params) => {
|
||||
handleWorkflowNodeLoopFinished(params)
|
||||
|
||||
if (onLoopFinish)
|
||||
onLoopFinish(params)
|
||||
},
|
||||
onNodeRetry: (params) => {
|
||||
handleWorkflowNodeRetry(params)
|
||||
|
||||
if (onNodeRetry)
|
||||
onNodeRetry(params)
|
||||
},
|
||||
onAgentLog: (params) => {
|
||||
handleWorkflowAgentLog(params)
|
||||
|
||||
if (onAgentLog)
|
||||
onAgentLog(params)
|
||||
},
|
||||
onTextChunk: (params) => {
|
||||
handleWorkflowTextChunk(params)
|
||||
},
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
onTTSChunk: (messageId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
const audioPlayer = getOrCreatePlayer()
|
||||
if (audioPlayer) {
|
||||
audioPlayer.playAudioWithAudio(audio, true)
|
||||
AudioPlayerManager.getInstance().resetMsgId(messageId)
|
||||
}
|
||||
},
|
||||
onTTSEnd: (_messageId: string, audio: string) => {
|
||||
const audioPlayer = getOrCreatePlayer()
|
||||
if (audioPlayer)
|
||||
audioPlayer.playAudioWithAudio(audio, false)
|
||||
},
|
||||
onWorkflowPaused: (params) => {
|
||||
handleWorkflowPaused()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
if (onWorkflowPaused)
|
||||
onWorkflowPaused(params)
|
||||
const url = `/workflow/${params.workflow_run_id}/events`
|
||||
sseGet(url, {}, baseSseOptions)
|
||||
},
|
||||
onHumanInputRequired: (params) => {
|
||||
handleWorkflowNodeHumanInputRequired(params)
|
||||
if (onHumanInputRequired)
|
||||
onHumanInputRequired(params)
|
||||
},
|
||||
onHumanInputFormFilled: (params) => {
|
||||
handleWorkflowNodeHumanInputFormFilled(params)
|
||||
if (onHumanInputFormFilled)
|
||||
onHumanInputFormFilled(params)
|
||||
},
|
||||
onHumanInputFormTimeout: (params) => {
|
||||
handleWorkflowNodeHumanInputFormTimeout(params)
|
||||
if (onHumanInputFormTimeout)
|
||||
onHumanInputFormTimeout(params)
|
||||
},
|
||||
onError: wrappedOnError,
|
||||
onCompleted: wrappedOnCompleted,
|
||||
}
|
||||
|
||||
return baseSseOptions
|
||||
}
|
||||
|
||||
export const createFinalWorkflowRunCallbacks = ({
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
runHistoryUrl,
|
||||
isInWorkflowDebug,
|
||||
fetchInspectVars,
|
||||
invalidAllLastRun,
|
||||
invalidateRunHistory,
|
||||
clearAbortController: _clearAbortController,
|
||||
clearListeningState: _clearListeningState,
|
||||
trackWorkflowRunFailed: _trackWorkflowRunFailed,
|
||||
handlers,
|
||||
callbacks,
|
||||
restCallback,
|
||||
baseSseOptions,
|
||||
player,
|
||||
setAbortController,
|
||||
}: FinalCallbacksContext): IOtherOptions => {
|
||||
const {
|
||||
handleWorkflowFinished,
|
||||
handleWorkflowFailed,
|
||||
handleWorkflowNodeStarted,
|
||||
handleWorkflowNodeFinished,
|
||||
handleWorkflowNodeHumanInputRequired,
|
||||
handleWorkflowNodeHumanInputFormFilled,
|
||||
handleWorkflowNodeHumanInputFormTimeout,
|
||||
handleWorkflowNodeIterationStarted,
|
||||
handleWorkflowNodeIterationNext,
|
||||
handleWorkflowNodeIterationFinished,
|
||||
handleWorkflowNodeLoopStarted,
|
||||
handleWorkflowNodeLoopNext,
|
||||
handleWorkflowNodeLoopFinished,
|
||||
handleWorkflowNodeRetry,
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowPaused,
|
||||
} = handlers
|
||||
const {
|
||||
onWorkflowFinished,
|
||||
onNodeStarted,
|
||||
onNodeFinished,
|
||||
onIterationStart,
|
||||
onIterationNext,
|
||||
onIterationFinish,
|
||||
onLoopStart,
|
||||
onLoopNext,
|
||||
onLoopFinish,
|
||||
onNodeRetry,
|
||||
onAgentLog,
|
||||
onError,
|
||||
onWorkflowPaused,
|
||||
onHumanInputRequired,
|
||||
onHumanInputFormFilled,
|
||||
onHumanInputFormTimeout,
|
||||
} = callbacks
|
||||
|
||||
const finalCallbacks: IOtherOptions = {
|
||||
...baseSseOptions,
|
||||
getAbortController: (controller: AbortController) => {
|
||||
setAbortController(controller)
|
||||
},
|
||||
onWorkflowFinished: (params) => {
|
||||
handleWorkflowFinished(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
if (onWorkflowFinished)
|
||||
onWorkflowFinished(params)
|
||||
if (isInWorkflowDebug) {
|
||||
fetchInspectVars({})
|
||||
invalidAllLastRun()
|
||||
}
|
||||
},
|
||||
onError: (params, code) => {
|
||||
handleWorkflowFailed()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
if (onError)
|
||||
onError(params, code)
|
||||
},
|
||||
onNodeStarted: (params) => {
|
||||
handleWorkflowNodeStarted(params, { clientWidth, clientHeight })
|
||||
|
||||
if (onNodeStarted)
|
||||
onNodeStarted(params)
|
||||
},
|
||||
onNodeFinished: (params) => {
|
||||
handleWorkflowNodeFinished(params)
|
||||
|
||||
if (onNodeFinished)
|
||||
onNodeFinished(params)
|
||||
},
|
||||
onIterationStart: (params) => {
|
||||
handleWorkflowNodeIterationStarted(params, { clientWidth, clientHeight })
|
||||
|
||||
if (onIterationStart)
|
||||
onIterationStart(params)
|
||||
},
|
||||
onIterationNext: (params) => {
|
||||
handleWorkflowNodeIterationNext(params)
|
||||
|
||||
if (onIterationNext)
|
||||
onIterationNext(params)
|
||||
},
|
||||
onIterationFinish: (params) => {
|
||||
handleWorkflowNodeIterationFinished(params)
|
||||
|
||||
if (onIterationFinish)
|
||||
onIterationFinish(params)
|
||||
},
|
||||
onLoopStart: (params) => {
|
||||
handleWorkflowNodeLoopStarted(params, { clientWidth, clientHeight })
|
||||
|
||||
if (onLoopStart)
|
||||
onLoopStart(params)
|
||||
},
|
||||
onLoopNext: (params) => {
|
||||
handleWorkflowNodeLoopNext(params)
|
||||
|
||||
if (onLoopNext)
|
||||
onLoopNext(params)
|
||||
},
|
||||
onLoopFinish: (params) => {
|
||||
handleWorkflowNodeLoopFinished(params)
|
||||
|
||||
if (onLoopFinish)
|
||||
onLoopFinish(params)
|
||||
},
|
||||
onNodeRetry: (params) => {
|
||||
handleWorkflowNodeRetry(params)
|
||||
|
||||
if (onNodeRetry)
|
||||
onNodeRetry(params)
|
||||
},
|
||||
onAgentLog: (params) => {
|
||||
handleWorkflowAgentLog(params)
|
||||
|
||||
if (onAgentLog)
|
||||
onAgentLog(params)
|
||||
},
|
||||
onTextChunk: (params) => {
|
||||
handleWorkflowTextChunk(params)
|
||||
},
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
onTTSChunk: (messageId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
player?.playAudioWithAudio(audio, true)
|
||||
AudioPlayerManager.getInstance().resetMsgId(messageId)
|
||||
},
|
||||
onTTSEnd: (_messageId: string, audio: string) => {
|
||||
player?.playAudioWithAudio(audio, false)
|
||||
},
|
||||
onWorkflowPaused: (params) => {
|
||||
handleWorkflowPaused()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
if (onWorkflowPaused)
|
||||
onWorkflowPaused(params)
|
||||
const url = `/workflow/${params.workflow_run_id}/events`
|
||||
sseGet(url, {}, finalCallbacks)
|
||||
},
|
||||
onHumanInputRequired: (params) => {
|
||||
handleWorkflowNodeHumanInputRequired(params)
|
||||
if (onHumanInputRequired)
|
||||
onHumanInputRequired(params)
|
||||
},
|
||||
onHumanInputFormFilled: (params) => {
|
||||
handleWorkflowNodeHumanInputFormFilled(params)
|
||||
if (onHumanInputFormFilled)
|
||||
onHumanInputFormFilled(params)
|
||||
},
|
||||
onHumanInputFormTimeout: (params) => {
|
||||
handleWorkflowNodeHumanInputFormTimeout(params)
|
||||
if (onHumanInputFormTimeout)
|
||||
onHumanInputFormTimeout(params)
|
||||
},
|
||||
...restCallback,
|
||||
}
|
||||
|
||||
return finalCallbacks
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
import type { Features as FeaturesData } from '@/app/components/base/features/types'
|
||||
import type { TriggerNodeType } from '@/app/components/workflow/types'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
import type { VersionHistory } from '@/types/workflow'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { toast } from '@/app/components/base/ui/toast'
|
||||
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { handleStream, post } from '@/service/base'
|
||||
import { ContentType } from '@/service/fetch'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
|
||||
export type HandleRunMode = TriggerType
|
||||
export type HandleRunOptions = {
|
||||
mode?: HandleRunMode
|
||||
scheduleNodeId?: string
|
||||
webhookNodeId?: string
|
||||
pluginNodeId?: string
|
||||
allNodeIds?: string[]
|
||||
}
|
||||
|
||||
export type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
|
||||
|
||||
type AppDetailLike = {
|
||||
id?: string
|
||||
mode?: AppModeEnum
|
||||
}
|
||||
|
||||
type TTSParamsLike = {
|
||||
token?: string
|
||||
appId?: string
|
||||
}
|
||||
|
||||
type ListeningStateActions = {
|
||||
setWorkflowRunningData: (data: ReturnType<typeof createRunningWorkflowState> | ReturnType<typeof createFailedWorkflowState> | ReturnType<typeof createStoppedWorkflowState>) => void
|
||||
setIsListening: (value: boolean) => void
|
||||
setShowVariableInspectPanel: (value: boolean) => void
|
||||
setListeningTriggerType: (value: TriggerNodeType | null) => void
|
||||
setListeningTriggerNodeIds: (value: string[]) => void
|
||||
setListeningTriggerIsAll: (value: boolean) => void
|
||||
setListeningTriggerNodeId: (value: string | null) => void
|
||||
}
|
||||
|
||||
type TriggerDebugRunnerOptions = {
|
||||
debugType: DebuggableTriggerType
|
||||
url: string
|
||||
requestBody: unknown
|
||||
baseSseOptions: IOtherOptions
|
||||
controllerTarget: Record<string, unknown>
|
||||
setAbortController: (controller: AbortController | null) => void
|
||||
clearAbortController: () => void
|
||||
clearListeningState: () => void
|
||||
setWorkflowRunningData: ListeningStateActions['setWorkflowRunningData']
|
||||
}
|
||||
|
||||
export const controllerKeyMap: Record<DebuggableTriggerType, string> = {
|
||||
[TriggerType.Webhook]: '__webhookDebugAbortController',
|
||||
[TriggerType.Plugin]: '__pluginDebugAbortController',
|
||||
[TriggerType.All]: '__allTriggersDebugAbortController',
|
||||
[TriggerType.Schedule]: '__scheduleDebugAbortController',
|
||||
}
|
||||
|
||||
export const debugLabelMap: Record<DebuggableTriggerType, string> = {
|
||||
[TriggerType.Webhook]: 'Webhook',
|
||||
[TriggerType.Plugin]: 'Plugin',
|
||||
[TriggerType.All]: 'All',
|
||||
[TriggerType.Schedule]: 'Schedule',
|
||||
}
|
||||
|
||||
export const createRunningWorkflowState = () => {
|
||||
return {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
}
|
||||
}
|
||||
|
||||
export const createStoppedWorkflowState = () => {
|
||||
return {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Stopped,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
}
|
||||
}
|
||||
|
||||
export const createFailedWorkflowState = (error: string) => {
|
||||
return {
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
error,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
}
|
||||
}
|
||||
|
||||
export const buildRunHistoryUrl = (appDetail?: AppDetailLike) => {
|
||||
return appDetail?.mode === AppModeEnum.ADVANCED_CHAT
|
||||
? `/apps/${appDetail.id}/advanced-chat/workflow-runs`
|
||||
: `/apps/${appDetail?.id}/workflow-runs`
|
||||
}
|
||||
|
||||
export const resolveWorkflowRunUrl = (
|
||||
appDetail: AppDetailLike | undefined,
|
||||
runMode: HandleRunMode,
|
||||
isInWorkflowDebug: boolean,
|
||||
) => {
|
||||
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
|
||||
if (!appDetail?.id) {
|
||||
console.error('handleRun: missing app id for trigger plugin run')
|
||||
return ''
|
||||
}
|
||||
|
||||
return `/apps/${appDetail.id}/workflows/draft/trigger/run`
|
||||
}
|
||||
|
||||
if (runMode === TriggerType.All) {
|
||||
if (!appDetail?.id) {
|
||||
console.error('handleRun: missing app id for trigger run all')
|
||||
return ''
|
||||
}
|
||||
|
||||
return `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
|
||||
}
|
||||
|
||||
if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT)
|
||||
return `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
|
||||
|
||||
if (isInWorkflowDebug && appDetail?.id)
|
||||
return `/apps/${appDetail.id}/workflows/draft/run`
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export const buildWorkflowRunRequestBody = (
|
||||
runMode: HandleRunMode,
|
||||
resolvedParams: Record<string, unknown>,
|
||||
options?: HandleRunOptions,
|
||||
) => {
|
||||
if (runMode === TriggerType.Schedule)
|
||||
return { node_id: options?.scheduleNodeId }
|
||||
|
||||
if (runMode === TriggerType.Webhook)
|
||||
return { node_id: options?.webhookNodeId }
|
||||
|
||||
if (runMode === TriggerType.Plugin)
|
||||
return { node_id: options?.pluginNodeId }
|
||||
|
||||
if (runMode === TriggerType.All)
|
||||
return { node_ids: options?.allNodeIds }
|
||||
|
||||
return resolvedParams
|
||||
}
|
||||
|
||||
export const validateWorkflowRunRequest = (
|
||||
runMode: HandleRunMode,
|
||||
options?: HandleRunOptions,
|
||||
) => {
|
||||
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId)
|
||||
return 'handleRun: schedule trigger run requires node id'
|
||||
|
||||
if (runMode === TriggerType.Webhook && !options?.webhookNodeId)
|
||||
return 'handleRun: webhook trigger run requires node id'
|
||||
|
||||
if (runMode === TriggerType.Plugin && !options?.pluginNodeId)
|
||||
return 'handleRun: plugin trigger run requires node id'
|
||||
|
||||
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0)
|
||||
return 'handleRun: all trigger run requires node ids'
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
export const isDebuggableTriggerType = (
|
||||
runMode: HandleRunMode,
|
||||
): runMode is DebuggableTriggerType => {
|
||||
return (
|
||||
runMode === TriggerType.Schedule
|
||||
|| runMode === TriggerType.Webhook
|
||||
|| runMode === TriggerType.Plugin
|
||||
|| runMode === TriggerType.All
|
||||
)
|
||||
}
|
||||
|
||||
export const buildListeningTriggerNodeIds = (
|
||||
runMode: DebuggableTriggerType,
|
||||
options?: HandleRunOptions,
|
||||
) => {
|
||||
if (runMode === TriggerType.All)
|
||||
return options?.allNodeIds ?? []
|
||||
|
||||
if (runMode === TriggerType.Webhook && options?.webhookNodeId)
|
||||
return [options.webhookNodeId]
|
||||
|
||||
if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
|
||||
return [options.scheduleNodeId]
|
||||
|
||||
if (runMode === TriggerType.Plugin && options?.pluginNodeId)
|
||||
return [options.pluginNodeId]
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
export const applyRunningStateForMode = (
|
||||
actions: ListeningStateActions,
|
||||
runMode: HandleRunMode,
|
||||
options?: HandleRunOptions,
|
||||
) => {
|
||||
if (isDebuggableTriggerType(runMode)) {
|
||||
actions.setIsListening(true)
|
||||
actions.setShowVariableInspectPanel(true)
|
||||
actions.setListeningTriggerIsAll(runMode === TriggerType.All)
|
||||
actions.setListeningTriggerNodeIds(buildListeningTriggerNodeIds(runMode, options))
|
||||
actions.setWorkflowRunningData(createRunningWorkflowState())
|
||||
return
|
||||
}
|
||||
|
||||
actions.setIsListening(false)
|
||||
actions.setListeningTriggerType(null)
|
||||
actions.setListeningTriggerNodeId(null)
|
||||
actions.setListeningTriggerNodeIds([])
|
||||
actions.setListeningTriggerIsAll(false)
|
||||
actions.setWorkflowRunningData(createRunningWorkflowState())
|
||||
}
|
||||
|
||||
export const clearListeningState = (actions: Pick<ListeningStateActions, 'setIsListening' | 'setListeningTriggerType' | 'setListeningTriggerNodeId' | 'setListeningTriggerNodeIds' | 'setListeningTriggerIsAll'>) => {
|
||||
actions.setIsListening(false)
|
||||
actions.setListeningTriggerType(null)
|
||||
actions.setListeningTriggerNodeId(null)
|
||||
actions.setListeningTriggerNodeIds([])
|
||||
actions.setListeningTriggerIsAll(false)
|
||||
}
|
||||
|
||||
export const applyStoppedState = (actions: Pick<ListeningStateActions, 'setWorkflowRunningData' | 'setIsListening' | 'setShowVariableInspectPanel' | 'setListeningTriggerType' | 'setListeningTriggerNodeId'>) => {
|
||||
actions.setWorkflowRunningData(createStoppedWorkflowState())
|
||||
actions.setIsListening(false)
|
||||
actions.setListeningTriggerType(null)
|
||||
actions.setListeningTriggerNodeId(null)
|
||||
actions.setShowVariableInspectPanel(true)
|
||||
}
|
||||
|
||||
export const clearWindowDebugControllers = (controllerTarget: Record<string, unknown>) => {
|
||||
delete controllerTarget.__webhookDebugAbortController
|
||||
delete controllerTarget.__pluginDebugAbortController
|
||||
delete controllerTarget.__scheduleDebugAbortController
|
||||
delete controllerTarget.__allTriggersDebugAbortController
|
||||
}
|
||||
|
||||
export const buildTTSConfig = (resolvedParams: TTSParamsLike, pathname: string) => {
|
||||
let ttsUrl = ''
|
||||
let ttsIsPublic = false
|
||||
|
||||
if (resolvedParams.token) {
|
||||
ttsUrl = '/text-to-audio'
|
||||
ttsIsPublic = true
|
||||
}
|
||||
else if (resolvedParams.appId) {
|
||||
if (pathname.search('explore/installed') > -1)
|
||||
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
|
||||
else
|
||||
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
|
||||
}
|
||||
|
||||
return {
|
||||
ttsUrl,
|
||||
ttsIsPublic,
|
||||
}
|
||||
}
|
||||
|
||||
export const mapPublishedWorkflowFeatures = (publishedWorkflow: VersionHistory): FeaturesData => {
|
||||
return {
|
||||
opening: {
|
||||
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
|
||||
opening_statement: publishedWorkflow.features.opening_statement,
|
||||
suggested_questions: publishedWorkflow.features.suggested_questions,
|
||||
},
|
||||
suggested: publishedWorkflow.features.suggested_questions_after_answer,
|
||||
text2speech: publishedWorkflow.features.text_to_speech,
|
||||
speech2text: publishedWorkflow.features.speech_to_text,
|
||||
citation: publishedWorkflow.features.retriever_resource,
|
||||
moderation: publishedWorkflow.features.sensitive_word_avoidance,
|
||||
file: publishedWorkflow.features.file_upload,
|
||||
}
|
||||
}
|
||||
|
||||
export const normalizePublishedWorkflowNodes = (publishedWorkflow: VersionHistory) => {
|
||||
return publishedWorkflow.graph.nodes.map(node => ({
|
||||
...node,
|
||||
selected: false,
|
||||
data: {
|
||||
...node.data,
|
||||
selected: false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
export const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
|
||||
const timer = window.setTimeout(resolve, delay)
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}, { once: true })
|
||||
})
|
||||
|
||||
export const runTriggerDebug = async ({
|
||||
debugType,
|
||||
url,
|
||||
requestBody,
|
||||
baseSseOptions,
|
||||
controllerTarget,
|
||||
setAbortController,
|
||||
clearAbortController,
|
||||
clearListeningState,
|
||||
setWorkflowRunningData,
|
||||
}: TriggerDebugRunnerOptions) => {
|
||||
const controller = new AbortController()
|
||||
setAbortController(controller)
|
||||
|
||||
const controllerKey = controllerKeyMap[debugType]
|
||||
controllerTarget[controllerKey] = controller
|
||||
|
||||
const debugLabel = debugLabelMap[debugType]
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await post<Response>(url, {
|
||||
body: requestBody,
|
||||
signal: controller.signal,
|
||||
}, {
|
||||
needAllResponseContent: true,
|
||||
})
|
||||
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
if (!response) {
|
||||
const message = `${debugLabel} debug request failed`
|
||||
toast.error(message)
|
||||
clearAbortController()
|
||||
return
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
if (contentType.includes(ContentType.json)) {
|
||||
let data: Record<string, unknown> | null = null
|
||||
try {
|
||||
data = await response.json() as Record<string, unknown>
|
||||
}
|
||||
catch (jsonError) {
|
||||
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
|
||||
toast.error(`${debugLabel} debug request failed`)
|
||||
clearAbortController()
|
||||
clearListeningState()
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
if (data?.status === 'waiting') {
|
||||
const delay = Number(data.retry_in) || 2000
|
||||
await waitWithAbort(controller.signal, delay)
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
await poll()
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = typeof data?.message === 'string' ? data.message : `${debugLabel} debug failed`
|
||||
toast.error(errorMessage)
|
||||
clearAbortController()
|
||||
setWorkflowRunningData(createFailedWorkflowState(errorMessage))
|
||||
clearListeningState()
|
||||
return
|
||||
}
|
||||
|
||||
clearListeningState()
|
||||
handleStream(
|
||||
response,
|
||||
baseSseOptions.onData ?? noop,
|
||||
baseSseOptions.onCompleted,
|
||||
baseSseOptions.onThought,
|
||||
baseSseOptions.onMessageEnd,
|
||||
baseSseOptions.onMessageReplace,
|
||||
baseSseOptions.onFile,
|
||||
baseSseOptions.onWorkflowStarted,
|
||||
baseSseOptions.onWorkflowFinished,
|
||||
baseSseOptions.onNodeStarted,
|
||||
baseSseOptions.onNodeFinished,
|
||||
baseSseOptions.onIterationStart,
|
||||
baseSseOptions.onIterationNext,
|
||||
baseSseOptions.onIterationFinish,
|
||||
baseSseOptions.onLoopStart,
|
||||
baseSseOptions.onLoopNext,
|
||||
baseSseOptions.onLoopFinish,
|
||||
baseSseOptions.onNodeRetry,
|
||||
baseSseOptions.onParallelBranchStarted,
|
||||
baseSseOptions.onParallelBranchFinished,
|
||||
baseSseOptions.onTextChunk,
|
||||
baseSseOptions.onTTSChunk,
|
||||
baseSseOptions.onTTSEnd,
|
||||
baseSseOptions.onTextReplace,
|
||||
baseSseOptions.onAgentLog,
|
||||
baseSseOptions.onHumanInputRequired,
|
||||
baseSseOptions.onHumanInputFormFilled,
|
||||
baseSseOptions.onHumanInputFormTimeout,
|
||||
baseSseOptions.onWorkflowPaused,
|
||||
baseSseOptions.onDataSourceNodeProcessing,
|
||||
baseSseOptions.onDataSourceNodeCompleted,
|
||||
baseSseOptions.onDataSourceNodeError,
|
||||
)
|
||||
}
|
||||
catch (error) {
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
if (error instanceof Response) {
|
||||
const data = await error.clone().json() as Record<string, unknown>
|
||||
const errorMessage = typeof data?.error === 'string' ? data.error : ''
|
||||
toast.error(errorMessage)
|
||||
clearAbortController()
|
||||
setWorkflowRunningData(createFailedWorkflowState(errorMessage))
|
||||
}
|
||||
|
||||
clearListeningState()
|
||||
}
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { HandleRunOptions } from './use-workflow-run-utils'
|
||||
import type AudioPlayer from '@/app/components/base/audio-btn/audio'
|
||||
import type { Node } from '@/app/components/workflow/types'
|
||||
import type { IOtherOptions } from '@/service/base'
|
||||
@@ -14,46 +15,38 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { trackEvent } from '@/app/components/base/amplitude'
|
||||
import { AudioPlayerManager } from '@/app/components/base/audio-btn/audio.player.manager'
|
||||
import { useFeaturesStore } from '@/app/components/base/features/hooks'
|
||||
import Toast from '@/app/components/base/toast'
|
||||
import { TriggerType } from '@/app/components/workflow/header/test-run-menu'
|
||||
import { useWorkflowUpdate } from '@/app/components/workflow/hooks/use-workflow-interactions'
|
||||
import { useWorkflowRunEvent } from '@/app/components/workflow/hooks/use-workflow-run-event/use-workflow-run-event'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { WorkflowRunningStatus } from '@/app/components/workflow/types'
|
||||
import { usePathname } from '@/next/navigation'
|
||||
import { handleStream, post, sseGet, ssePost } from '@/service/base'
|
||||
import { ContentType } from '@/service/fetch'
|
||||
import { ssePost } from '@/service/base'
|
||||
import { useInvalidAllLastRun, useInvalidateWorkflowRunHistory } from '@/service/use-workflow'
|
||||
import { stopWorkflowRun } from '@/service/workflow'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { useSetWorkflowVarsWithValue } from '../../workflow/hooks/use-fetch-workflow-inspect-vars'
|
||||
import { useConfigsMap } from './use-configs-map'
|
||||
import { useNodesSyncDraft } from './use-nodes-sync-draft'
|
||||
import {
|
||||
createBaseWorkflowRunCallbacks,
|
||||
createFinalWorkflowRunCallbacks,
|
||||
} from './use-workflow-run-callbacks'
|
||||
import {
|
||||
applyRunningStateForMode,
|
||||
applyStoppedState,
|
||||
buildRunHistoryUrl,
|
||||
buildTTSConfig,
|
||||
buildWorkflowRunRequestBody,
|
||||
clearListeningState,
|
||||
clearWindowDebugControllers,
|
||||
|
||||
type HandleRunMode = TriggerType
|
||||
type HandleRunOptions = {
|
||||
mode?: HandleRunMode
|
||||
scheduleNodeId?: string
|
||||
webhookNodeId?: string
|
||||
pluginNodeId?: string
|
||||
allNodeIds?: string[]
|
||||
}
|
||||
|
||||
type DebuggableTriggerType = Exclude<TriggerType, TriggerType.UserInput>
|
||||
|
||||
const controllerKeyMap: Record<DebuggableTriggerType, string> = {
|
||||
[TriggerType.Webhook]: '__webhookDebugAbortController',
|
||||
[TriggerType.Plugin]: '__pluginDebugAbortController',
|
||||
[TriggerType.All]: '__allTriggersDebugAbortController',
|
||||
[TriggerType.Schedule]: '__scheduleDebugAbortController',
|
||||
}
|
||||
|
||||
const debugLabelMap: Record<DebuggableTriggerType, string> = {
|
||||
[TriggerType.Webhook]: 'Webhook',
|
||||
[TriggerType.Plugin]: 'Plugin',
|
||||
[TriggerType.All]: 'All',
|
||||
[TriggerType.Schedule]: 'Schedule',
|
||||
}
|
||||
isDebuggableTriggerType,
|
||||
mapPublishedWorkflowFeatures,
|
||||
normalizePublishedWorkflowNodes,
|
||||
resolveWorkflowRunUrl,
|
||||
runTriggerDebug,
|
||||
validateWorkflowRunRequest,
|
||||
} from './use-workflow-run-utils'
|
||||
|
||||
export const useWorkflowRun = () => {
|
||||
const store = useStoreApi()
|
||||
@@ -152,7 +145,7 @@ export const useWorkflowRun = () => {
|
||||
callback?: IOtherOptions,
|
||||
options?: HandleRunOptions,
|
||||
) => {
|
||||
const runMode: HandleRunMode = options?.mode ?? TriggerType.UserInput
|
||||
const runMode = options?.mode ?? TriggerType.UserInput
|
||||
const resolvedParams = params ?? {}
|
||||
const {
|
||||
getNodes,
|
||||
@@ -190,9 +183,7 @@ export const useWorkflowRun = () => {
|
||||
} = callback || {}
|
||||
workflowStore.setState({ historyWorkflowData: undefined })
|
||||
const appDetail = useAppStore.getState().appDetail
|
||||
const runHistoryUrl = appDetail?.mode === AppModeEnum.ADVANCED_CHAT
|
||||
? `/apps/${appDetail.id}/advanced-chat/workflow-runs`
|
||||
: `/apps/${appDetail?.id}/workflow-runs`
|
||||
const runHistoryUrl = buildRunHistoryUrl(appDetail)
|
||||
const workflowContainer = document.getElementById('workflow-container')
|
||||
|
||||
const {
|
||||
@@ -202,65 +193,15 @@ export const useWorkflowRun = () => {
|
||||
|
||||
const isInWorkflowDebug = appDetail?.mode === AppModeEnum.WORKFLOW
|
||||
|
||||
let url = ''
|
||||
if (runMode === TriggerType.Plugin || runMode === TriggerType.Webhook || runMode === TriggerType.Schedule) {
|
||||
if (!appDetail?.id) {
|
||||
console.error('handleRun: missing app id for trigger plugin run')
|
||||
return
|
||||
}
|
||||
url = `/apps/${appDetail.id}/workflows/draft/trigger/run`
|
||||
}
|
||||
else if (runMode === TriggerType.All) {
|
||||
if (!appDetail?.id) {
|
||||
console.error('handleRun: missing app id for trigger run all')
|
||||
return
|
||||
}
|
||||
url = `/apps/${appDetail.id}/workflows/draft/trigger/run-all`
|
||||
}
|
||||
else if (appDetail?.mode === AppModeEnum.ADVANCED_CHAT) {
|
||||
url = `/apps/${appDetail.id}/advanced-chat/workflows/draft/run`
|
||||
}
|
||||
else if (isInWorkflowDebug && appDetail?.id) {
|
||||
url = `/apps/${appDetail.id}/workflows/draft/run`
|
||||
}
|
||||
|
||||
let requestBody = {}
|
||||
|
||||
if (runMode === TriggerType.Schedule)
|
||||
requestBody = { node_id: options?.scheduleNodeId }
|
||||
|
||||
else if (runMode === TriggerType.Webhook)
|
||||
requestBody = { node_id: options?.webhookNodeId }
|
||||
|
||||
else if (runMode === TriggerType.Plugin)
|
||||
requestBody = { node_id: options?.pluginNodeId }
|
||||
|
||||
else if (runMode === TriggerType.All)
|
||||
requestBody = { node_ids: options?.allNodeIds }
|
||||
|
||||
else
|
||||
requestBody = resolvedParams
|
||||
const url = resolveWorkflowRunUrl(appDetail, runMode, isInWorkflowDebug)
|
||||
const requestBody = buildWorkflowRunRequestBody(runMode, resolvedParams, options)
|
||||
|
||||
if (!url)
|
||||
return
|
||||
|
||||
if (runMode === TriggerType.Schedule && !options?.scheduleNodeId) {
|
||||
console.error('handleRun: schedule trigger run requires node id')
|
||||
return
|
||||
}
|
||||
|
||||
if (runMode === TriggerType.Webhook && !options?.webhookNodeId) {
|
||||
console.error('handleRun: webhook trigger run requires node id')
|
||||
return
|
||||
}
|
||||
|
||||
if (runMode === TriggerType.Plugin && !options?.pluginNodeId) {
|
||||
console.error('handleRun: plugin trigger run requires node id')
|
||||
return
|
||||
}
|
||||
|
||||
if (runMode === TriggerType.All && !options?.allNodeIds && options?.allNodeIds?.length === 0) {
|
||||
console.error('handleRun: all trigger run requires node ids')
|
||||
const validationMessage = validateWorkflowRunRequest(runMode, options)
|
||||
if (validationMessage) {
|
||||
console.error(validationMessage)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -277,66 +218,17 @@ export const useWorkflowRun = () => {
|
||||
setListeningTriggerNodeId,
|
||||
} = workflowStore.getState()
|
||||
|
||||
if (
|
||||
runMode === TriggerType.Webhook
|
||||
|| runMode === TriggerType.Plugin
|
||||
|| runMode === TriggerType.All
|
||||
|| runMode === TriggerType.Schedule
|
||||
) {
|
||||
setIsListening(true)
|
||||
setShowVariableInspectPanel(true)
|
||||
setListeningTriggerIsAll(runMode === TriggerType.All)
|
||||
if (runMode === TriggerType.All)
|
||||
setListeningTriggerNodeIds(options?.allNodeIds ?? [])
|
||||
else if (runMode === TriggerType.Webhook && options?.webhookNodeId)
|
||||
setListeningTriggerNodeIds([options.webhookNodeId])
|
||||
else if (runMode === TriggerType.Schedule && options?.scheduleNodeId)
|
||||
setListeningTriggerNodeIds([options.scheduleNodeId])
|
||||
else if (runMode === TriggerType.Plugin && options?.pluginNodeId)
|
||||
setListeningTriggerNodeIds([options.pluginNodeId])
|
||||
else
|
||||
setListeningTriggerNodeIds([])
|
||||
setWorkflowRunningData({
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
})
|
||||
}
|
||||
else {
|
||||
setIsListening(false)
|
||||
setListeningTriggerType(null)
|
||||
setListeningTriggerNodeId(null)
|
||||
setListeningTriggerNodeIds([])
|
||||
setListeningTriggerIsAll(false)
|
||||
setWorkflowRunningData({
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Running,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
})
|
||||
}
|
||||
applyRunningStateForMode({
|
||||
setWorkflowRunningData,
|
||||
setIsListening,
|
||||
setShowVariableInspectPanel,
|
||||
setListeningTriggerType,
|
||||
setListeningTriggerNodeIds,
|
||||
setListeningTriggerIsAll,
|
||||
setListeningTriggerNodeId,
|
||||
}, runMode, options)
|
||||
|
||||
let ttsUrl = ''
|
||||
let ttsIsPublic = false
|
||||
if (resolvedParams.token) {
|
||||
ttsUrl = '/text-to-audio'
|
||||
ttsIsPublic = true
|
||||
}
|
||||
else if (resolvedParams.appId) {
|
||||
if (pathname.search('explore/installed') > -1)
|
||||
ttsUrl = `/installed-apps/${resolvedParams.appId}/text-to-audio`
|
||||
else
|
||||
ttsUrl = `/apps/${resolvedParams.appId}/text-to-audio`
|
||||
}
|
||||
const { ttsUrl, ttsIsPublic } = buildTTSConfig(resolvedParams, pathname)
|
||||
// Lazy initialization: Only create AudioPlayer when TTS is actually needed
|
||||
// This prevents opening audio channel unnecessarily
|
||||
let player: AudioPlayer | null = null
|
||||
@@ -349,497 +241,121 @@ export const useWorkflowRun = () => {
|
||||
|
||||
const clearAbortController = () => {
|
||||
abortControllerRef.current = null
|
||||
delete (window as any).__webhookDebugAbortController
|
||||
delete (window as any).__pluginDebugAbortController
|
||||
delete (window as any).__scheduleDebugAbortController
|
||||
delete (window as any).__allTriggersDebugAbortController
|
||||
clearWindowDebugControllers(window as unknown as Record<string, unknown>)
|
||||
}
|
||||
|
||||
const clearListeningState = () => {
|
||||
const clearListeningStateInStore = () => {
|
||||
const state = workflowStore.getState()
|
||||
state.setIsListening(false)
|
||||
state.setListeningTriggerType(null)
|
||||
state.setListeningTriggerNodeId(null)
|
||||
state.setListeningTriggerNodeIds([])
|
||||
state.setListeningTriggerIsAll(false)
|
||||
clearListeningState({
|
||||
setIsListening: state.setIsListening,
|
||||
setListeningTriggerType: state.setListeningTriggerType,
|
||||
setListeningTriggerNodeId: state.setListeningTriggerNodeId,
|
||||
setListeningTriggerNodeIds: state.setListeningTriggerNodeIds,
|
||||
setListeningTriggerIsAll: state.setListeningTriggerIsAll,
|
||||
})
|
||||
}
|
||||
|
||||
const wrappedOnError = (params: any) => {
|
||||
clearAbortController()
|
||||
handleWorkflowFailed()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
clearListeningState()
|
||||
|
||||
if (onError)
|
||||
onError(params)
|
||||
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: params.error, node_type: params.node_type })
|
||||
const workflowRunEventHandlers = {
|
||||
handleWorkflowStarted,
|
||||
handleWorkflowFinished,
|
||||
handleWorkflowFailed,
|
||||
handleWorkflowNodeStarted,
|
||||
handleWorkflowNodeFinished,
|
||||
handleWorkflowNodeHumanInputRequired,
|
||||
handleWorkflowNodeHumanInputFormFilled,
|
||||
handleWorkflowNodeHumanInputFormTimeout,
|
||||
handleWorkflowNodeIterationStarted,
|
||||
handleWorkflowNodeIterationNext,
|
||||
handleWorkflowNodeIterationFinished,
|
||||
handleWorkflowNodeLoopStarted,
|
||||
handleWorkflowNodeLoopNext,
|
||||
handleWorkflowNodeLoopFinished,
|
||||
handleWorkflowNodeRetry,
|
||||
handleWorkflowAgentLog,
|
||||
handleWorkflowTextChunk,
|
||||
handleWorkflowTextReplace,
|
||||
handleWorkflowPaused,
|
||||
}
|
||||
const userCallbacks = {
|
||||
onWorkflowStarted,
|
||||
onWorkflowFinished,
|
||||
onNodeStarted,
|
||||
onNodeFinished,
|
||||
onIterationStart,
|
||||
onIterationNext,
|
||||
onIterationFinish,
|
||||
onLoopStart,
|
||||
onLoopNext,
|
||||
onLoopFinish,
|
||||
onNodeRetry,
|
||||
onAgentLog,
|
||||
onError,
|
||||
onWorkflowPaused,
|
||||
onHumanInputRequired,
|
||||
onHumanInputFormFilled,
|
||||
onHumanInputFormTimeout,
|
||||
onCompleted,
|
||||
}
|
||||
|
||||
const wrappedOnCompleted: IOtherOptions['onCompleted'] = async (hasError?: boolean, errorMessage?: string) => {
|
||||
clearAbortController()
|
||||
clearListeningState()
|
||||
if (onCompleted)
|
||||
onCompleted(hasError, errorMessage)
|
||||
const trackWorkflowRunFailed = (eventParams: unknown) => {
|
||||
const payload = eventParams as { error?: string, node_type?: string }
|
||||
trackEvent('workflow_run_failed', { workflow_id: flowId, reason: payload?.error, node_type: payload?.node_type })
|
||||
}
|
||||
|
||||
const baseSseOptions: IOtherOptions = {
|
||||
...restCallback,
|
||||
onWorkflowStarted: (params) => {
|
||||
handleWorkflowStarted(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
if (onWorkflowStarted)
|
||||
onWorkflowStarted(params)
|
||||
},
|
||||
onWorkflowFinished: (params) => {
|
||||
clearListeningState()
|
||||
handleWorkflowFinished(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
if (onWorkflowFinished)
|
||||
onWorkflowFinished(params)
|
||||
if (isInWorkflowDebug) {
|
||||
fetchInspectVars({})
|
||||
invalidAllLastRun()
|
||||
}
|
||||
},
|
||||
onNodeStarted: (params) => {
|
||||
handleWorkflowNodeStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onNodeStarted)
|
||||
onNodeStarted(params)
|
||||
},
|
||||
onNodeFinished: (params) => {
|
||||
handleWorkflowNodeFinished(params)
|
||||
|
||||
if (onNodeFinished)
|
||||
onNodeFinished(params)
|
||||
},
|
||||
onIterationStart: (params) => {
|
||||
handleWorkflowNodeIterationStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onIterationStart)
|
||||
onIterationStart(params)
|
||||
},
|
||||
onIterationNext: (params) => {
|
||||
handleWorkflowNodeIterationNext(params)
|
||||
|
||||
if (onIterationNext)
|
||||
onIterationNext(params)
|
||||
},
|
||||
onIterationFinish: (params) => {
|
||||
handleWorkflowNodeIterationFinished(params)
|
||||
|
||||
if (onIterationFinish)
|
||||
onIterationFinish(params)
|
||||
},
|
||||
onLoopStart: (params) => {
|
||||
handleWorkflowNodeLoopStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onLoopStart)
|
||||
onLoopStart(params)
|
||||
},
|
||||
onLoopNext: (params) => {
|
||||
handleWorkflowNodeLoopNext(params)
|
||||
|
||||
if (onLoopNext)
|
||||
onLoopNext(params)
|
||||
},
|
||||
onLoopFinish: (params) => {
|
||||
handleWorkflowNodeLoopFinished(params)
|
||||
|
||||
if (onLoopFinish)
|
||||
onLoopFinish(params)
|
||||
},
|
||||
onNodeRetry: (params) => {
|
||||
handleWorkflowNodeRetry(params)
|
||||
|
||||
if (onNodeRetry)
|
||||
onNodeRetry(params)
|
||||
},
|
||||
onAgentLog: (params) => {
|
||||
handleWorkflowAgentLog(params)
|
||||
|
||||
if (onAgentLog)
|
||||
onAgentLog(params)
|
||||
},
|
||||
onTextChunk: (params) => {
|
||||
handleWorkflowTextChunk(params)
|
||||
},
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
onTTSChunk: (messageId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
const audioPlayer = getOrCreatePlayer()
|
||||
if (audioPlayer) {
|
||||
audioPlayer.playAudioWithAudio(audio, true)
|
||||
AudioPlayerManager.getInstance().resetMsgId(messageId)
|
||||
}
|
||||
},
|
||||
onTTSEnd: (messageId: string, audio: string) => {
|
||||
const audioPlayer = getOrCreatePlayer()
|
||||
if (audioPlayer)
|
||||
audioPlayer.playAudioWithAudio(audio, false)
|
||||
},
|
||||
onWorkflowPaused: (params) => {
|
||||
handleWorkflowPaused()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
if (onWorkflowPaused)
|
||||
onWorkflowPaused(params)
|
||||
const url = `/workflow/${params.workflow_run_id}/events`
|
||||
sseGet(
|
||||
url,
|
||||
{},
|
||||
baseSseOptions,
|
||||
)
|
||||
},
|
||||
onHumanInputRequired: (params) => {
|
||||
handleWorkflowNodeHumanInputRequired(params)
|
||||
if (onHumanInputRequired)
|
||||
onHumanInputRequired(params)
|
||||
},
|
||||
onHumanInputFormFilled: (params) => {
|
||||
handleWorkflowNodeHumanInputFormFilled(params)
|
||||
if (onHumanInputFormFilled)
|
||||
onHumanInputFormFilled(params)
|
||||
},
|
||||
onHumanInputFormTimeout: (params) => {
|
||||
handleWorkflowNodeHumanInputFormTimeout(params)
|
||||
if (onHumanInputFormTimeout)
|
||||
onHumanInputFormTimeout(params)
|
||||
},
|
||||
onError: wrappedOnError,
|
||||
onCompleted: wrappedOnCompleted,
|
||||
}
|
||||
|
||||
const waitWithAbort = (signal: AbortSignal, delay: number) => new Promise<void>((resolve) => {
|
||||
const timer = window.setTimeout(resolve, delay)
|
||||
signal.addEventListener('abort', () => {
|
||||
clearTimeout(timer)
|
||||
resolve()
|
||||
}, { once: true })
|
||||
const baseSseOptions = createBaseWorkflowRunCallbacks({
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
runHistoryUrl,
|
||||
isInWorkflowDebug,
|
||||
fetchInspectVars,
|
||||
invalidAllLastRun,
|
||||
invalidateRunHistory,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateInStore,
|
||||
trackWorkflowRunFailed,
|
||||
handlers: workflowRunEventHandlers,
|
||||
callbacks: userCallbacks,
|
||||
restCallback,
|
||||
getOrCreatePlayer,
|
||||
})
|
||||
|
||||
const runTriggerDebug = async (debugType: DebuggableTriggerType) => {
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
const controllerKey = controllerKeyMap[debugType]
|
||||
|
||||
; (window as any)[controllerKey] = controller
|
||||
|
||||
const debugLabel = debugLabelMap[debugType]
|
||||
|
||||
const poll = async (): Promise<void> => {
|
||||
try {
|
||||
const response = await post<Response>(url, {
|
||||
body: requestBody,
|
||||
signal: controller.signal,
|
||||
}, {
|
||||
needAllResponseContent: true,
|
||||
})
|
||||
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
if (!response) {
|
||||
const message = `${debugLabel} debug request failed`
|
||||
Toast.notify({ type: 'error', message })
|
||||
clearAbortController()
|
||||
return
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
|
||||
if (contentType.includes(ContentType.json)) {
|
||||
let data: any = null
|
||||
try {
|
||||
data = await response.json()
|
||||
}
|
||||
catch (jsonError) {
|
||||
console.error(`handleRun: ${debugLabel.toLowerCase()} debug response parse error`, jsonError)
|
||||
Toast.notify({ type: 'error', message: `${debugLabel} debug request failed` })
|
||||
clearAbortController()
|
||||
clearListeningState()
|
||||
return
|
||||
}
|
||||
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
if (data?.status === 'waiting') {
|
||||
const delay = Number(data.retry_in) || 2000
|
||||
await waitWithAbort(controller.signal, delay)
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
await poll()
|
||||
return
|
||||
}
|
||||
|
||||
const errorMessage = data?.message || `${debugLabel} debug failed`
|
||||
Toast.notify({ type: 'error', message: errorMessage })
|
||||
clearAbortController()
|
||||
setWorkflowRunningData({
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
error: errorMessage,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
})
|
||||
clearListeningState()
|
||||
return
|
||||
}
|
||||
|
||||
clearListeningState()
|
||||
handleStream(
|
||||
response,
|
||||
baseSseOptions.onData ?? noop,
|
||||
baseSseOptions.onCompleted,
|
||||
baseSseOptions.onThought,
|
||||
baseSseOptions.onMessageEnd,
|
||||
baseSseOptions.onMessageReplace,
|
||||
baseSseOptions.onFile,
|
||||
baseSseOptions.onWorkflowStarted,
|
||||
baseSseOptions.onWorkflowFinished,
|
||||
baseSseOptions.onNodeStarted,
|
||||
baseSseOptions.onNodeFinished,
|
||||
baseSseOptions.onIterationStart,
|
||||
baseSseOptions.onIterationNext,
|
||||
baseSseOptions.onIterationFinish,
|
||||
baseSseOptions.onLoopStart,
|
||||
baseSseOptions.onLoopNext,
|
||||
baseSseOptions.onLoopFinish,
|
||||
baseSseOptions.onNodeRetry,
|
||||
baseSseOptions.onParallelBranchStarted,
|
||||
baseSseOptions.onParallelBranchFinished,
|
||||
baseSseOptions.onTextChunk,
|
||||
baseSseOptions.onTTSChunk,
|
||||
baseSseOptions.onTTSEnd,
|
||||
baseSseOptions.onTextReplace,
|
||||
baseSseOptions.onAgentLog,
|
||||
baseSseOptions.onHumanInputRequired,
|
||||
baseSseOptions.onHumanInputFormFilled,
|
||||
baseSseOptions.onHumanInputFormTimeout,
|
||||
baseSseOptions.onWorkflowPaused,
|
||||
baseSseOptions.onDataSourceNodeProcessing,
|
||||
baseSseOptions.onDataSourceNodeCompleted,
|
||||
baseSseOptions.onDataSourceNodeError,
|
||||
)
|
||||
}
|
||||
catch (error) {
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
if (error instanceof Response) {
|
||||
const data = await error.clone().json() as Record<string, any>
|
||||
const { error: respError } = data || {}
|
||||
Toast.notify({ type: 'error', message: respError })
|
||||
clearAbortController()
|
||||
setWorkflowRunningData({
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Failed,
|
||||
error: respError,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
})
|
||||
}
|
||||
clearListeningState()
|
||||
}
|
||||
}
|
||||
|
||||
await poll()
|
||||
}
|
||||
|
||||
if (runMode === TriggerType.Schedule) {
|
||||
await runTriggerDebug(TriggerType.Schedule)
|
||||
if (isDebuggableTriggerType(runMode)) {
|
||||
await runTriggerDebug({
|
||||
debugType: runMode,
|
||||
url,
|
||||
requestBody,
|
||||
baseSseOptions,
|
||||
controllerTarget: window as unknown as Record<string, unknown>,
|
||||
setAbortController: (controller) => {
|
||||
abortControllerRef.current = controller
|
||||
},
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateInStore,
|
||||
setWorkflowRunningData,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (runMode === TriggerType.Webhook) {
|
||||
await runTriggerDebug(TriggerType.Webhook)
|
||||
return
|
||||
}
|
||||
|
||||
if (runMode === TriggerType.Plugin) {
|
||||
await runTriggerDebug(TriggerType.Plugin)
|
||||
return
|
||||
}
|
||||
|
||||
if (runMode === TriggerType.All) {
|
||||
await runTriggerDebug(TriggerType.All)
|
||||
return
|
||||
}
|
||||
|
||||
const finalCallbacks: IOtherOptions = {
|
||||
...baseSseOptions,
|
||||
getAbortController: (controller: AbortController) => {
|
||||
const finalCallbacks = createFinalWorkflowRunCallbacks({
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
runHistoryUrl,
|
||||
isInWorkflowDebug,
|
||||
fetchInspectVars,
|
||||
invalidAllLastRun,
|
||||
invalidateRunHistory,
|
||||
clearAbortController,
|
||||
clearListeningState: clearListeningStateInStore,
|
||||
trackWorkflowRunFailed,
|
||||
handlers: workflowRunEventHandlers,
|
||||
callbacks: userCallbacks,
|
||||
restCallback,
|
||||
baseSseOptions,
|
||||
player,
|
||||
setAbortController: (controller) => {
|
||||
abortControllerRef.current = controller
|
||||
},
|
||||
onWorkflowFinished: (params) => {
|
||||
handleWorkflowFinished(params)
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
if (onWorkflowFinished)
|
||||
onWorkflowFinished(params)
|
||||
if (isInWorkflowDebug) {
|
||||
fetchInspectVars({})
|
||||
invalidAllLastRun()
|
||||
}
|
||||
},
|
||||
onError: (params) => {
|
||||
handleWorkflowFailed()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
|
||||
if (onError)
|
||||
onError(params)
|
||||
},
|
||||
onNodeStarted: (params) => {
|
||||
handleWorkflowNodeStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onNodeStarted)
|
||||
onNodeStarted(params)
|
||||
},
|
||||
onNodeFinished: (params) => {
|
||||
handleWorkflowNodeFinished(params)
|
||||
|
||||
if (onNodeFinished)
|
||||
onNodeFinished(params)
|
||||
},
|
||||
onIterationStart: (params) => {
|
||||
handleWorkflowNodeIterationStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onIterationStart)
|
||||
onIterationStart(params)
|
||||
},
|
||||
onIterationNext: (params) => {
|
||||
handleWorkflowNodeIterationNext(params)
|
||||
|
||||
if (onIterationNext)
|
||||
onIterationNext(params)
|
||||
},
|
||||
onIterationFinish: (params) => {
|
||||
handleWorkflowNodeIterationFinished(params)
|
||||
|
||||
if (onIterationFinish)
|
||||
onIterationFinish(params)
|
||||
},
|
||||
onLoopStart: (params) => {
|
||||
handleWorkflowNodeLoopStarted(
|
||||
params,
|
||||
{
|
||||
clientWidth,
|
||||
clientHeight,
|
||||
},
|
||||
)
|
||||
|
||||
if (onLoopStart)
|
||||
onLoopStart(params)
|
||||
},
|
||||
onLoopNext: (params) => {
|
||||
handleWorkflowNodeLoopNext(params)
|
||||
|
||||
if (onLoopNext)
|
||||
onLoopNext(params)
|
||||
},
|
||||
onLoopFinish: (params) => {
|
||||
handleWorkflowNodeLoopFinished(params)
|
||||
|
||||
if (onLoopFinish)
|
||||
onLoopFinish(params)
|
||||
},
|
||||
onNodeRetry: (params) => {
|
||||
handleWorkflowNodeRetry(params)
|
||||
|
||||
if (onNodeRetry)
|
||||
onNodeRetry(params)
|
||||
},
|
||||
onAgentLog: (params) => {
|
||||
handleWorkflowAgentLog(params)
|
||||
|
||||
if (onAgentLog)
|
||||
onAgentLog(params)
|
||||
},
|
||||
onTextChunk: (params) => {
|
||||
handleWorkflowTextChunk(params)
|
||||
},
|
||||
onTextReplace: (params) => {
|
||||
handleWorkflowTextReplace(params)
|
||||
},
|
||||
onTTSChunk: (messageId: string, audio: string) => {
|
||||
if (!audio || audio === '')
|
||||
return
|
||||
player?.playAudioWithAudio(audio, true)
|
||||
AudioPlayerManager.getInstance().resetMsgId(messageId)
|
||||
},
|
||||
onTTSEnd: (messageId: string, audio: string) => {
|
||||
player?.playAudioWithAudio(audio, false)
|
||||
},
|
||||
onWorkflowPaused: (params) => {
|
||||
handleWorkflowPaused()
|
||||
invalidateRunHistory(runHistoryUrl)
|
||||
if (onWorkflowPaused)
|
||||
onWorkflowPaused(params)
|
||||
const url = `/workflow/${params.workflow_run_id}/events`
|
||||
sseGet(
|
||||
url,
|
||||
{},
|
||||
finalCallbacks,
|
||||
)
|
||||
},
|
||||
onHumanInputRequired: (params) => {
|
||||
handleWorkflowNodeHumanInputRequired(params)
|
||||
if (onHumanInputRequired)
|
||||
onHumanInputRequired(params)
|
||||
},
|
||||
onHumanInputFormFilled: (params) => {
|
||||
handleWorkflowNodeHumanInputFormFilled(params)
|
||||
if (onHumanInputFormFilled)
|
||||
onHumanInputFormFilled(params)
|
||||
},
|
||||
onHumanInputFormTimeout: (params) => {
|
||||
handleWorkflowNodeHumanInputFormTimeout(params)
|
||||
if (onHumanInputFormTimeout)
|
||||
onHumanInputFormTimeout(params)
|
||||
},
|
||||
...restCallback,
|
||||
}
|
||||
})
|
||||
|
||||
ssePost(
|
||||
url,
|
||||
@@ -860,20 +376,13 @@ export const useWorkflowRun = () => {
|
||||
setListeningTriggerNodeId,
|
||||
} = workflowStore.getState()
|
||||
|
||||
setWorkflowRunningData({
|
||||
result: {
|
||||
status: WorkflowRunningStatus.Stopped,
|
||||
inputs_truncated: false,
|
||||
process_data_truncated: false,
|
||||
outputs_truncated: false,
|
||||
},
|
||||
tracing: [],
|
||||
resultText: '',
|
||||
applyStoppedState({
|
||||
setWorkflowRunningData,
|
||||
setIsListening,
|
||||
setShowVariableInspectPanel,
|
||||
setListeningTriggerType,
|
||||
setListeningTriggerNodeId,
|
||||
})
|
||||
setIsListening(false)
|
||||
setListeningTriggerType(null)
|
||||
setListeningTriggerNodeId(null)
|
||||
setShowVariableInspectPanel(true)
|
||||
}
|
||||
|
||||
if (taskId) {
|
||||
@@ -909,7 +418,7 @@ export const useWorkflowRun = () => {
|
||||
}, [workflowStore])
|
||||
|
||||
const handleRestoreFromPublishedWorkflow = useCallback((publishedWorkflow: VersionHistory) => {
|
||||
const nodes = publishedWorkflow.graph.nodes.map(node => ({ ...node, selected: false, data: { ...node.data, selected: false } }))
|
||||
const nodes = normalizePublishedWorkflowNodes(publishedWorkflow)
|
||||
const edges = publishedWorkflow.graph.edges
|
||||
const viewport = publishedWorkflow.graph.viewport!
|
||||
handleUpdateWorkflowCanvas({
|
||||
@@ -917,21 +426,7 @@ export const useWorkflowRun = () => {
|
||||
edges,
|
||||
viewport,
|
||||
})
|
||||
const mappedFeatures = {
|
||||
opening: {
|
||||
enabled: !!publishedWorkflow.features.opening_statement || !!publishedWorkflow.features.suggested_questions.length,
|
||||
opening_statement: publishedWorkflow.features.opening_statement,
|
||||
suggested_questions: publishedWorkflow.features.suggested_questions,
|
||||
},
|
||||
suggested: publishedWorkflow.features.suggested_questions_after_answer,
|
||||
text2speech: publishedWorkflow.features.text_to_speech,
|
||||
speech2text: publishedWorkflow.features.speech_to_text,
|
||||
citation: publishedWorkflow.features.retriever_resource,
|
||||
moderation: publishedWorkflow.features.sensitive_word_avoidance,
|
||||
file: publishedWorkflow.features.file_upload,
|
||||
}
|
||||
|
||||
featuresStore?.setState({ features: mappedFeatures })
|
||||
featuresStore?.setState({ features: mapPublishedWorkflowFeatures(publishedWorkflow) })
|
||||
workflowStore.getState().setEnvironmentVariables(publishedWorkflow.environment_variables || [])
|
||||
}, [featuresStore, handleUpdateWorkflowCanvas, workflowStore])
|
||||
|
||||
|
||||
@@ -9,16 +9,12 @@ import {
|
||||
import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import { FeaturesProvider } from '@/app/components/base/features'
|
||||
import Loading from '@/app/components/base/loading'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import WorkflowWithDefaultContext from '@/app/components/workflow'
|
||||
import {
|
||||
WorkflowContextProvider,
|
||||
} from '@/app/components/workflow/context'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
import { useTriggerStatusStore } from '@/app/components/workflow/store/trigger-status'
|
||||
import {
|
||||
SupportUploadFileTypes,
|
||||
} from '@/app/components/workflow/types'
|
||||
import {
|
||||
initialEdges,
|
||||
initialNodes,
|
||||
@@ -35,6 +31,11 @@ import {
|
||||
useWorkflowInit,
|
||||
} from './hooks/use-workflow-init'
|
||||
import { createWorkflowSlice } from './store/workflow/workflow-slice'
|
||||
import {
|
||||
buildInitialFeatures,
|
||||
buildTriggerStatusMap,
|
||||
coerceReplayUserInputs,
|
||||
} from './utils'
|
||||
|
||||
const WorkflowAppWithAdditionalContext = () => {
|
||||
const {
|
||||
@@ -58,13 +59,7 @@ const WorkflowAppWithAdditionalContext = () => {
|
||||
// Sync trigger statuses to store when data loads
|
||||
useEffect(() => {
|
||||
if (triggersResponse?.data) {
|
||||
// Map API status to EntryNodeStatus: 'enabled' stays 'enabled', all others become 'disabled'
|
||||
const statusMap = triggersResponse.data.reduce((acc, trigger) => {
|
||||
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
|
||||
return acc
|
||||
}, {} as Record<string, 'enabled' | 'disabled'>)
|
||||
|
||||
setTriggerStatuses(statusMap)
|
||||
setTriggerStatuses(buildTriggerStatusMap(triggersResponse.data))
|
||||
}
|
||||
}, [triggersResponse?.data, setTriggerStatuses])
|
||||
|
||||
@@ -108,49 +103,21 @@ const WorkflowAppWithAdditionalContext = () => {
|
||||
fetchRunDetail(runUrl).then((res) => {
|
||||
const { setInputs, setShowInputsPanel, setShowDebugAndPreviewPanel } = workflowStore.getState()
|
||||
const rawInputs = res.inputs
|
||||
let parsedInputs: Record<string, unknown> | null = null
|
||||
let parsedInputs: unknown = rawInputs
|
||||
|
||||
if (typeof rawInputs === 'string') {
|
||||
try {
|
||||
const maybeParsed = JSON.parse(rawInputs) as unknown
|
||||
if (maybeParsed && typeof maybeParsed === 'object' && !Array.isArray(maybeParsed))
|
||||
parsedInputs = maybeParsed as Record<string, unknown>
|
||||
parsedInputs = JSON.parse(rawInputs) as unknown
|
||||
}
|
||||
catch (error) {
|
||||
console.error('Failed to parse workflow run inputs', error)
|
||||
return
|
||||
}
|
||||
}
|
||||
else if (rawInputs && typeof rawInputs === 'object' && !Array.isArray(rawInputs)) {
|
||||
parsedInputs = rawInputs as Record<string, unknown>
|
||||
}
|
||||
|
||||
if (!parsedInputs)
|
||||
return
|
||||
const userInputs = coerceReplayUserInputs(parsedInputs)
|
||||
|
||||
const userInputs: Record<string, string | number | boolean> = {}
|
||||
Object.entries(parsedInputs).forEach(([key, value]) => {
|
||||
if (key.startsWith('sys.'))
|
||||
return
|
||||
|
||||
if (value == null) {
|
||||
userInputs[key] = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
userInputs[key] = value
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
userInputs[key] = JSON.stringify(value)
|
||||
}
|
||||
catch {
|
||||
userInputs[key] = String(value)
|
||||
}
|
||||
})
|
||||
|
||||
if (!Object.keys(userInputs).length)
|
||||
if (!userInputs || !Object.keys(userInputs).length)
|
||||
return
|
||||
|
||||
setInputs(userInputs)
|
||||
@@ -167,32 +134,7 @@ const WorkflowAppWithAdditionalContext = () => {
|
||||
)
|
||||
}
|
||||
|
||||
const features = data.features || {}
|
||||
const initialFeatures: FeaturesData = {
|
||||
file: {
|
||||
image: {
|
||||
enabled: !!features.file_upload?.image?.enabled,
|
||||
number_limits: features.file_upload?.image?.number_limits || 3,
|
||||
transfer_methods: features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
},
|
||||
enabled: !!(features.file_upload?.enabled || features.file_upload?.image?.enabled),
|
||||
allowed_file_types: features.file_upload?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: features.file_upload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
|
||||
allowed_file_upload_methods: features.file_upload?.allowed_file_upload_methods || features.file_upload?.image?.transfer_methods || ['local_file', 'remote_url'],
|
||||
number_limits: features.file_upload?.number_limits || features.file_upload?.image?.number_limits || 3,
|
||||
fileUploadConfig: fileUploadConfigResponse,
|
||||
},
|
||||
opening: {
|
||||
enabled: !!features.opening_statement,
|
||||
opening_statement: features.opening_statement,
|
||||
suggested_questions: features.suggested_questions,
|
||||
},
|
||||
suggested: features.suggested_questions_after_answer || { enabled: false },
|
||||
speech2text: features.speech_to_text || { enabled: false },
|
||||
text2speech: features.text_to_speech || { enabled: false },
|
||||
citation: features.retriever_resource || { enabled: false },
|
||||
moderation: features.sensitive_word_avoidance || { enabled: false },
|
||||
}
|
||||
const initialFeatures: FeaturesData = buildInitialFeatures(data.features, fileUploadConfigResponse)
|
||||
|
||||
return (
|
||||
<WorkflowWithDefaultContext
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createStore } from 'zustand/vanilla'
|
||||
import { createWorkflowSlice } from '../workflow-slice'
|
||||
|
||||
describe('createWorkflowSlice', () => {
|
||||
it('should initialize workflow slice state with expected defaults', () => {
|
||||
const store = createStore(createWorkflowSlice)
|
||||
const state = store.getState()
|
||||
|
||||
expect(state.appId).toBe('')
|
||||
expect(state.appName).toBe('')
|
||||
expect(state.notInitialWorkflow).toBe(false)
|
||||
expect(state.shouldAutoOpenStartNodeSelector).toBe(false)
|
||||
expect(state.nodesDefaultConfigs).toEqual({})
|
||||
expect(state.showOnboarding).toBe(false)
|
||||
expect(state.hasSelectedStartNode).toBe(false)
|
||||
expect(state.hasShownOnboarding).toBe(false)
|
||||
})
|
||||
|
||||
it('should update every workflow slice field through its setters', () => {
|
||||
const store = createStore(createWorkflowSlice)
|
||||
|
||||
store.setState({
|
||||
appId: 'app-1',
|
||||
appName: 'Workflow App',
|
||||
})
|
||||
store.getState().setNotInitialWorkflow(true)
|
||||
store.getState().setShouldAutoOpenStartNodeSelector(true)
|
||||
store.getState().setNodesDefaultConfigs({ start: { title: 'Start' } })
|
||||
store.getState().setShowOnboarding(true)
|
||||
store.getState().setHasSelectedStartNode(true)
|
||||
store.getState().setHasShownOnboarding(true)
|
||||
|
||||
expect(store.getState()).toMatchObject({
|
||||
appId: 'app-1',
|
||||
appName: 'Workflow App',
|
||||
notInitialWorkflow: true,
|
||||
shouldAutoOpenStartNodeSelector: true,
|
||||
nodesDefaultConfigs: { start: { title: 'Start' } },
|
||||
showOnboarding: true,
|
||||
hasSelectedStartNode: true,
|
||||
hasShownOnboarding: true,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,107 @@
|
||||
import type { Features as FeaturesData } from '@/app/components/base/features/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
import { TransferMethod } from '@/types/app'
|
||||
|
||||
type TriggerStatusLike = {
|
||||
node_id: string
|
||||
status: string
|
||||
}
|
||||
|
||||
type FileUploadFeatureLike = {
|
||||
enabled?: boolean
|
||||
allowed_file_types?: SupportUploadFileTypes[]
|
||||
allowed_file_extensions?: string[]
|
||||
allowed_file_upload_methods?: TransferMethod[]
|
||||
number_limits?: number
|
||||
image?: {
|
||||
enabled?: boolean
|
||||
number_limits?: number
|
||||
transfer_methods?: TransferMethod[]
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowFeaturesLike = {
|
||||
file_upload?: FileUploadFeatureLike
|
||||
opening_statement?: string
|
||||
suggested_questions?: string[]
|
||||
suggested_questions_after_answer?: { enabled?: boolean }
|
||||
speech_to_text?: { enabled?: boolean }
|
||||
text_to_speech?: { enabled?: boolean }
|
||||
retriever_resource?: { enabled?: boolean }
|
||||
sensitive_word_avoidance?: { enabled?: boolean }
|
||||
}
|
||||
|
||||
export const buildTriggerStatusMap = (triggers: TriggerStatusLike[]) => {
|
||||
return triggers.reduce<Record<string, 'enabled' | 'disabled'>>((acc, trigger) => {
|
||||
acc[trigger.node_id] = trigger.status === 'enabled' ? 'enabled' : 'disabled'
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
|
||||
export const coerceReplayUserInputs = (rawInputs: unknown): Record<string, string | number | boolean> | null => {
|
||||
if (!rawInputs || typeof rawInputs !== 'object' || Array.isArray(rawInputs))
|
||||
return null
|
||||
|
||||
const userInputs: Record<string, string | number | boolean> = {}
|
||||
|
||||
Object.entries(rawInputs as Record<string, unknown>).forEach(([key, value]) => {
|
||||
if (key.startsWith('sys.'))
|
||||
return
|
||||
|
||||
if (value == null) {
|
||||
userInputs[key] = ''
|
||||
return
|
||||
}
|
||||
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
userInputs[key] = value
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
userInputs[key] = JSON.stringify(value)
|
||||
}
|
||||
catch {
|
||||
userInputs[key] = String(value)
|
||||
}
|
||||
})
|
||||
|
||||
return userInputs
|
||||
}
|
||||
|
||||
export const buildInitialFeatures = (
|
||||
featuresSource: WorkflowFeaturesLike | null | undefined,
|
||||
fileUploadConfigResponse: FileUploadConfigResponse | undefined,
|
||||
): FeaturesData => {
|
||||
const features = featuresSource || {}
|
||||
const fileUpload = features.file_upload
|
||||
const imageUpload = fileUpload?.image
|
||||
|
||||
return {
|
||||
file: {
|
||||
image: {
|
||||
enabled: !!imageUpload?.enabled,
|
||||
number_limits: imageUpload?.number_limits || 3,
|
||||
transfer_methods: imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
},
|
||||
enabled: !!(fileUpload?.enabled || imageUpload?.enabled),
|
||||
allowed_file_types: fileUpload?.allowed_file_types || [SupportUploadFileTypes.image],
|
||||
allowed_file_extensions: fileUpload?.allowed_file_extensions || FILE_EXTS[SupportUploadFileTypes.image].map(ext => `.${ext}`),
|
||||
allowed_file_upload_methods: fileUpload?.allowed_file_upload_methods || imageUpload?.transfer_methods || [TransferMethod.local_file, TransferMethod.remote_url],
|
||||
number_limits: fileUpload?.number_limits || imageUpload?.number_limits || 3,
|
||||
fileUploadConfig: fileUploadConfigResponse,
|
||||
},
|
||||
opening: {
|
||||
enabled: !!features.opening_statement,
|
||||
opening_statement: features.opening_statement,
|
||||
suggested_questions: features.suggested_questions,
|
||||
},
|
||||
suggested: features.suggested_questions_after_answer || { enabled: false },
|
||||
speech2text: features.speech_to_text || { enabled: false },
|
||||
text2speech: features.text_to_speech || { enabled: false },
|
||||
citation: features.retriever_resource || { enabled: false },
|
||||
moderation: features.sensitive_word_avoidance || { enabled: false },
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { renderHook } from '@testing-library/react'
|
||||
import useNodeResizeObserver from '../use-node-resize-observer'
|
||||
|
||||
describe('useNodeResizeObserver', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
it('should observe and disconnect when enabled with a mounted node ref', () => {
|
||||
const observe = vi.fn()
|
||||
const disconnect = vi.fn()
|
||||
|
||||
+10
@@ -57,6 +57,16 @@ describe('before-run-form helpers', () => {
|
||||
values: createValues({ query: '' }),
|
||||
})], [{}], t)).toContain('errorMsg.fieldRequired')
|
||||
|
||||
expect(getFormErrorMessage([createForm({
|
||||
inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile, required: true })],
|
||||
values: createValues({ file: [] }),
|
||||
})], [{}], t)).toContain('errorMsg.fieldRequired')
|
||||
|
||||
expect(getFormErrorMessage([createForm({
|
||||
inputs: [createInput({ variable: 'files', label: 'Files', type: InputVarType.multiFiles, required: true })],
|
||||
values: createValues({ files: [] }),
|
||||
})], [{}], t)).toContain('errorMsg.fieldRequired')
|
||||
|
||||
expect(getFormErrorMessage([createForm({
|
||||
inputs: [createInput({ variable: 'file', label: 'File', type: InputVarType.singleFile })],
|
||||
values: createValues({ file: { transferMethod: TransferMethod.local_file } }),
|
||||
|
||||
@@ -56,7 +56,16 @@ export const getFormErrorMessage = (
|
||||
const missingRequired = input.required
|
||||
&& input.type !== InputVarType.checkbox
|
||||
&& !(input.variable in existVarValuesInForm)
|
||||
&& (value === '' || value === undefined || value === null || (input.type === InputVarType.files && Array.isArray(value) && value.length === 0))
|
||||
&& (
|
||||
value === '' || value === undefined || value === null
|
||||
|| (
|
||||
(input.type === InputVarType.files
|
||||
|| input.type === InputVarType.multiFiles
|
||||
|| input.type === InputVarType.singleFile)
|
||||
&& Array.isArray(value)
|
||||
&& value.length === 0
|
||||
)
|
||||
)
|
||||
|
||||
if (!errMsg && missingRequired) {
|
||||
errMsg = t('errorMsg.fieldRequired', { ns: 'workflow', field: typeof input.label === 'object' ? input.label.variable : input.label })
|
||||
|
||||
+2
-6
@@ -75,16 +75,12 @@ describe('workflow-panel helpers', () => {
|
||||
})
|
||||
|
||||
describe('custom run form fallback', () => {
|
||||
it('should return a fallback message for unsupported custom run form nodes', () => {
|
||||
it('should return null for unsupported custom run form nodes', () => {
|
||||
const form = getCustomRunForm({
|
||||
...createCustomRunFormProps({ type: BlockEnum.Tool }),
|
||||
})
|
||||
|
||||
expect(form).toMatchObject({
|
||||
props: {
|
||||
children: expect.arrayContaining(['Custom Run Form:', ' ', 'not found']),
|
||||
},
|
||||
})
|
||||
expect(form).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -39,14 +39,7 @@ export const getCustomRunForm = (params: CustomRunFormProps): ReactNode => {
|
||||
case BlockEnum.DataSource:
|
||||
return <DataSourceBeforeRunForm {...params} />
|
||||
default:
|
||||
return (
|
||||
<div>
|
||||
Custom Run Form:
|
||||
{nodeType}
|
||||
{' '}
|
||||
not found
|
||||
</div>
|
||||
)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+14
-3
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { useState } from 'react'
|
||||
import GenericTable from '../generic-table'
|
||||
@@ -50,8 +50,19 @@ const advancedColumns = [
|
||||
describe('GenericTable', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const selectOption = async (triggerName: string, optionName: string) => {
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: triggerName }))
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
fireEvent.click(await screen.findByRole('option', { name: optionName }))
|
||||
})
|
||||
}
|
||||
|
||||
it('should render an empty editable row and append a configured row when typing into the virtual row', async () => {
|
||||
const onChange = vi.fn()
|
||||
|
||||
@@ -143,11 +154,11 @@ describe('GenericTable', () => {
|
||||
<ControlledTable />,
|
||||
)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Choose method' }))
|
||||
await user.click(await screen.findByRole('option', { name: 'POST' }))
|
||||
await selectOption('Choose method', 'POST')
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onChange).toHaveBeenCalledWith([{ method: 'post', preview: '' }])
|
||||
expect(screen.getByRole('button', { name: 'POST' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
onChange.mockClear()
|
||||
|
||||
+30
-1
@@ -90,6 +90,22 @@ describe('useVariableModalState', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should keep valid object rows when switching to json mode from form mode', () => {
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange(ChatVarType.Object)
|
||||
result.current.setObjectValue([
|
||||
{ key: '', type: ChatVarType.String, value: undefined },
|
||||
{ key: 'timeout', type: ChatVarType.Number, value: 30 },
|
||||
])
|
||||
result.current.handleEditorChange(true)
|
||||
})
|
||||
|
||||
expect(result.current.editInJSON).toBe(true)
|
||||
expect(result.current.value).toEqual({ timeout: 30 })
|
||||
expect(result.current.editorContent).toBe(JSON.stringify({ timeout: 30 }))
|
||||
})
|
||||
it('should reset object form values when leaving empty json mode', () => {
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions({
|
||||
chatVar: {
|
||||
@@ -141,6 +157,19 @@ describe('useVariableModalState', () => {
|
||||
expect(result.current.editorContent).toBe(JSON.stringify(['True', 'False']))
|
||||
})
|
||||
|
||||
it('should preserve zero values when switching number arrays into json mode', () => {
|
||||
const { result } = renderHook(() => useVariableModalState(createOptions()))
|
||||
|
||||
act(() => {
|
||||
result.current.handleTypeChange(ChatVarType.ArrayNumber)
|
||||
result.current.setValue([0, 2, undefined])
|
||||
result.current.handleEditorChange(true)
|
||||
})
|
||||
|
||||
expect(result.current.editInJSON).toBe(true)
|
||||
expect(result.current.value).toEqual([0, 2])
|
||||
expect(result.current.editorContent).toBe(JSON.stringify([0, 2]))
|
||||
})
|
||||
it('should notify and stop saving when object keys are invalid', () => {
|
||||
const notify = vi.fn()
|
||||
const onSave = vi.fn()
|
||||
@@ -161,7 +190,7 @@ describe('useVariableModalState', () => {
|
||||
result.current.handleSave()
|
||||
})
|
||||
|
||||
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'object key can not be empty' })
|
||||
expect(notify).toHaveBeenCalledWith({ type: 'error', message: 'chatVariable.modal.objectKeyRequired' })
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
+15
@@ -33,6 +33,10 @@ describe('variable-modal helpers', () => {
|
||||
{ key: '', type: ChatVarType.Number, value: 1 },
|
||||
])).toEqual({ apiKey: 'secret' })
|
||||
|
||||
expect(formatObjectValueFromList([
|
||||
{ key: 'count', type: ChatVarType.Number, value: 0 },
|
||||
{ key: 'label', type: ChatVarType.String, value: '' },
|
||||
])).toEqual({ count: 0, label: null })
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: false,
|
||||
objectValue: [{ key: 'enabled', type: ChatVarType.String, value: 'true' }],
|
||||
@@ -54,6 +58,13 @@ describe('variable-modal helpers', () => {
|
||||
value: ['a', '', 'b'],
|
||||
})).toEqual(['a', 'b'])
|
||||
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: false,
|
||||
objectValue: [],
|
||||
type: ChatVarType.ArrayNumber,
|
||||
value: [0, 1, undefined, null, ''] as unknown as Array<number | undefined>,
|
||||
})).toEqual([0, 1])
|
||||
|
||||
expect(formatChatVariableValue({
|
||||
editInJSON: false,
|
||||
objectValue: [],
|
||||
@@ -94,6 +105,10 @@ describe('variable-modal helpers', () => {
|
||||
type: ChatVarType.ArrayBoolean,
|
||||
})).toEqual([true, false, true, false])
|
||||
|
||||
expect(() => parseEditorContent({
|
||||
content: '{"enabled":true}',
|
||||
type: ChatVarType.ArrayBoolean,
|
||||
})).toThrow('JSON array')
|
||||
expect(parseEditorContent({
|
||||
content: '{"enabled":true}',
|
||||
type: ChatVarType.Object,
|
||||
|
||||
+23
-3
@@ -80,7 +80,7 @@ describe('variable-modal', () => {
|
||||
await user.type(screen.getByPlaceholderText('workflow.chatVariable.modal.namePlaceholder'), 'existing_name')
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('name is existed')
|
||||
expect(mockToastError.mock.calls.at(-1)?.[0]).toBe('appDebug.varKeyError.keyAlreadyExists:{"key":"workflow.chatVariable.modal.name"}')
|
||||
expect(onSave).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -100,8 +100,10 @@ describe('variable-modal', () => {
|
||||
expect(screen.getByDisplayValue('secret')).toBeInTheDocument()
|
||||
expect(screen.getByDisplayValue('30')).toBeInTheDocument()
|
||||
|
||||
const timeoutInput = screen.getByDisplayValue('30') as HTMLInputElement
|
||||
await user.clear(screen.getByDisplayValue('secret'))
|
||||
await user.type(screen.getByDisplayValue('30'), '5')
|
||||
await user.clear(timeoutInput)
|
||||
await user.type(timeoutInput, '5')
|
||||
await user.click(screen.getByText('common.operation.save'))
|
||||
|
||||
expect(onSave).toHaveBeenCalledWith({
|
||||
@@ -110,7 +112,7 @@ describe('variable-modal', () => {
|
||||
value_type: ChatVarType.Object,
|
||||
value: {
|
||||
apiKey: null,
|
||||
timeout: 305,
|
||||
timeout: 5,
|
||||
},
|
||||
description: 'settings',
|
||||
})
|
||||
@@ -195,4 +197,22 @@ describe('variable-modal', () => {
|
||||
description: '',
|
||||
})
|
||||
})
|
||||
|
||||
it('should keep the number input empty while editing after the user clears it', async () => {
|
||||
const user = userEvent.setup()
|
||||
renderVariableModal({
|
||||
chatVar: {
|
||||
id: 'var-4',
|
||||
name: 'timeout',
|
||||
description: '',
|
||||
value_type: ChatVarType.Number,
|
||||
value: 3,
|
||||
},
|
||||
})
|
||||
|
||||
const input = screen.getByDisplayValue('3') as HTMLInputElement
|
||||
await user.clear(input)
|
||||
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
+12
-6
@@ -108,7 +108,7 @@ export const useVariableModalState = ({
|
||||
|
||||
if (prev.type === ChatVarType.Object) {
|
||||
if (nextEditInJSON) {
|
||||
const nextValue = !prev.objectValue[0].key ? undefined : formatObjectValueFromList(prev.objectValue)
|
||||
const nextValue = prev.objectValue.some(item => item.key) ? formatObjectValueFromList(prev.objectValue) : undefined
|
||||
nextState.value = nextValue
|
||||
nextState.editorContent = JSON.stringify(nextValue)
|
||||
return nextState
|
||||
@@ -133,8 +133,11 @@ export const useVariableModalState = ({
|
||||
|
||||
if (prev.type === ChatVarType.ArrayString || prev.type === ChatVarType.ArrayNumber) {
|
||||
if (nextEditInJSON) {
|
||||
const nextValue = (Array.isArray(prev.value) && prev.value.length && prev.value.filter(Boolean).length)
|
||||
? prev.value.filter(Boolean)
|
||||
const compactValues = Array.isArray(prev.value)
|
||||
? prev.value.filter(item => item !== null && item !== undefined && item !== '')
|
||||
: []
|
||||
const nextValue = compactValues.length
|
||||
? compactValues
|
||||
: undefined
|
||||
nextState.value = nextValue
|
||||
if (!prev.editorContent)
|
||||
@@ -181,12 +184,15 @@ export const useVariableModalState = ({
|
||||
return
|
||||
|
||||
if (!chatVar && conversationVariables.some(item => item.name === state.name)) {
|
||||
notify({ type: 'error', message: 'name is existed' })
|
||||
notify({
|
||||
type: 'error',
|
||||
message: t('varKeyError.keyAlreadyExists', { ns: 'appDebug', key: t('chatVariable.modal.name', { ns: 'workflow' }) }),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && !!item.value)) {
|
||||
notify({ type: 'error', message: 'object key can not be empty' })
|
||||
if (state.type === ChatVarType.Object && state.objectValue.some(item => !item.key && item.value !== undefined && item.value !== '')) {
|
||||
notify({ type: 'error', message: t('chatVariable.modal.objectKeyRequired', { ns: 'workflow' }) })
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
+6
-2
@@ -72,7 +72,7 @@ export const buildObjectValueItems = (chatVar?: ConversationVariable): ObjectVal
|
||||
export const formatObjectValueFromList = (list: ObjectValueItem[]) => {
|
||||
return list.reduce<Record<string, string | number | null>>((acc, curr) => {
|
||||
if (curr.key)
|
||||
acc[curr.key] = curr.value || null
|
||||
acc[curr.key] = curr.value === '' || curr.value === undefined ? null : curr.value
|
||||
return acc
|
||||
}, {})
|
||||
}
|
||||
@@ -88,6 +88,8 @@ export const formatChatVariableValue = ({
|
||||
type: ChatVarType
|
||||
value: unknown
|
||||
}) => {
|
||||
const compactArrayValue = (items: unknown[]) =>
|
||||
items.filter(item => item !== null && item !== undefined && item !== '')
|
||||
switch (type) {
|
||||
case ChatVarTypeEnum.String:
|
||||
return value || ''
|
||||
@@ -100,7 +102,7 @@ export const formatChatVariableValue = ({
|
||||
case ChatVarTypeEnum.ArrayString:
|
||||
case ChatVarTypeEnum.ArrayNumber:
|
||||
case ChatVarTypeEnum.ArrayObject:
|
||||
return Array.isArray(value) ? value.filter(Boolean) : []
|
||||
return Array.isArray(value) ? compactArrayValue(value) : []
|
||||
case ChatVarTypeEnum.ArrayBoolean:
|
||||
return value || []
|
||||
}
|
||||
@@ -151,6 +153,8 @@ export const parseEditorContent = ({
|
||||
if (type !== ChatVarTypeEnum.ArrayBoolean)
|
||||
return parsed
|
||||
|
||||
if (!Array.isArray(parsed))
|
||||
throw new TypeError('ArrayBoolean editor content must be a JSON array')
|
||||
return parsed
|
||||
.map((item: string | boolean) => {
|
||||
if (item === 'True' || item === 'true' || item === true)
|
||||
|
||||
+4
-1
@@ -138,7 +138,10 @@ export const ValueSection = ({
|
||||
<Input
|
||||
placeholder={t('chatVariable.modal.valuePlaceholder', { ns: 'workflow' }) || ''}
|
||||
value={value as number | undefined}
|
||||
onChange={e => onArrayChange([Number(e.target.value)])}
|
||||
onChange={(e) => {
|
||||
const rawValue = e.target.value
|
||||
onArrayChange([rawValue === '' ? undefined : Number(rawValue)])
|
||||
}}
|
||||
type="number"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6416,11 +6416,8 @@
|
||||
}
|
||||
},
|
||||
"app/components/workflow-app/hooks/use-workflow-run.ts": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 13
|
||||
"count": 5
|
||||
}
|
||||
},
|
||||
"app/components/workflow-app/hooks/use-workflow-template.ts": {
|
||||
|
||||
+7
-7
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.13.2",
|
||||
"version": "1.13.3",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"imports": {
|
||||
@@ -125,15 +125,15 @@
|
||||
"mime": "4.1.0",
|
||||
"mitt": "3.0.1",
|
||||
"negotiator": "1.0.0",
|
||||
"next": "16.2.1",
|
||||
"next": "16.2.3",
|
||||
"next-themes": "0.4.6",
|
||||
"nuqs": "2.8.9",
|
||||
"pinyin-pro": "3.28.0",
|
||||
"qrcode.react": "4.2.0",
|
||||
"qs": "6.15.0",
|
||||
"react": "19.2.4",
|
||||
"react": "19.2.5",
|
||||
"react-18-input-autosize": "3.0.0",
|
||||
"react-dom": "19.2.4",
|
||||
"react-dom": "19.2.5",
|
||||
"react-easy-crop": "5.5.6",
|
||||
"react-hotkeys-hook": "5.2.4",
|
||||
"react-i18next": "16.6.1",
|
||||
@@ -173,8 +173,8 @@
|
||||
"@mdx-js/loader": "3.1.1",
|
||||
"@mdx-js/react": "3.1.1",
|
||||
"@mdx-js/rollup": "3.1.1",
|
||||
"@next/eslint-plugin-next": "16.2.1",
|
||||
"@next/mdx": "16.2.1",
|
||||
"@next/eslint-plugin-next": "16.2.3",
|
||||
"@next/mdx": "16.2.3",
|
||||
"@rgrove/parse-xml": "4.2.0",
|
||||
"@storybook/addon-docs": "10.3.1",
|
||||
"@storybook/addon-links": "10.3.1",
|
||||
@@ -231,7 +231,7 @@
|
||||
"nock": "14.0.11",
|
||||
"postcss": "8.5.8",
|
||||
"postcss-js": "5.1.0",
|
||||
"react-server-dom-webpack": "19.2.4",
|
||||
"react-server-dom-webpack": "19.2.5",
|
||||
"sass": "1.98.0",
|
||||
"storybook": "10.3.1",
|
||||
"tailwindcss": "3.4.19",
|
||||
|
||||
Generated
+533
-533
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user